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:
parent
9b17d4a904
commit
4207b5eb55
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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), [])
|
||||
|
||||
|
||||
@ -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
25
lib/folder-nav-icons.ts
Normal 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 d’Ariane). */
|
||||
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 d’Ariane : 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"
|
||||
@ -202,6 +202,16 @@ export function tailwindBgUtilityToHex(bgClass: string): string | null {
|
||||
return TW_BG_HEX.get(bgClass.trim()) ?? null
|
||||
}
|
||||
|
||||
/** Couleur d’icô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)
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user