Update dependencies, enhance long-press functionality, and improve sidebar navigation. Added Fluent icons for folder representation and refined touch interactions for better user experience.

This commit is contained in:
R3D347HR4Y 2026-05-18 09:29:20 +02:00
parent 9b17d4a904
commit 4207b5eb55
13 changed files with 432 additions and 117 deletions

View File

@ -277,3 +277,21 @@ body {
user-select: none;
-webkit-touch-callout: none;
}
/* Touch long-press acknowledgment (contact card, sidebar row menu, …) */
@keyframes long-press-ack {
0% {
transform: scale(1);
}
40% {
transform: scale(0.94);
}
100% {
transform: scale(1);
}
}
.long-press-ack {
animation: long-press-ack 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: center;
}

View File

@ -1,7 +1,7 @@
"use client"
import type { MouseEvent, ReactNode } from "react"
import { useEffect, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
HoverCard,
HoverCardContent,
@ -24,6 +24,10 @@ import {
Video,
} from "lucide-react"
import { useComposeActions } from "@/lib/compose-context"
import { useLongPress } from "@/hooks/use-long-press"
import { useCoarsePointer } from "@/hooks/use-touch-nav"
const HOVER_OPEN_DELAY_MS = 1000
export interface ContactHoverCardProps {
/** Champ expéditeur brut (liste, conversation, etc.) */
@ -47,10 +51,36 @@ export function ContactHoverCard({
}: ContactHoverCardProps) {
const { openComposeWithInitial } = useComposeActions()
const [open, setOpen] = useState(false)
const coarsePointer = useCoarsePointer()
const triggerRef = useRef<HTMLSpanElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const allowHoverOpenRef = useRef(false)
const name = cleanSenderName(displayName)
const email = resolveSenderEmail(displayName, emailOverride)
const color = avatarColor(name)
const openFromLongPress = useCallback(() => {
allowHoverOpenRef.current = true
setOpen(true)
queueMicrotask(() => {
allowHoverOpenRef.current = false
})
}, [])
const longPress = useLongPress(openFromLongPress, {
disabled: !coarsePointer,
delay: HOVER_OPEN_DELAY_MS,
})
const handleOpenChange = useCallback(
(next: boolean) => {
if (coarsePointer && next && !allowHoverOpenRef.current) return
setOpen(next)
},
[coarsePointer]
)
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
@ -65,24 +95,50 @@ export function ContactHoverCard({
}
}, [open])
useEffect(() => {
if (!open || !coarsePointer) return
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target)) return
if (contentRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown, { capture: true })
return () =>
document.removeEventListener("pointerdown", onPointerDown, { capture: true })
}, [open, coarsePointer])
return (
<HoverCard open={open} onOpenChange={setOpen} openDelay={1000} closeDelay={150}>
<HoverCard
open={open}
onOpenChange={handleOpenChange}
openDelay={coarsePointer ? 1_000_000 : HOVER_OPEN_DELAY_MS}
closeDelay={150}
>
<HoverCardTrigger asChild>
<span
ref={triggerRef}
role="presentation"
tabIndex={0}
className={cn(
"inline min-w-0 max-w-full cursor-default text-inherit outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm",
longPress.ackClassName,
className
)}
onClick={(e) => {
onTriggerClick?.(e)
}}
onPointerDown={coarsePointer ? longPress.onPointerDown : undefined}
onPointerUp={coarsePointer ? longPress.onPointerUp : undefined}
onPointerLeave={coarsePointer ? longPress.onPointerLeave : undefined}
onPointerCancel={coarsePointer ? longPress.onPointerCancel : undefined}
onClickCapture={coarsePointer ? longPress.onClickCapture : undefined}
>
{children}
</span>
</HoverCardTrigger>
<HoverCardContent
ref={contentRef}
side={side}
align={align}
sideOffset={8}

View File

@ -123,8 +123,11 @@ import {
type FolderTreeNode,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { normalizeInboxTabSegment } from "@/lib/mail-url"
import {
mailNavVisitKey,
parseMailNavVisitKey,
} from "@/lib/mail-folder-display"
import { DEFAULT_INBOX_TAB, normalizeInboxTabSegment } from "@/lib/mail-url"
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { ContactHoverCard } from "./contact-hover-card"
@ -1743,6 +1746,40 @@ export function EmailList({
[onMailRouteNavigate]
)
const handleBreadcrumbNavigate = useCallback(
(visitKey: string) => {
if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return
const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey)
startTransition(() => {
if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) {
onMailRouteNavigate({
folderId: "inbox",
inboxTab: tab,
page: 1,
mailId: null,
})
return
}
if (onSelectFolder) {
onSelectFolder(folderId)
return
}
onMailRouteNavigate({
folderId,
inboxTab: DEFAULT_INBOX_TAB,
page: 1,
mailId: null,
})
})
},
[
selectedFolder,
inboxTab,
onMailRouteNavigate,
onSelectFolder,
]
)
const goListPrevPage = useCallback(() => {
if (listPage <= 1) return
onMailRouteNavigate({ page: listPage - 1 })
@ -2494,7 +2531,7 @@ export function EmailList({
"relative z-20 flex shrink-0 min-h-12 gap-2 border-b border-gray-200 bg-white py-1.5 pl-2 pr-4",
splitView ? "rounded-none" : "rounded-t-2xl",
isViewMode ? "items-start" : "items-center",
!listToolbarMode && "max-sm:hidden"
(isViewMode ? !listToolbarMode : true) && "max-sm:hidden"
)}
>
@ -4367,6 +4404,7 @@ export function EmailList({
folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows}
onNavigate={handleBreadcrumbNavigate}
/>
</div>
) : null}

View File

@ -14,6 +14,7 @@ type MailFolderStackIndicatorProps = {
folderIdToLabel: Record<string, string>
labelRows?: readonly LabelRowItem[]
className?: string
onNavigate?: (visitKey: string) => void
}
function MailNavIconGlyph({
@ -30,13 +31,12 @@ function MailNavIconGlyph({
[visitKey, folderTree, labelRows]
)
if (resolved.kind === "folder-dot") {
if (resolved.kind === "folder") {
return (
<span
className={cn(
"inline-block size-4 shrink-0 rounded-full",
resolved.colorClass
)}
<Icon
icon={resolved.icon}
className="size-4 shrink-0"
style={{ color: resolved.color }}
aria-hidden
/>
)
@ -68,6 +68,7 @@ export function MailFolderStackIndicator({
folderIdToLabel,
labelRows,
className,
onNavigate,
}: MailFolderStackIndicatorProps) {
const items = useMemo(
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
@ -77,10 +78,8 @@ export function MailFolderStackIndicator({
const ariaLabel = items.map((i) => i.label).join(" · ")
return (
<div
role="status"
aria-live="polite"
aria-label={`Boîte actuelle : ${ariaLabel}`}
<nav
aria-label={`Fil d'Ariane : ${ariaLabel}`}
className={cn(
"flex max-w-[min(360px,calc(100vw-1rem))] items-center",
"border-t border-r border-[#dadce0]/90",
@ -90,7 +89,26 @@ export function MailFolderStackIndicator({
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{items.map((item, i) => (
{items.map((item, i) => {
const isCurrent = item.visitKey === currentKey
const segmentClass = cn(
"flex min-w-0 items-center gap-1.5 rounded-sm outline-none",
onNavigate &&
!isCurrent &&
"cursor-pointer hover:bg-[#f1f3f4] focus-visible:ring-2 focus-visible:ring-[#0b57d0]/40",
onNavigate && isCurrent && "cursor-default"
)
const content = (
<>
<MailNavIconGlyph
visitKey={item.visitKey}
folderTree={folderTree}
labelRows={labelRows}
/>
<span className="truncate">{item.label}</span>
</>
)
return (
<Fragment key={`${item.visitKey}-${i}`}>
{i > 0 ? (
<span
@ -100,17 +118,22 @@ export function MailFolderStackIndicator({
·
</span>
) : null}
<span className="flex min-w-0 items-center gap-1.5">
<MailNavIconGlyph
visitKey={item.visitKey}
folderTree={folderTree}
labelRows={labelRows}
/>
<span className="truncate">{item.label}</span>
</span>
{onNavigate ? (
<button
type="button"
className={segmentClass}
aria-current={isCurrent ? "page" : undefined}
onClick={() => onNavigate(item.visitKey)}
>
{content}
</button>
) : (
<span className={segmentClass}>{content}</span>
)}
</Fragment>
))}
)
})}
</span>
</div>
</nav>
)
}

View File

@ -9,7 +9,6 @@ import {
FileText,
Tag,
ChevronDown,
ChevronRight,
GripVertical,
Pencil,
Plus,
@ -71,6 +70,11 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import {
FOLDER_SECTION_ICON,
folderTreeNavIconName,
navFolderIconColorFromBgClass,
} from "@/lib/folder-nav-icons"
import { UltiMailLogo } from "@/components/ultimail-logo"
import {
SidebarNavOptionsSheet,
@ -275,6 +279,38 @@ function navRowRoundedWhenActive(active: boolean) {
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
}
/** Pastille non-lus : point jaune + ping en haut à droite du picto. */
function SidebarNavIconUnreadDot({ show }: { show: boolean }) {
if (!show) return null
return (
<>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400 opacity-75 motion-reduce:animate-none animate-ping"
aria-hidden
/>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400"
aria-hidden
/>
</>
)
}
function SidebarNavIconSlot({
showUnreadDot,
children,
}: {
showUnreadDot?: boolean
children: ReactNode
}) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
{children}
<SidebarNavIconUnreadDot show={!!showUnreadDot} />
</span>
)
}
/** Mark an element as the nav drag source (opacity via CSS). */
function markNavDragSource(el: HTMLElement | null) {
el?.setAttribute("data-nav-drag-source", "true")
@ -305,6 +341,29 @@ function navRowActivate(
}
}
function FolderTreeNavIcon({
hasChildren,
open,
colorBgClass,
className,
style,
}: {
hasChildren: boolean
open: boolean
colorBgClass: string
className?: string
style?: CSSProperties
}) {
return (
<Icon
icon={folderTreeNavIconName(hasChildren, open)}
className={cn("h-5 w-5 shrink-0", className)}
style={{ color: navFolderIconColorFromBgClass(colorBgClass), ...style }}
aria-hidden
/>
)
}
function SidebarNavDragHandle({
label,
onDragStart,
@ -330,6 +389,21 @@ function SidebarNavDragHandle({
)
}
const OVERFLOW_COUNT_HOVER_HIDE = {
folderrow: "group-hover/folderrow:opacity-0",
labelrow: "group-hover/labelrow:opacity-0",
catnav: "group-hover/catnav:opacity-0",
} as const
const OVERFLOW_MENU_HOVER_SHOW = {
folderrow:
"group-hover/folderrow:opacity-100 group-has-[button:focus-visible]/folderrow:opacity-100",
labelrow:
"group-hover/labelrow:opacity-100 group-has-[button:focus-visible]/labelrow:opacity-100",
catnav:
"group-hover/catnav:opacity-100 group-has-[button:focus-visible]/catnav:opacity-100",
} as const
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
function SidebarOverflowColumn({
unread,
@ -367,9 +441,6 @@ function SidebarOverflowColumn({
)
}
const countHoverHide = `group-hover/${hoverGroup}:opacity-0`
const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
{unread > 0 && (
@ -378,7 +449,7 @@ function SidebarOverflowColumn({
"pointer-events-none absolute inset-0 flex items-center justify-center text-xs tabular-nums leading-none transition-opacity duration-150",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold",
menuOpen ? "opacity-0" : cn("opacity-100", countHoverHide)
menuOpen ? "opacity-0" : OVERFLOW_COUNT_HOVER_HIDE[hoverGroup]
)}
>
{formatCount(unread)}
@ -386,8 +457,8 @@ function SidebarOverflowColumn({
)}
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-150",
menuOpen ? "opacity-100" : cn("opacity-0", menuHoverShow)
"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-150",
menuOpen ? "opacity-100" : OVERFLOW_MENU_HOVER_SHOW[hoverGroup]
)}
>
{children}
@ -437,6 +508,9 @@ function CategoryNavRow({
}
}
const rowHoverHeld =
!isHiddenRow && !isSelected && !isOver && (menuOpen || sheetOpen)
const rowIcon = item.icon ? (
<Icon
icon={item.icon}
@ -551,11 +625,13 @@ function CategoryNavRow({
{...touchRowProps}
className={cn(
"group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver),
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
: rowHoverHeld
? "bg-gray-100 text-gray-900"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100",
@ -571,7 +647,9 @@ function CategoryNavRow({
showCategoryMenu ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
{rowIcon}
</SidebarNavIconSlot>
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
@ -603,6 +681,7 @@ function CategoryNavRow({
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
@ -1398,38 +1477,6 @@ export function Sidebar({
onDragEnd={clearNavDrag}
/>
) : null}
{hasChildren ? (
<button
type="button"
draggable={false}
className={cn(
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected && "text-gray-900"
)}
aria-expanded={isBranchOpen}
aria-label={
isBranchOpen
? `Replier le dossier ${node.label}`
: `Déplier le dossier ${node.label}`
}
onClick={(e) => {
e.preventDefault()
toggleFolderExpanded(node.id)
}}
>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-transform duration-200",
isBranchOpen && "rotate-90"
)}
/>
</button>
) : (
<span
className="inline-flex h-8 w-5 shrink-0 items-center justify-center"
aria-hidden
/>
)}
<div
role="button"
tabIndex={0}
@ -1449,9 +1496,40 @@ export function Sidebar({
: "text-gray-700"
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<span className={cn("block h-3 w-3 rounded-sm", dotClass)} />
</span>
{hasChildren ? (
<button
type="button"
draggable={false}
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50"
aria-expanded={isBranchOpen}
aria-label={
isBranchOpen
? `Replier le dossier ${node.label}`
: `Déplier le dossier ${node.label}`
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleFolderExpanded(node.id)
}}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren
open={isBranchOpen}
colorBgClass={dotClass}
/>
</SidebarNavIconSlot>
</button>
) : (
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren={false}
open={false}
colorBgClass={dotClass}
/>
</SidebarNavIconSlot>
)}
<div className="flex min-w-0 flex-1 items-baseline gap-3">
<span className="min-w-0 flex-1 truncate leading-5">
<span
@ -1687,12 +1765,8 @@ export function Sidebar({
)
}
const renderExpandedFolderSubtree = (
nodes: FolderTreeNode[],
depth: number
): ReactNode =>
nodes
.filter((node) => {
const sidebarVisibleFolderNodes = (nodes: FolderTreeNode[]) =>
nodes.filter((node) => {
const p = getNavItemPrefs(node.id)
if (p.sidebar === "hide") return false
if (
@ -1703,7 +1777,12 @@ export function Sidebar({
}
return true
})
.map((node) => {
const renderExpandedFolderSubtree = (
nodes: FolderTreeNode[],
depth: number
): ReactNode =>
sidebarVisibleFolderNodes(nodes).map((node) => {
const isBranchOpen = expandedFolderIds.has(node.id)
const kids = node.children
return (
@ -1722,6 +1801,7 @@ export function Sidebar({
const FolderButtonCollapsed = ({ node }: { node: FolderTreeNode }) => {
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
const dotClass = node.color ?? "bg-gray-400"
const hasChildFolders = !!(node.children?.length)
const isHighlighted = folderSubtreeContainsId(node, selectedFolder)
const unread = folderUnreadCounts[node.id] ?? 0
const hasUnread = unread > 0
@ -1749,22 +1829,32 @@ export function Sidebar({
: "text-gray-700 hover:bg-gray-100"
)}
>
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
{hasUnread && (
<span
className={cn(
"absolute block h-3 w-3 rounded-sm opacity-75 animate-ping",
dotClass
)}
aria-hidden
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren={hasChildFolders}
open={false}
colorBgClass={dotClass}
/>
)}
<span className={cn("relative block h-3 w-3 rounded-sm", dotClass)} />
</span>
</SidebarNavIconSlot>
</button>
)
}
/** Rail repliée : mêmes dossiers visibles que lorsque les branches sont dépliées. */
const renderCollapsedFolderList = (nodes: FolderTreeNode[]): ReactNode => {
const walk = (list: FolderTreeNode[]): ReactNode[] => {
const out: ReactNode[] = []
for (const node of sidebarVisibleFolderNodes(list)) {
out.push(<FolderButtonCollapsed key={node.id} node={node} />)
if (node.children?.length && expandedFolderIds.has(node.id)) {
out.push(...walk(node.children))
}
}
return out
}
return walk(nodes)
}
const LabelItemRow = ({
item,
unreadCount,
@ -2155,11 +2245,11 @@ export function Sidebar({
labelRowExpanded ? "pr-1" : "pr-3"
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<span
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
/>
</span>
</SidebarNavIconSlot>
{labelRowExpanded && (
<span
className={cn(
@ -2559,7 +2649,11 @@ export function Sidebar({
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
title={!isExpanded ? "Dossiers" : undefined}
>
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
<Icon
icon={FOLDER_SECTION_ICON}
className="h-5 w-5 shrink-0 text-gray-600"
aria-hidden
/>
{isExpanded && (
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
Dossiers
@ -2584,9 +2678,7 @@ export function Sidebar({
{isExpanded
? renderExpandedFolderSubtree(folderTree, 0)
: folderTree.map((node) => (
<FolderButtonCollapsed key={node.id} node={node} />
))}
: renderCollapsedFolderList(folderTree)}
</div>
{/* Labels */}

View File

@ -2,6 +2,7 @@
import { useCallback, useRef, useState } from "react"
import { useLongPress } from "@/hooks/use-long-press"
import { cn } from "@/lib/utils"
/** Long-press / context-menu handlers to open a sidebar row options bottom sheet. */
export function useSidebarTouchOptionsMenu(enabled: boolean) {
@ -32,7 +33,9 @@ export function useSidebarTouchOptionsMenu(enabled: boolean) {
}
: {}
const touchRowClassName = enabled ? "select-none" : undefined
const touchRowClassName = enabled
? cn("select-none", longPress.ackClassName)
: undefined
const closeSheet = useCallback(() => setSheetOpen(false), [])

View File

@ -1,15 +1,30 @@
import { useCallback, useRef } from "react"
import { useCallback, useRef, useState } from "react"
const DEFAULT_DELAY_MS = 500
const ACK_MS = 280
/** Applied briefly when a long-press action fires (touch feedback). */
export const LONG_PRESS_ACK_CLASS = "long-press-ack"
export function useLongPress(
onLongPress: () => void,
options?: { delay?: number; disabled?: boolean }
options?: { delay?: number; disabled?: boolean; ack?: boolean }
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const ackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const firedRef = useRef(false)
const delay = options?.delay ?? DEFAULT_DELAY_MS
const disabled = options?.disabled ?? false
const withAck = options?.ack ?? true
const [ack, setAck] = useState(false)
const clearAck = useCallback(() => {
if (ackTimerRef.current) {
clearTimeout(ackTimerRef.current)
ackTimerRef.current = null
}
setAck(false)
}, [])
const clear = useCallback(() => {
if (timerRef.current) {
@ -18,6 +33,16 @@ export function useLongPress(
}
}, [])
const pulseAck = useCallback(() => {
if (!withAck) return
clearAck()
setAck(true)
ackTimerRef.current = setTimeout(() => {
setAck(false)
ackTimerRef.current = null
}, ACK_MS)
}, [clearAck, withAck])
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return
@ -26,10 +51,11 @@ export function useLongPress(
clear()
timerRef.current = setTimeout(() => {
firedRef.current = true
pulseAck()
onLongPress()
}, delay)
},
[clear, delay, disabled, onLongPress]
[clear, delay, disabled, onLongPress, pulseAck]
)
const onClickCapture = useCallback(
@ -48,5 +74,7 @@ export function useLongPress(
onPointerLeave: clear,
onPointerCancel: clear,
onClickCapture,
ackActive: ack,
ackClassName: ack ? LONG_PRESS_ACK_CLASS : undefined,
}
}

25
lib/folder-nav-icons.ts Normal file
View File

@ -0,0 +1,25 @@
import { addCollection } from "@iconify/react"
import { icons as fluentIcons } from "@iconify-json/fluent"
import { navFolderIconColorFromBgClass } from "@/lib/label-pill-contrast"
addCollection(fluentIcons)
export { navFolderIconColorFromBgClass }
/** Icône Fluent dossier (sidebar + fil dAriane). */
export function folderTreeNavIconName(
hasChildren: boolean,
open: boolean
): string {
if (!hasChildren) return "fluent:folder-20-filled"
return open
? "fluent:folder-open-20-filled"
: "fluent:folder-list-20-filled"
}
/** Fil dAriane : toujours la variante fermée. */
export function folderTreeNavIconNameClosed(hasChildren: boolean): string {
return folderTreeNavIconName(hasChildren, false)
}
export const FOLDER_SECTION_ICON = "fluent:folder-mail-20-filled"

View File

@ -202,6 +202,16 @@ export function tailwindBgUtilityToHex(bgClass: string): string | null {
return TW_BG_HEX.get(bgClass.trim()) ?? null
}
/** Couleur dicône dossier sidebar : `bg-*` Tailwind ou `bg-[#hex]`. */
export function navFolderIconColorFromBgClass(bgClass: string): string {
const trimmed = bgClass.trim()
const mapped = tailwindBgUtilityToHex(trimmed)
if (mapped) return mapped
const arbitrary = trimmed.match(/^bg-\[(#[\da-fA-F]{3,8})\]$/)
if (arbitrary?.[1]) return arbitrary[1]
return "#9ca3af"
}
/** Classes Tailwind `text-[...]` pour pastille sur fond `bg-*`. */
export function labelPillTextClassForTailwindBgUtility(bgClass: string): string {
const hex = tailwindBgUtilityToHex(bgClass)

View File

@ -9,8 +9,11 @@ import {
ClockArrowUp,
ShieldAlert,
Trash2,
Folder,
} from "lucide-react"
import {
folderTreeNavIconNameClosed,
navFolderIconColorFromBgClass,
} from "@/lib/folder-nav-icons"
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
import { defaultNavLabelRowsSnapshot, labelRowById } from "@/lib/sidebar-nav-data"
@ -32,7 +35,7 @@ const SYSTEM_ICONS: Record<string, LucideIcon> = {
export type MailNavIcon =
| { kind: "lucide"; Icon: LucideIcon }
| { kind: "folder-dot"; colorClass: string }
| { kind: "folder"; icon: string; color: string }
| { kind: "iconify"; icon: string }
export function resolveMailNavIcon(
@ -59,8 +62,16 @@ export function resolveMailNavIcon(
const path = findFolderPath(folderTree, folderId)
if (path?.length) {
const leaf = path[path.length - 1]!
return { kind: "folder-dot", colorClass: leaf.color ?? "bg-slate-400" }
return {
kind: "folder",
icon: folderTreeNavIconNameClosed(!!leaf.children?.length),
color: navFolderIconColorFromBgClass(leaf.color ?? "bg-slate-400"),
}
}
return { kind: "lucide", Icon: Folder }
return {
kind: "folder",
icon: folderTreeNavIconNameClosed(false),
color: navFolderIconColorFromBgClass("bg-slate-400"),
}
}

View File

@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
"@iconify-json/cbi": "^1.2.36",
"@iconify-json/fluent": "^1.2.47",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.82",

View File

@ -20,6 +20,9 @@ importers:
'@iconify-json/cbi':
specifier: ^1.2.36
version: 1.2.36
'@iconify-json/fluent':
specifier: ^1.2.47
version: 1.2.47
'@iconify-json/logos':
specifier: ^1.2.11
version: 1.2.11
@ -292,6 +295,9 @@ packages:
'@iconify-json/cbi@1.2.36':
resolution: {integrity: sha512-0rQ0VFkZqzIlj0ffJAzoxGgwUGoHCmCFl+btWuHquUnyqFfLIYXy34G1QJISIvQO9h6w4LffKh1Mffs3geSylw==}
'@iconify-json/fluent@1.2.47':
resolution: {integrity: sha512-YA9pCYNW3bqXMD1rMIkK0vqLK90UyE63hfI1cB2sQwGAbEFhi+VUz5mvcXYfb7bl5R8N5sLZNI2Kr0Q3Yo9M4A==}
'@iconify-json/logos@1.2.11':
resolution: {integrity: sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==}
@ -2112,6 +2118,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/fluent@1.2.47':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/logos@1.2.11':
dependencies:
'@iconify/types': 2.0.0

File diff suppressed because one or more lines are too long