ultisuite-client/components/gmail/sidebar.tsx

2870 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import {
Inbox,
Star,
Clock,
ClockArrowUp,
Send,
FileText,
ChevronDown,
GripVertical,
Pencil,
Plus,
Bot,
Folder,
MoreVertical,
Newspaper,
LayoutGrid,
Rss,
Mail,
ShieldAlert,
Check,
Trash2,
} from "lucide-react"
import { cn, formatCount } from "@/lib/utils"
import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import {
useState,
useRef,
useEffect,
useMemo,
useCallback,
type ReactNode,
type CSSProperties,
} from "react"
import { useEmailDropTarget } from "@/lib/drag-context"
import {
readSidebarNavDragData,
resolveNavDropPlacement,
setSidebarNavDragData,
type SidebarNavDragPayload,
type SidebarNavDropPlacement,
} from "@/lib/sidebar-nav-dnd"
import { useComposeActions } from "@/lib/compose-context"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { type FolderTreeNode, isSystemNavLabelId, SYSTEM_NAV_LABEL_DEFAULTS } from "@/lib/sidebar-nav-data"
import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
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,
SidebarNavSheetAction,
SidebarNavSheetCheckOption,
SidebarNavSheetColorPicker,
SidebarNavSheetDivider,
SidebarNavSheetSectionLabel,
} from "@/components/gmail/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
addCollection(mdiIcons)
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
/** Retourne les ids des parents à ouvrir pour afficher `targetId`, ou null. */
function ancestorFolderIdsForTarget(
nodes: FolderTreeNode[],
targetId: string,
chain: string[] = []
): string[] | null {
for (const n of nodes) {
if (n.id === targetId) return chain
if (n.children?.length) {
const found = ancestorFolderIdsForTarget(n.children, targetId, [...chain, n.id])
if (found) return found
}
}
return null
}
function folderSubtreeContainsId(node: FolderTreeNode, targetId: string): boolean {
if (node.id === targetId) return true
return node.children?.some((c) => folderSubtreeContainsId(c, targetId)) ?? false
}
interface SidebarProps {
selectedFolder: string
onSelectFolder: (folder: string) => void
collapsed: boolean
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number>
/** md+ split pane: mobile-style branding, no header compose. */
splitView?: boolean
}
const mainItems = [
{ id: "inbox", label: "Boîte de réception", icon: Inbox },
{ id: "starred", label: "Messages suivis", icon: Star },
{ id: "snoozed", label: "En attente", icon: Clock },
{ id: "important", label: "Important", icon: "mdi:label-variant-outline" },
{ id: "sent", label: "Messages envoyés", icon: Send },
{ id: "drafts", label: "Brouillons", icon: FileText },
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
{ id: "trash", label: "Corbeille", icon: Trash2 },
]
/** Catégories système affichées sous « Plus » uniquement. */
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["mises-a-jour", "finance"])
const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
function sortSystemLabelRows(rows: { id: string }[]): { id: string; label: string; icon?: string }[] {
const copy = [...rows]
copy.sort(
(a, b) =>
SYSTEM_NAV_LABEL_ORDER.indexOf(a.id) - SYSTEM_NAV_LABEL_ORDER.indexOf(b.id)
)
return copy as { id: string; label: string; icon?: string }[]
}
/** Liens secondaires sous la liste (jusquà Gérer les abonnements). */
const sidebarSecondaryActions = [
{ id: "customize-inbox", label: "Personnaliser la zone de réception", icon: LayoutGrid },
{ id: "manage-sections", label: "Gérer les sections", icon: Newspaper },
{ id: "manage-news", label: "Gérer les actualités", icon: Rss },
{ id: "manage-subscriptions", label: "Gérer les abonnements", icon: Mail },
] as const
const hasPlusOnlyExtras =
SYSTEM_NAV_LABEL_DEFAULTS.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
sidebarSecondaryActions.length > 0
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */
const LABEL_MENU_COLOR_SWATCHES = [
"bg-gray-500",
"bg-red-400",
"bg-orange-400",
"bg-amber-500",
"bg-yellow-400",
"bg-lime-500",
"bg-emerald-500",
"bg-teal-500",
"bg-blue-500",
"bg-indigo-500",
"bg-purple-500",
"bg-pink-500",
] as const
function LabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onPick()
}}
className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden />
) : null}
</span>
</DropdownMenuItem>
)
}
function ContextLabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<ContextMenuItem
onClick={() => onPick()}
className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm"
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden />
) : null}
</span>
</ContextMenuItem>
)
}
function folderParentSelectOptions(tree: FolderTreeNode[]): {
value: string
label: string
}[] {
const out: { value: string; label: string }[] = [
{ value: "__root__", label: "Racine" },
]
const walk = (nodes: FolderTreeNode[], depth: number) => {
for (const n of nodes) {
out.push({
value: n.id,
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
})
if (n.children?.length) walk(n.children, depth + 1)
}
}
walk(tree, 0)
return out
}
type CategoryNavSourceItem = {
id: string
label: string
icon?: string
}
/** Pill à droite seulement quand le fond daccent est visible (évite frange sur fond neutre). */
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")
}
function unmarkNavDragSource(el: HTMLElement | null) {
el?.removeAttribute("data-nav-drag-source")
}
/** Mark / unmark a drop indicator via data attribute (CSS driven). */
function setNavDropIndicator(
el: HTMLElement | null,
placement: SidebarNavDropPlacement | null,
) {
if (!el) return
if (placement) {
el.setAttribute("data-nav-drop", placement)
} else {
el.removeAttribute("data-nav-drop")
}
}
function navRowActivate(
e: React.KeyboardEvent,
action: () => void
) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
action()
}
}
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,
onDragEnd,
}: {
label: string
onDragStart: (e: React.DragEvent<HTMLSpanElement>) => void
onDragEnd: () => void
}) {
return (
<span
draggable
title={`Réorganiser : ${label}`}
aria-label={`Réorganiser : ${label}`}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="pointer-events-none absolute left-0 top-1/2 z-10 flex h-8 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-gray-400 opacity-0 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:pointer-events-auto group-hover/folderrow:opacity-100 group-hover/labelrow:pointer-events-auto group-hover/labelrow:opacity-100"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</span>
)
}
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,
menuOpen,
hoverGroup,
isSelected,
hasUnread,
className,
showMenuButton = true,
children,
}: {
unread: number
menuOpen: boolean
hoverGroup: "folderrow" | "labelrow" | "catnav"
isSelected?: boolean
hasUnread?: boolean
className?: string
showMenuButton?: boolean
children?: ReactNode
}) {
if (!showMenuButton) {
if (unread <= 0) return null
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
<span
className={cn(
"flex h-full items-center justify-center text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unread)}
</span>
</div>
)
}
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
{unread > 0 && (
<span
className={cn(
"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" : OVERFLOW_COUNT_HOVER_HIDE[hoverGroup]
)}
>
{formatCount(unread)}
</span>
)}
<div
className={cn(
"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-150",
menuOpen ? "opacity-100" : OVERFLOW_MENU_HOVER_SHOW[hoverGroup]
)}
>
{children}
</div>
</div>
)
}
const sidebarOverflowMenuButtonClass =
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-600 outline-none hover:bg-black/8 focus-visible:ring-2 focus-visible:ring-ring/50"
function CategoryNavRow({
item,
isSelected,
isExpanded,
unreadCount,
onSelectFolder,
onDisableNavLabel,
onEnableNavLabel,
touchNav,
variant = "listed",
}: {
item: CategoryNavSourceItem
isSelected: boolean
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
touchNav: boolean
variant?: "listed" | "hidden"
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const [menuOpen, setMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const isHiddenRow = variant === "hidden"
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchMenuEnabled)
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isHiddenRow && !isSelected && !isOver && (menuOpen || sheetOpen)
const rowIcon = item.icon ? (
<Icon
icon={item.icon}
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
) : (
<Folder
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
)
if (isHiddenRow) {
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
className={cn(
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
isOver ? "rounded-r-full" : "rounded-r-none",
isOver && "bg-yellow-100 text-gray-900",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
>
{rowIcon}
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none text-gray-700",
hasUnread && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
</button>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={sidebarOverflowMenuButtonClass}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem
onClick={() => {
onEnableNavLabel(item.id)
setMenuOpen(false)
}}
>
Réactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{touchNav && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<SidebarNavSheetAction
onClick={() => {
onEnableNavLabel(item.id)
closeSheet()
}}
>
Réactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
return (
<>
<div
{...dropHandlers}
{...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 || 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",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
title={!isExpanded ? item.label : undefined}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none",
showCategoryMenu ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
{rowIcon}
</SidebarNavIconSlot>
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{!showCategoryMenu && unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
)}
</button>
{showCategoryMenu && (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen || sheetOpen}
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`}
onClick={(e) => {
e.stopPropagation()
}}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem disabled className="text-gray-400">
Afficher
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onDisableNavLabel(item.id)
setMenuOpen(false)
}}
>
Désactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)}
</div>
{touchNav && showCategoryMenu && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<div className="px-4 py-3 text-sm text-[#9aa0a6]">Afficher</div>
<SidebarNavSheetAction
onClick={() => {
onDisableNavLabel(item.id)
closeSheet()
}}
>
Désactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
export function Sidebar({
selectedFolder,
onSelectFolder,
collapsed,
folderUnreadCounts = {},
splitView = false,
}: SidebarProps) {
const { openCompose } = useComposeActions()
const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
const touchNav = useTouchNav()
const isXs = useIsXs()
const isExpanded = !collapsed || (!touchNav && hoverExpanded)
const isOverlayOpen = touchNav && !collapsed
const {
folderTree,
labelRows,
folderIdToLabel,
addFolder,
addLabelRowFromSidebar,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder,
addChildLabelRow,
setLabelRowEnabled,
} = useSidebarNav()
const navDragRef = useRef<SidebarNavDragPayload | null>(null)
const navDragSourceElRef = useRef<HTMLElement | null>(null)
const navDropTargetElRef = useRef<HTMLElement | null>(null)
const navDropPlacementRef = useRef<SidebarNavDropPlacement | null>(null)
const beginNavDrag = useCallback(
(payload: SidebarNavDragPayload, sourceEl: HTMLElement | null) => {
navDragRef.current = payload
navDragSourceElRef.current = sourceEl
markNavDragSource(sourceEl)
},
[]
)
const clearNavDrag = useCallback(() => {
unmarkNavDragSource(navDragSourceElRef.current)
setNavDropIndicator(navDropTargetElRef.current, null)
navDragRef.current = null
navDragSourceElRef.current = null
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}, [])
const updateNavDropTarget = useCallback(
(el: HTMLElement, placement: SidebarNavDropPlacement) => {
if (navDropTargetElRef.current !== el) {
setNavDropIndicator(navDropTargetElRef.current, null)
}
navDropTargetElRef.current = el
navDropPlacementRef.current = placement
setNavDropIndicator(el, placement)
},
[]
)
const clearNavDropTarget = useCallback((el: HTMLElement) => {
if (navDropTargetElRef.current === el) {
setNavDropIndicator(el, null)
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}
}, [])
const commitNavDrop = useCallback(
(
payload: SidebarNavDragPayload,
targetId: string,
placement: SidebarNavDropPlacement,
targetKind: "label" | "folder"
) => {
clearNavDrag()
if (payload.id === targetId && placement !== "inside") return
if (targetKind === "label" && payload.kind === "label") {
if (placement === "inside") return
reorderLabelRows(payload.id, targetId, placement)
} else if (targetKind === "folder" && payload.kind === "folder") {
moveFolderRelative(payload.id, targetId, placement)
if (placement === "inside") {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
next.add(targetId)
return next
})
}
}
},
[clearNavDrag, moveFolderRelative, reorderLabelRows]
)
const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => {
if (row.enabled === false) return false
if (isSystemNavLabelId(row.id)) return false
const p = getNavItemPrefs(row.id)
if (p.sidebar === "hide") return false
if (
p.sidebar === "showUnread" &&
(folderUnreadCounts[row.id] ?? 0) === 0
) {
return false
}
return true
})
}, [labelRows, getNavItemPrefs, folderUnreadCounts])
const validNavFolderIds = useMemo(() => {
const s = new Set<string>()
for (const i of mainItems) s.add(i.id)
for (const k of Object.keys(folderIdToLabel)) s.add(k)
return s
}, [folderIdToLabel])
useEffect(() => {
if (!validNavFolderIds.has(selectedFolder)) {
onSelectFolder("inbox")
}
}, [validNavFolderIds, selectedFolder, onSelectFolder])
const [folderDialogOpen, setFolderDialogOpen] = useState(false)
const [labelDialogOpen, setLabelDialogOpen] = useState(false)
const [newFolderName, setNewFolderName] = useState("")
const [newFolderParent, setNewFolderParent] = useState("__root__")
const [newLabelName, setNewLabelName] = useState("")
const newFolderNameInputRef = useRef<HTMLInputElement>(null)
const newLabelNameInputRef = useRef<HTMLInputElement>(null)
const folderParentOptions = useMemo(
() => folderParentSelectOptions(folderTree),
[folderTree]
)
const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => {
const systemEnabled = sortSystemLabelRows(
labelRows.filter((r) => r.enabled !== false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
return {
primaryVisibleCategories: systemEnabled.filter(
(c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
plusOnlyVisibleCategories: systemEnabled.filter((c) =>
CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
}
}, [labelRows])
const disabledSystemNavItems = useMemo(() => {
return sortSystemLabelRows(
labelRows.filter((r) => r.enabled === false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
}, [labelRows])
const visibleMainItems = useMemo(() => {
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
if (scheduledTotal > 0) return mainItems
return mainItems.filter((item) => item.id !== "scheduled")
}, [folderUnreadCounts.scheduled])
const toggleFolderExpanded = (id: string) => {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSubmitNewFolder = () => {
const name = newFolderName.trim()
if (!name) return
const parentId = newFolderParent === "__root__" ? null : newFolderParent
addFolder(parentId, name)
setNewFolderName("")
setFolderDialogOpen(false)
}
const handleSubmitNewLabel = () => {
const name = newLabelName.trim()
if (!name) return
addLabelRowFromSidebar(name)
setNewLabelName("")
setLabelDialogOpen(false)
}
useEffect(() => {
const row = labelRows.find((r) => r.id === selectedFolder)
if (row && row.enabled === false) {
onSelectFolder("inbox")
}
}, [labelRows, selectedFolder, onSelectFolder])
useEffect(() => {
if (selectedFolder !== "scheduled") return
if ((folderUnreadCounts.scheduled ?? 0) > 0) return
onSelectFolder("inbox")
}, [folderUnreadCounts.scheduled, selectedFolder, onSelectFolder])
useEffect(() => {
if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder) && !navMoreOpen) {
setNavMoreOpen(true)
}
}, [selectedFolder, navMoreOpen])
useEffect(() => {
const ancestors = ancestorFolderIdsForTarget(folderTree, selectedFolder)
if (ancestors?.length) {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
ancestors.forEach((id) => next.add(id))
return next
})
}
}, [selectedFolder])
const handleMouseEnter = () => {
if (readTouchNavMatches()) return
if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true)
}, 300)
}
}
const handleMouseLeave = () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
if (readTouchNavMatches()) return
setHoverExpanded(false)
}
useEffect(() => {
if (touchNav) setHoverExpanded(false)
}, [touchNav, collapsed])
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
}
}
}, [])
/** Inset rows from sidebar right edge (padding works with w-full; margin-right often clips under overflow-x-hidden). */
const navRailInset = "pr-3.5"
/** pl-6 + demi-largeur icône nav (h-5) → axe à 34px ; picto split (size-9) centré sur cet axe. */
const splitViewLogoIconClass = "size-9 shrink-0"
/** Aligné sur la barre split (pt-1 shell + py-2 + recherche h-12) et le bouton menu size-9. */
const splitViewLogoHeaderClass =
"box-border min-h-[80px] pt-3 pl-4 pr-3.5 pb-4"
/** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */
const NavItem = ({
item,
isSelected,
unreadCount,
}: {
item: { id: string; label: string; icon: React.ElementType | string }
isSelected: boolean
unreadCount: number
}) => {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const hasUnread = unreadCount > 0
const iconClassName = cn(
"h-5 w-5 shrink-0",
hasUnread && !isSelected && "text-gray-900"
)
return (
<button
onClick={() => onSelectFolder(item.id)}
title={!isExpanded ? item.label : undefined}
{...dropHandlers}
className={cn(
"flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 transition-colors",
navRowRoundedWhenActive(isSelected || isOver),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
)}
>
{typeof item.icon === "string" ? (
<Icon icon={item.icon} className={iconClassName} aria-hidden />
) : (
<item.icon className={iconClassName} />
)}
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-left text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
)}
</button>
)
}
const FolderRowExpanded = ({
node,
depth,
}: {
node: FolderTreeNode
depth: number
}) => {
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
const hasChildren = !!(node.children?.length)
const isBranchOpen = expandedFolderIds.has(node.id)
const dotClass = node.color ?? "bg-gray-400"
const isSelected = selectedFolder === node.id
const unread = folderUnreadCounts[node.id] ?? 0
const hasUnread = unread > 0
const isStickyBranch = hasChildren && isBranchOpen
const stickyTopPx = 32 + depth * 32
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(node.label)
const [moveOpen, setMoveOpen] = useState(false)
const [moveParent, setMoveParent] = useState("__root__")
const [subfolderOpen, setSubfolderOpen] = useState(false)
const [subfolderName, setSubfolderName] = useState("")
const folderRenameInputRef = useRef<HTMLInputElement>(null)
const subfolderNameInputRef = useRef<HTMLInputElement>(null)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && isExpanded)
useEffect(() => {
setRenameDraft(node.label)
}, [node.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo(
() => folderMoveParentOptions(folderTree, node.id),
[folderTree, node.id]
)
const folderMenuSurface =
"min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md"
const colorSub = (
subKind: "dropdown" | "context"
) => {
const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
const SubTr =
subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger
const SubCo =
subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent
return (
<Sub>
<SubTr
className={cn(
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100",
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
dotClass
)}
aria-hidden
/>
</span>
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
</SubTr>
<SubCo className="min-w-[180px] border-gray-200 bg-white p-2 shadow-md">
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(node.id, sw)
setMenuOpen(false)
}}
className={cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
sw
)}
/>
))}
</div>
</SubCo>
</Sub>
)
}
const rowClass = cn(
"group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
!isSelected && hasUnread && "text-gray-900",
isOver && "bg-yellow-100 text-gray-900",
rowHoverHeld && "bg-gray-100 text-gray-900",
touchRowClassName
)
const rowStyle: CSSProperties = {
paddingLeft: 24 + depth * 16,
...(isStickyBranch ? { top: stickyTopPx, zIndex: 30 - depth } : {}),
}
const overflowMenu = (
<SidebarOverflowColumn
unread={unread}
menuOpen={menuOpen || sheetOpen}
hoverGroup="folderrow"
isSelected={isSelected}
hasUnread={hasUnread}
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${node.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={folderMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des dossiers
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
>
Afficher si messages non lus
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(node.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
setMenuOpen(false)
}}
>
Renommer
</DropdownMenuItem>
<DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
setMenuOpen(false)
}}
>
Déplacer
</DropdownMenuItem>
<DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
setMenuOpen(false)
}}
>
Nouveau sous-dossier
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50"
onClick={() => {
removeFolderOrLabelRow(node.id)
setMenuOpen(false)
}}
>
Supprimer le dossier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)
const folderOptionsSheet = touchNav && isExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={node.label}
colorDotClass={dotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du dossier"
dotClass={dotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(node.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des dossiers</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
closeSheet()
}}
>
Déplacer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
closeSheet()
}}
>
Nouveau sous-dossier
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(node.id)
closeSheet()
}}
>
Supprimer le dossier
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const onFolderRowDragEnter = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "folder" && active.id !== node.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onFolderRowDragOver = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "folder") {
e.preventDefault()
e.stopPropagation()
if (active.id === node.id) return
const ancestors = ancestorFolderIdsForTarget(folderTree, node.id)
if (ancestors?.includes(active.id)) return
e.dataTransfer.dropEffect = "move"
updateNavDropTarget(
e.currentTarget as HTMLElement,
resolveNavDropPlacement(e, true)
)
return
}
dropHandlers.onDragOver(e)
}
const onFolderRowDragLeave = (e: React.DragEvent) => {
if (navDragRef.current?.kind === "folder") {
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
clearNavDropTarget(e.currentTarget as HTMLElement)
return
}
dropHandlers.onDragLeave(e)
}
const onFolderRowDrop = (e: React.DragEvent) => {
const payload = readSidebarNavDragData(e, navDragRef.current)
if (payload?.kind === "folder") {
e.preventDefault()
e.stopPropagation()
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, true)
commitNavDrop(payload, node.id, placement, "folder")
return
}
dropHandlers.onDrop(e)
}
const onFolderDragHandleStart = (e: React.DragEvent<HTMLSpanElement>) => {
const payload = { kind: "folder" as const, id: node.id }
setSidebarNavDragData(e, payload)
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
beginNavDrag(payload, rowEl)
}
const folderRowEl = (
<div
data-nav-row
{...touchRowProps}
onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave}
onDrop={onFolderRowDrop}
className={rowClass}
style={rowStyle}
>
{isExpanded ? (
<SidebarNavDragHandle
label={node.label}
onDragStart={onFolderDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
<div
role="button"
tabIndex={0}
onClick={() => onSelectFolder(node.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
!isSelected &&
!isOver &&
!rowHoverHeld &&
"rounded-r-none hover:rounded-r-full hover:bg-gray-100",
rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
isSelected
? "text-gray-900"
: isOver
? "text-gray-900"
: "text-gray-700"
)}
>
{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
className={cn(
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{node.label}
</span>
</span>
</div>
</div>
{overflowMenu}
</div>
)
return (
<>
{touchNav ? (
folderRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des dossiers
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
>
Afficher si non lus
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des messages
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(node.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
}}
>
Renommer
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
}}
>
Déplacer
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
}}
>
Nouveau sous-dossier
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => removeFolderOrLabelRow(node.id)}
>
Supprimer le dossier
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{folderOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
folderRenameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Renommer le dossier</DialogTitle>
<DialogDescription>Nouveau nom pour « {node.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={folderRenameInputRef}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
renameFolderOrLabel(node.id, renameDraft)
setRenameOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
renameFolderOrLabel(node.id, renameDraft)
setRenameOpen(false)
}}
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={moveOpen} onOpenChange={setMoveOpen}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>Déplacer le dossier</DialogTitle>
<DialogDescription>Choisissez le dossier parent.</DialogDescription>
</DialogHeader>
<Select value={moveParent} onValueChange={setMoveParent}>
<SelectTrigger className="w-full min-w-0" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72">
{moveTargets.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setMoveOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
moveFolder(
node.id,
moveParent === "__root__" ? null : moveParent
)
setMoveOpen(false)
}}
>
Déplacer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={subfolderOpen} onOpenChange={setSubfolderOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
subfolderNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau sous-dossier</DialogTitle>
<DialogDescription>Sous « {node.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={subfolderNameInputRef}
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="Nom du dossier"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addSubfolder(node.id, subfolderName)
setSubfolderOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setSubfolderOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
addSubfolder(node.id, subfolderName)
setSubfolderOpen(false)
}}
>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
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 = (
nodes: FolderTreeNode[],
depth: number
): ReactNode =>
sidebarVisibleFolderNodes(nodes).map((node) => {
const isBranchOpen = expandedFolderIds.has(node.id)
const kids = node.children
return (
/* Limite le sticky au sous-arbre (évite lempilement hors contexte). */
<div key={node.id} className="min-w-0">
<FolderRowExpanded node={node} depth={depth} />
{kids?.length && isBranchOpen ? (
<div className="min-w-0">
{renderExpandedFolderSubtree(kids, depth + 1)}
</div>
) : null}
</div>
)
})
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
return (
<button
type="button"
title={
!isExpanded
? unread > 0
? `${node.label}${unread} non lus`
: node.label
: undefined
}
onClick={() => onSelectFolder(node.id)}
{...dropHandlers}
className={cn(
"relative flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm transition-colors",
navRowRoundedWhenActive(isHighlighted || isOver),
isHighlighted
? "bg-[#d3e3fd] text-gray-900 font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren={hasChildFolders}
open={false}
colorBgClass={dotClass}
/>
</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,
isExpanded: labelRowExpanded,
}: {
item: { id: string; label: string; color: string; count?: number }
unreadCount: number
isExpanded: boolean
}) => {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const isSelected = selectedFolder === item.id
const hasUnread = unreadCount > 0
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(item.label)
const [sublabelOpen, setSublabelOpen] = useState(false)
const [sublabelName, setSublabelName] = useState("")
const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && labelRowExpanded)
useEffect(() => {
setRenameDraft(item.label)
}, [item.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
const labelMenuSurface =
"min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md"
const colorSub = (subKind: "dropdown" | "context") => {
const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
const SubTr =
subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger
const SubCo =
subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent
return (
<Sub>
<SubTr
className={cn(
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100",
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
labelDotClass
)}
aria-hidden
/>
</span>
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
</SubTr>
<SubCo className="min-w-[180px] border-gray-200 bg-white p-2 shadow-md">
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(item.id, sw)
setMenuOpen(false)
}}
className={cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
sw
)}
/>
))}
</div>
</SubCo>
</Sub>
)
}
const rowClass = cn(
"group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
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",
touchRowClassName
)
const onLabelRowDragEnter = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label" && active.id !== item.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onLabelRowDragOver = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label") {
e.preventDefault()
e.stopPropagation()
if (active.id === item.id) return
e.dataTransfer.dropEffect = "move"
updateNavDropTarget(
e.currentTarget as HTMLElement,
resolveNavDropPlacement(e, false)
)
return
}
dropHandlers.onDragOver(e)
}
const onLabelRowDragLeave = (e: React.DragEvent) => {
if (navDragRef.current?.kind === "label") {
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
clearNavDropTarget(e.currentTarget as HTMLElement)
return
}
dropHandlers.onDragLeave(e)
}
const onLabelRowDrop = (e: React.DragEvent) => {
const payload = readSidebarNavDragData(e, navDragRef.current)
if (payload?.kind === "label") {
e.preventDefault()
e.stopPropagation()
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, false)
if (placement !== "inside") {
commitNavDrop(payload, item.id, placement, "label")
} else {
clearNavDrag()
}
return
}
dropHandlers.onDrop(e)
}
const onLabelDragHandleStart = (e: React.DragEvent<HTMLSpanElement>) => {
const payload = { kind: "label" as const, id: item.id }
setSidebarNavDragData(e, payload)
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
beginNavDrag(payload, rowEl)
}
const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen || sheetOpen}
hoverGroup="labelrow"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
draggable={false}
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={labelMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des libellés
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
>
Afficher si messages non lus
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(item.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
setMenuOpen(false)
}}
>
Renommer
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50"
onClick={() => {
removeFolderOrLabelRow(item.id)
setMenuOpen(false)
}}
>
Supprimer le libellé
</DropdownMenuItem>
<DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
setMenuOpen(false)
}}
>
Ajouter un sous-libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
) : null
const labelOptionsSheet = touchNav && labelRowExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
colorDotClass={labelDotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du libellé"
dotClass={labelDotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(item.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des libellés</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(item.id)
closeSheet()
}}
>
Supprimer le libellé
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
closeSheet()
}}
>
Ajouter un sous-libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const labelRowEl = (
<div
data-nav-row
{...touchRowProps}
onDragEnter={onLabelRowDragEnter}
onDragOver={onLabelRowDragOver}
onDragLeave={onLabelRowDragLeave}
onDrop={onLabelRowDrop}
className={rowClass}
>
{canDragLabel ? (
<SidebarNavDragHandle
label={item.label}
onDragStart={onLabelDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
<div
role="button"
tabIndex={0}
title={!labelRowExpanded ? item.label : undefined}
onClick={() => onSelectFolder(item.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
labelRowExpanded ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<span
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
/>
</SidebarNavIconSlot>
{labelRowExpanded && (
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
)}
</div>
{overflowMenu}
</div>
)
return (
<>
{touchNav ? (
labelRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des libellés
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
>
Afficher si non lus
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
Dans la liste des messages
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(item.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
}}
>
Renommer
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => removeFolderOrLabelRow(item.id)}
>
Supprimer le libellé
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
}}
>
Ajouter un sous-libellé
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{labelOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
labelRenameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Renommer le libellé</DialogTitle>
<DialogDescription>Nouveau nom pour « {item.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={labelRenameInputRef}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
renameFolderOrLabel(item.id, renameDraft)
setRenameOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
renameFolderOrLabel(item.id, renameDraft)
setRenameOpen(false)
}}
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={sublabelOpen} onOpenChange={setSublabelOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
sublabelNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Sous-libellé</DialogTitle>
<DialogDescription>
Sera créé sous « {item.label} » (chemin type Parent/Enfant).
</DialogDescription>
</DialogHeader>
<Input
ref={sublabelNameInputRef}
value={sublabelName}
onChange={(e) => setSublabelName(e.target.value)}
placeholder="Nom du sous-libellé"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addChildLabelRow(item.id, sublabelName)
setSublabelOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setSublabelOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
addChildLabelRow(item.id, sublabelName)
setSublabelOpen(false)
}}
>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
return (
<aside
ref={sidebarRef}
data-sidebar
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40 select-none",
isExpanded ? "w-60" : "w-[68px]",
splitView && "border-r border-gray-200",
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
collapsed && isXs && "-translate-x-full pointer-events-none"
)}
>
<div
className={cn(
"flex shrink-0 items-center bg-app-canvas",
splitView
? cn(
splitViewLogoHeaderClass,
isExpanded ? "justify-between" : "justify-start"
)
: "justify-between px-4 pt-4 pb-4 sm:hidden"
)}
>
{splitView && !isExpanded ? (
<UltiMailLogo variant="mark" className={splitViewLogoIconClass} />
) : (
<>
<UltiMailLogo
className={cn(
"shrink-0",
splitView
? "max-w-[140px] gap-4 [&_img]:size-9"
: "min-h-8"
)}
/>
{(splitView || touchNav) && isExpanded && (
<Button
variant="ghost"
size="icon"
className="size-9 shrink-0 text-gray-600"
aria-label="Réglages"
>
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
)}
</>
)}
</div>
<div
className={cn(
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
isExpanded ? "pr-3.5" : "pr-2",
splitView && "!hidden"
)}
>
<button
type="button"
title={!isExpanded ? "Nouveau message" : undefined}
aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose}
className={cn(
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-gray-200 bg-white text-sm font-medium text-gray-700 shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
isExpanded
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0"
)}
>
<Pencil className="size-5 shrink-0" />
{isExpanded && (
<span className="min-w-0 truncate text-sm font-medium">
Nouveau message
</span>
)}
</button>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
)}
>
{/* Boîte principale + catégories (hors « Plus »), puis Plus / Moins, puis extras. */}
<nav className={cn("flex min-h-full flex-col", navRailInset)}>
{visibleMainItems.map((item) => (
<NavItem
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
unreadCount={folderUnreadCounts[item.id] ?? 0}
/>
))}
{primaryVisibleCategories.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
{hasPlusOnlyExtras && (
<button
type="button"
title={!isExpanded ? (navMoreOpen ? "Moins" : "Plus") : undefined}
aria-expanded={navMoreOpen}
aria-label={
!isExpanded
? navMoreOpen
? "Moins dentrées"
: "Plus dentrées"
: undefined
}
onClick={() =>
setNavMoreOpen((wasOpen) => {
if (!wasOpen) return true
if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder)) {
onSelectFolder("inbox")
return false
}
return false
})
}
className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-gray-700 transition-colors hover:bg-gray-100",
navRowRoundedWhenActive(false)
)}
>
<ChevronDown
className={cn(
"h-5 w-5 shrink-0 transition-transform duration-200",
navMoreOpen && "rotate-180"
)}
/>
{isExpanded && (
<span className="text-sm">{navMoreOpen ? "Moins" : "Plus"}</span>
)}
</button>
)}
{navMoreOpen && (
<>
{plusOnlyVisibleCategories.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
{isExpanded && (
<div className="mt-1 flex flex-col gap-px">
{sidebarSecondaryActions.map((a) => {
const ActionIcon = a.icon
return (
<button
key={a.id}
type="button"
className="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pl-6 pr-3 text-left text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-800"
>
<ActionIcon className="h-3.5 w-3.5 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 leading-snug">{a.label}</span>
</button>
)
})}
</div>
)}
{isExpanded && disabledSystemNavItems.length > 0 && (
<div className="mt-2 pt-2">
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
Désactivées
</div>
{disabledSystemNavItems.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={false}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
variant="hidden"
/>
))}
</div>
)}
</>
)}
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
<div className="mt-3 pt-1">
<div
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}
>
<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
</span>
)}
{isExpanded && (
<button
type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700"
aria-label="Ajouter un dossier"
title="Ajouter un dossier"
onClick={() => {
setNewFolderParent("__root__")
setNewFolderName("")
setFolderDialogOpen(true)
}}
>
<Plus className="h-5 w-5 shrink-0" />
</button>
)}
</div>
{isExpanded
? renderExpandedFolderSubtree(folderTree, 0)
: renderCollapsedFolderList(folderTree)}
</div>
{/* Labels */}
<div className="mt-3 pt-1">
<div
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 ? "Libellés" : undefined}
>
<Icon
icon="mdi:label-outline"
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">
Libellés
</span>
)}
{isExpanded && (
<button
type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700"
aria-label="Ajouter un libellé"
title="Ajouter un libellé"
onClick={() => {
setNewLabelName("")
setLabelDialogOpen(true)
}}
>
<Plus className="h-5 w-5 shrink-0" />
</button>
)}
</div>
{visibleNavLabelRows.map((item) => (
<LabelItemRow
key={item.id}
item={item}
unreadCount={folderUnreadCounts[item.id] ?? 0}
isExpanded={isExpanded}
/>
))}
</div>
{/* Sortbot */}
<div
className={cn(
"relative z-32 mt-auto bg-app-canvas pt-2",
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)}
>
<button
type="button"
title={!isExpanded ? "Sortbot" : undefined}
className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm text-gray-700 transition-colors hover:bg-gray-100",
navRowRoundedWhenActive(false)
)}
>
<Bot className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
{isExpanded && <span>Sortbot</span>}
</button>
</div>
</nav>
</div>
<Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
newFolderNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau dossier</DialogTitle>
<DialogDescription>
Choisissez lemplacement (racine ou dossier parent) puis le nom.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-1">
<div className="grid gap-2">
<Label htmlFor="new-folder-parent">Emplacement</Label>
<Select value={newFolderParent} onValueChange={setNewFolderParent}>
<SelectTrigger id="new-folder-parent" className="w-full min-w-0" size="sm">
<SelectValue placeholder="Parent" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72">
{folderParentOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="new-folder-name">Nom</Label>
<Input
id="new-folder-name"
ref={newFolderNameInputRef}
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Mon dossier"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSubmitNewFolder()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setFolderDialogOpen(false)}
>
Annuler
</Button>
<Button type="button" onClick={handleSubmitNewFolder}>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
newLabelNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau libellé</DialogTitle>
<DialogDescription>
Nom affiché dans la barre latérale et utilisé sur les messages.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-1">
<Label htmlFor="new-label-name">Nom</Label>
<Input
id="new-label-name"
ref={newLabelNameInputRef}
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="Libellé"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSubmitNewLabel()
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setLabelDialogOpen(false)}
>
Annuler
</Button>
<Button type="button" onClick={handleSubmitNewLabel}>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</aside>
)
}