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;
|
user-select: none;
|
||||||
-webkit-touch-callout: 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"
|
"use client"
|
||||||
|
|
||||||
import type { MouseEvent, ReactNode } from "react"
|
import type { MouseEvent, ReactNode } from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@ -24,6 +24,10 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useComposeActions } from "@/lib/compose-context"
|
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 {
|
export interface ContactHoverCardProps {
|
||||||
/** Champ expéditeur brut (liste, conversation, etc.) */
|
/** Champ expéditeur brut (liste, conversation, etc.) */
|
||||||
@ -47,10 +51,36 @@ export function ContactHoverCard({
|
|||||||
}: ContactHoverCardProps) {
|
}: ContactHoverCardProps) {
|
||||||
const { openComposeWithInitial } = useComposeActions()
|
const { openComposeWithInitial } = useComposeActions()
|
||||||
const [open, setOpen] = useState(false)
|
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 name = cleanSenderName(displayName)
|
||||||
const email = resolveSenderEmail(displayName, emailOverride)
|
const email = resolveSenderEmail(displayName, emailOverride)
|
||||||
const color = avatarColor(name)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const close = () => setOpen(false)
|
const close = () => setOpen(false)
|
||||||
@ -65,24 +95,50 @@ export function ContactHoverCard({
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [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 (
|
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>
|
<HoverCardTrigger asChild>
|
||||||
<span
|
<span
|
||||||
|
ref={triggerRef}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onTriggerClick?.(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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
|
ref={contentRef}
|
||||||
side={side}
|
side={side}
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
|||||||
@ -123,8 +123,11 @@ import {
|
|||||||
type FolderTreeNode,
|
type FolderTreeNode,
|
||||||
type LabelRowItem,
|
type LabelRowItem,
|
||||||
} from "@/lib/sidebar-nav-data"
|
} from "@/lib/sidebar-nav-data"
|
||||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
import {
|
||||||
import { normalizeInboxTabSegment } from "@/lib/mail-url"
|
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 { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
||||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||||
import { ContactHoverCard } from "./contact-hover-card"
|
import { ContactHoverCard } from "./contact-hover-card"
|
||||||
@ -1743,6 +1746,40 @@ export function EmailList({
|
|||||||
[onMailRouteNavigate]
|
[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(() => {
|
const goListPrevPage = useCallback(() => {
|
||||||
if (listPage <= 1) return
|
if (listPage <= 1) return
|
||||||
onMailRouteNavigate({ page: listPage - 1 })
|
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",
|
"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",
|
splitView ? "rounded-none" : "rounded-t-2xl",
|
||||||
isViewMode ? "items-start" : "items-center",
|
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}
|
folderTree={sidebarNav.folderTree}
|
||||||
folderIdToLabel={sidebarNav.folderIdToLabel}
|
folderIdToLabel={sidebarNav.folderIdToLabel}
|
||||||
labelRows={sidebarNav.labelRows}
|
labelRows={sidebarNav.labelRows}
|
||||||
|
onNavigate={handleBreadcrumbNavigate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ type MailFolderStackIndicatorProps = {
|
|||||||
folderIdToLabel: Record<string, string>
|
folderIdToLabel: Record<string, string>
|
||||||
labelRows?: readonly LabelRowItem[]
|
labelRows?: readonly LabelRowItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (visitKey: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailNavIconGlyph({
|
function MailNavIconGlyph({
|
||||||
@ -30,13 +31,12 @@ function MailNavIconGlyph({
|
|||||||
[visitKey, folderTree, labelRows]
|
[visitKey, folderTree, labelRows]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (resolved.kind === "folder-dot") {
|
if (resolved.kind === "folder") {
|
||||||
return (
|
return (
|
||||||
<span
|
<Icon
|
||||||
className={cn(
|
icon={resolved.icon}
|
||||||
"inline-block size-4 shrink-0 rounded-full",
|
className="size-4 shrink-0"
|
||||||
resolved.colorClass
|
style={{ color: resolved.color }}
|
||||||
)}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -68,6 +68,7 @@ export function MailFolderStackIndicator({
|
|||||||
folderIdToLabel,
|
folderIdToLabel,
|
||||||
labelRows,
|
labelRows,
|
||||||
className,
|
className,
|
||||||
|
onNavigate,
|
||||||
}: MailFolderStackIndicatorProps) {
|
}: MailFolderStackIndicatorProps) {
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
|
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
|
||||||
@ -77,10 +78,8 @@ export function MailFolderStackIndicator({
|
|||||||
const ariaLabel = items.map((i) => i.label).join(" · ")
|
const ariaLabel = items.map((i) => i.label).join(" · ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<nav
|
||||||
role="status"
|
aria-label={`Fil d'Ariane : ${ariaLabel}`}
|
||||||
aria-live="polite"
|
|
||||||
aria-label={`Boîte actuelle : ${ariaLabel}`}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[min(360px,calc(100vw-1rem))] items-center",
|
"flex max-w-[min(360px,calc(100vw-1rem))] items-center",
|
||||||
"border-t border-r border-[#dadce0]/90",
|
"border-t border-r border-[#dadce0]/90",
|
||||||
@ -90,27 +89,51 @@ export function MailFolderStackIndicator({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => {
|
||||||
<Fragment key={`${item.visitKey}-${i}`}>
|
const isCurrent = item.visitKey === currentKey
|
||||||
{i > 0 ? (
|
const segmentClass = cn(
|
||||||
<span
|
"flex min-w-0 items-center gap-1.5 rounded-sm outline-none",
|
||||||
className="shrink-0 text-xs leading-none text-[#9aa0a6]"
|
onNavigate &&
|
||||||
aria-hidden
|
!isCurrent &&
|
||||||
>
|
"cursor-pointer hover:bg-[#f1f3f4] focus-visible:ring-2 focus-visible:ring-[#0b57d0]/40",
|
||||||
·
|
onNavigate && isCurrent && "cursor-default"
|
||||||
</span>
|
)
|
||||||
) : null}
|
const content = (
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<>
|
||||||
<MailNavIconGlyph
|
<MailNavIconGlyph
|
||||||
visitKey={item.visitKey}
|
visitKey={item.visitKey}
|
||||||
folderTree={folderTree}
|
folderTree={folderTree}
|
||||||
labelRows={labelRows}
|
labelRows={labelRows}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</span>
|
</>
|
||||||
</Fragment>
|
)
|
||||||
))}
|
return (
|
||||||
|
<Fragment key={`${item.visitKey}-${i}`}>
|
||||||
|
{i > 0 ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs leading-none text-[#9aa0a6]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{onNavigate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={segmentClass}
|
||||||
|
aria-current={isCurrent ? "page" : undefined}
|
||||||
|
onClick={() => onNavigate(item.visitKey)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={segmentClass}>{content}</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Tag,
|
Tag,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@ -71,6 +70,11 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Icon, addCollection } from "@iconify/react"
|
import { Icon, addCollection } from "@iconify/react"
|
||||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
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 { UltiMailLogo } from "@/components/ultimail-logo"
|
||||||
import {
|
import {
|
||||||
SidebarNavOptionsSheet,
|
SidebarNavOptionsSheet,
|
||||||
@ -275,6 +279,38 @@ function navRowRoundedWhenActive(active: boolean) {
|
|||||||
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
|
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). */
|
/** Mark an element as the nav drag source (opacity via CSS). */
|
||||||
function markNavDragSource(el: HTMLElement | null) {
|
function markNavDragSource(el: HTMLElement | null) {
|
||||||
el?.setAttribute("data-nav-drag-source", "true")
|
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({
|
function SidebarNavDragHandle({
|
||||||
label,
|
label,
|
||||||
onDragStart,
|
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). */
|
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
|
||||||
function SidebarOverflowColumn({
|
function SidebarOverflowColumn({
|
||||||
unread,
|
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 (
|
return (
|
||||||
<div className={cn("relative h-8 w-8 shrink-0", className)}>
|
<div className={cn("relative h-8 w-8 shrink-0", className)}>
|
||||||
{unread > 0 && (
|
{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",
|
"pointer-events-none absolute inset-0 flex items-center justify-center text-xs tabular-nums leading-none transition-opacity duration-150",
|
||||||
isSelected && "font-medium",
|
isSelected && "font-medium",
|
||||||
hasUnread && !isSelected && "font-semibold",
|
hasUnread && !isSelected && "font-semibold",
|
||||||
menuOpen ? "opacity-0" : cn("opacity-100", countHoverHide)
|
menuOpen ? "opacity-0" : OVERFLOW_COUNT_HOVER_HIDE[hoverGroup]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCount(unread)}
|
{formatCount(unread)}
|
||||||
@ -386,8 +457,8 @@ function SidebarOverflowColumn({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 flex items-center justify-center transition-opacity duration-150",
|
"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-150",
|
||||||
menuOpen ? "opacity-100" : cn("opacity-0", menuHoverShow)
|
menuOpen ? "opacity-100" : OVERFLOW_MENU_HOVER_SHOW[hoverGroup]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -437,6 +508,9 @@ function CategoryNavRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowHoverHeld =
|
||||||
|
!isHiddenRow && !isSelected && !isOver && (menuOpen || sheetOpen)
|
||||||
|
|
||||||
const rowIcon = item.icon ? (
|
const rowIcon = item.icon ? (
|
||||||
<Icon
|
<Icon
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
@ -551,14 +625,16 @@ function CategoryNavRow({
|
|||||||
{...touchRowProps}
|
{...touchRowProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors",
|
"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
|
isSelected
|
||||||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||||||
: isOver
|
: isOver
|
||||||
? "bg-yellow-100 text-gray-900"
|
? "bg-yellow-100 text-gray-900"
|
||||||
: hasUnread
|
: rowHoverHeld
|
||||||
? "text-gray-900 hover:bg-gray-100"
|
? "bg-gray-100 text-gray-900"
|
||||||
: "text-gray-700 hover:bg-gray-100",
|
: hasUnread
|
||||||
|
? "text-gray-900 hover:bg-gray-100"
|
||||||
|
: "text-gray-700 hover:bg-gray-100",
|
||||||
touchRowClassName
|
touchRowClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -571,7 +647,9 @@ function CategoryNavRow({
|
|||||||
showCategoryMenu ? "pr-1" : "pr-3"
|
showCategoryMenu ? "pr-1" : "pr-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{rowIcon}
|
<SidebarNavIconSlot showUnreadDot={hasUnread}>
|
||||||
|
{rowIcon}
|
||||||
|
</SidebarNavIconSlot>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
@ -603,6 +681,7 @@ function CategoryNavRow({
|
|||||||
hoverGroup="catnav"
|
hoverGroup="catnav"
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
hasUnread={hasUnread}
|
hasUnread={hasUnread}
|
||||||
|
className="mr-[-7px]"
|
||||||
showMenuButton={!touchNav}
|
showMenuButton={!touchNav}
|
||||||
>
|
>
|
||||||
{!touchNav && (
|
{!touchNav && (
|
||||||
@ -1398,38 +1477,6 @@ export function Sidebar({
|
|||||||
onDragEnd={clearNavDrag}
|
onDragEnd={clearNavDrag}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -1449,9 +1496,40 @@ export function Sidebar({
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
{hasChildren ? (
|
||||||
<span className={cn("block h-3 w-3 rounded-sm", dotClass)} />
|
<button
|
||||||
</span>
|
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">
|
<div className="flex min-w-0 flex-1 items-baseline gap-3">
|
||||||
<span className="min-w-0 flex-1 truncate leading-5">
|
<span className="min-w-0 flex-1 truncate leading-5">
|
||||||
<span
|
<span
|
||||||
@ -1687,23 +1765,24 @@ export function Sidebar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sidebarVisibleFolderNodes = (nodes: FolderTreeNode[]) =>
|
||||||
|
nodes.filter((node) => {
|
||||||
|
const p = getNavItemPrefs(node.id)
|
||||||
|
if (p.sidebar === "hide") return false
|
||||||
|
if (
|
||||||
|
p.sidebar === "showUnread" &&
|
||||||
|
(folderUnreadCounts[node.id] ?? 0) === 0
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const renderExpandedFolderSubtree = (
|
const renderExpandedFolderSubtree = (
|
||||||
nodes: FolderTreeNode[],
|
nodes: FolderTreeNode[],
|
||||||
depth: number
|
depth: number
|
||||||
): ReactNode =>
|
): ReactNode =>
|
||||||
nodes
|
sidebarVisibleFolderNodes(nodes).map((node) => {
|
||||||
.filter((node) => {
|
|
||||||
const p = getNavItemPrefs(node.id)
|
|
||||||
if (p.sidebar === "hide") return false
|
|
||||||
if (
|
|
||||||
p.sidebar === "showUnread" &&
|
|
||||||
(folderUnreadCounts[node.id] ?? 0) === 0
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((node) => {
|
|
||||||
const isBranchOpen = expandedFolderIds.has(node.id)
|
const isBranchOpen = expandedFolderIds.has(node.id)
|
||||||
const kids = node.children
|
const kids = node.children
|
||||||
return (
|
return (
|
||||||
@ -1722,6 +1801,7 @@ export function Sidebar({
|
|||||||
const FolderButtonCollapsed = ({ node }: { node: FolderTreeNode }) => {
|
const FolderButtonCollapsed = ({ node }: { node: FolderTreeNode }) => {
|
||||||
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
|
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
|
||||||
const dotClass = node.color ?? "bg-gray-400"
|
const dotClass = node.color ?? "bg-gray-400"
|
||||||
|
const hasChildFolders = !!(node.children?.length)
|
||||||
const isHighlighted = folderSubtreeContainsId(node, selectedFolder)
|
const isHighlighted = folderSubtreeContainsId(node, selectedFolder)
|
||||||
const unread = folderUnreadCounts[node.id] ?? 0
|
const unread = folderUnreadCounts[node.id] ?? 0
|
||||||
const hasUnread = unread > 0
|
const hasUnread = unread > 0
|
||||||
@ -1749,22 +1829,32 @@ export function Sidebar({
|
|||||||
: "text-gray-700 hover:bg-gray-100"
|
: "text-gray-700 hover:bg-gray-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
|
<SidebarNavIconSlot showUnreadDot={hasUnread}>
|
||||||
{hasUnread && (
|
<FolderTreeNavIcon
|
||||||
<span
|
hasChildren={hasChildFolders}
|
||||||
className={cn(
|
open={false}
|
||||||
"absolute block h-3 w-3 rounded-sm opacity-75 animate-ping",
|
colorBgClass={dotClass}
|
||||||
dotClass
|
/>
|
||||||
)}
|
</SidebarNavIconSlot>
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className={cn("relative block h-3 w-3 rounded-sm", dotClass)} />
|
|
||||||
</span>
|
|
||||||
</button>
|
</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 = ({
|
const LabelItemRow = ({
|
||||||
item,
|
item,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
@ -2155,11 +2245,11 @@ export function Sidebar({
|
|||||||
labelRowExpanded ? "pr-1" : "pr-3"
|
labelRowExpanded ? "pr-1" : "pr-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
<SidebarNavIconSlot showUnreadDot={hasUnread}>
|
||||||
<span
|
<span
|
||||||
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
|
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
|
||||||
/>
|
/>
|
||||||
</span>
|
</SidebarNavIconSlot>
|
||||||
{labelRowExpanded && (
|
{labelRowExpanded && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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"
|
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}
|
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 && (
|
{isExpanded && (
|
||||||
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
|
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
|
||||||
Dossiers
|
Dossiers
|
||||||
@ -2584,9 +2678,7 @@ export function Sidebar({
|
|||||||
|
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? renderExpandedFolderSubtree(folderTree, 0)
|
? renderExpandedFolderSubtree(folderTree, 0)
|
||||||
: folderTree.map((node) => (
|
: renderCollapsedFolderList(folderTree)}
|
||||||
<FolderButtonCollapsed key={node.id} node={node} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useRef, useState } from "react"
|
import { useCallback, useRef, useState } from "react"
|
||||||
import { useLongPress } from "@/hooks/use-long-press"
|
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. */
|
/** Long-press / context-menu handlers to open a sidebar row options bottom sheet. */
|
||||||
export function useSidebarTouchOptionsMenu(enabled: boolean) {
|
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), [])
|
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 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(
|
export function useLongPress(
|
||||||
onLongPress: () => void,
|
onLongPress: () => void,
|
||||||
options?: { delay?: number; disabled?: boolean }
|
options?: { delay?: number; disabled?: boolean; ack?: boolean }
|
||||||
) {
|
) {
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const ackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const firedRef = useRef(false)
|
const firedRef = useRef(false)
|
||||||
const delay = options?.delay ?? DEFAULT_DELAY_MS
|
const delay = options?.delay ?? DEFAULT_DELAY_MS
|
||||||
const disabled = options?.disabled ?? false
|
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(() => {
|
const clear = useCallback(() => {
|
||||||
if (timerRef.current) {
|
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(
|
const onPointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
@ -26,10 +51,11 @@ export function useLongPress(
|
|||||||
clear()
|
clear()
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
firedRef.current = true
|
firedRef.current = true
|
||||||
|
pulseAck()
|
||||||
onLongPress()
|
onLongPress()
|
||||||
}, delay)
|
}, delay)
|
||||||
},
|
},
|
||||||
[clear, delay, disabled, onLongPress]
|
[clear, delay, disabled, onLongPress, pulseAck]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onClickCapture = useCallback(
|
const onClickCapture = useCallback(
|
||||||
@ -48,5 +74,7 @@ export function useLongPress(
|
|||||||
onPointerLeave: clear,
|
onPointerLeave: clear,
|
||||||
onPointerCancel: clear,
|
onPointerCancel: clear,
|
||||||
onClickCapture,
|
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
|
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-*`. */
|
/** Classes Tailwind `text-[...]` pour pastille sur fond `bg-*`. */
|
||||||
export function labelPillTextClassForTailwindBgUtility(bgClass: string): string {
|
export function labelPillTextClassForTailwindBgUtility(bgClass: string): string {
|
||||||
const hex = tailwindBgUtilityToHex(bgClass)
|
const hex = tailwindBgUtilityToHex(bgClass)
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import {
|
|||||||
ClockArrowUp,
|
ClockArrowUp,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
Folder,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
folderTreeNavIconNameClosed,
|
||||||
|
navFolderIconColorFromBgClass,
|
||||||
|
} from "@/lib/folder-nav-icons"
|
||||||
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
||||||
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
|
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
import { defaultNavLabelRowsSnapshot, labelRowById } 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 =
|
export type MailNavIcon =
|
||||||
| { kind: "lucide"; Icon: LucideIcon }
|
| { kind: "lucide"; Icon: LucideIcon }
|
||||||
| { kind: "folder-dot"; colorClass: string }
|
| { kind: "folder"; icon: string; color: string }
|
||||||
| { kind: "iconify"; icon: string }
|
| { kind: "iconify"; icon: string }
|
||||||
|
|
||||||
export function resolveMailNavIcon(
|
export function resolveMailNavIcon(
|
||||||
@ -59,8 +62,16 @@ export function resolveMailNavIcon(
|
|||||||
const path = findFolderPath(folderTree, folderId)
|
const path = findFolderPath(folderTree, folderId)
|
||||||
if (path?.length) {
|
if (path?.length) {
|
||||||
const leaf = path[path.length - 1]!
|
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",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@iconify-json/cbi": "^1.2.36",
|
"@iconify-json/cbi": "^1.2.36",
|
||||||
|
"@iconify-json/fluent": "^1.2.47",
|
||||||
"@iconify-json/logos": "^1.2.11",
|
"@iconify-json/logos": "^1.2.11",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@iconify-json/simple-icons": "^1.2.82",
|
"@iconify-json/simple-icons": "^1.2.82",
|
||||||
|
|||||||
@ -20,6 +20,9 @@ importers:
|
|||||||
'@iconify-json/cbi':
|
'@iconify-json/cbi':
|
||||||
specifier: ^1.2.36
|
specifier: ^1.2.36
|
||||||
version: 1.2.36
|
version: 1.2.36
|
||||||
|
'@iconify-json/fluent':
|
||||||
|
specifier: ^1.2.47
|
||||||
|
version: 1.2.47
|
||||||
'@iconify-json/logos':
|
'@iconify-json/logos':
|
||||||
specifier: ^1.2.11
|
specifier: ^1.2.11
|
||||||
version: 1.2.11
|
version: 1.2.11
|
||||||
@ -292,6 +295,9 @@ packages:
|
|||||||
'@iconify-json/cbi@1.2.36':
|
'@iconify-json/cbi@1.2.36':
|
||||||
resolution: {integrity: sha512-0rQ0VFkZqzIlj0ffJAzoxGgwUGoHCmCFl+btWuHquUnyqFfLIYXy34G1QJISIvQO9h6w4LffKh1Mffs3geSylw==}
|
resolution: {integrity: sha512-0rQ0VFkZqzIlj0ffJAzoxGgwUGoHCmCFl+btWuHquUnyqFfLIYXy34G1QJISIvQO9h6w4LffKh1Mffs3geSylw==}
|
||||||
|
|
||||||
|
'@iconify-json/fluent@1.2.47':
|
||||||
|
resolution: {integrity: sha512-YA9pCYNW3bqXMD1rMIkK0vqLK90UyE63hfI1cB2sQwGAbEFhi+VUz5mvcXYfb7bl5R8N5sLZNI2Kr0Q3Yo9M4A==}
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.11':
|
'@iconify-json/logos@1.2.11':
|
||||||
resolution: {integrity: sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==}
|
resolution: {integrity: sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==}
|
||||||
|
|
||||||
@ -2112,6 +2118,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/fluent@1.2.47':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.11':
|
'@iconify-json/logos@1.2.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user