ultisuite-client/components/gmail/sidebar.tsx
2026-05-16 20:30:50 +02:00

2409 lines
82 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,
Tag,
ChevronDown,
ChevronRight,
GripVertical,
Pencil,
Plus,
Bot,
Folder,
MoreVertical,
Newspaper,
LayoutGrid,
Rss,
Mail,
ShieldAlert,
Check,
Trash2,
} from "lucide-react"
import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs"
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 { UltiMailLogo } from "@/components/ultimail-logo"
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>
}
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: Tag },
{ 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"
}
/** 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 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="flex h-8 w-4 shrink-0 cursor-grab items-center justify-center text-gray-400 opacity-50 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:opacity-100 group-hover/labelrow:opacity-100"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</span>
)
}
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
function SidebarOverflowColumn({
unread,
menuOpen,
hoverGroup,
isSelected,
hasUnread,
className,
children,
}: {
unread: number
menuOpen: boolean
hoverGroup: "folderrow" | "labelrow" | "catnav"
isSelected?: boolean
hasUnread?: boolean
className?: string
children: ReactNode
}) {
const countHoverHide = `group-hover/${hoverGroup}:opacity-0`
const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
{unread > 0 && (
<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" : cn("opacity-100", countHoverHide)
)}
>
{formatCount(unread)}
</span>
)}
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-150",
menuOpen ? "opacity-100" : cn("opacity-0", menuHoverShow)
)}
>
{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,
variant = "listed",
}: {
item: CategoryNavSourceItem
isSelected: boolean
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
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 handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
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}
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"
)}
>
<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>
<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>
)
}
return (
<div
{...dropHandlers}
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),
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"
)}
>
<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"
)}
>
{rowIcon}
{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}
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
>
<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>
)
}
export function Sidebar({
selectedFolder,
onSelectFolder,
collapsed,
folderUnreadCounts = {},
}: 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 isExpanded = !collapsed || hoverExpanded
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 (readXsMatches()) return
if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true)
}, 300)
}
}
const handleMouseLeave = () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
if (readXsMatches()) return
setHoverExpanded(false)
}
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"
/** 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 }
isSelected: boolean
unreadCount: number
}) => {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const hasUnread = unreadCount > 0
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"
)}
>
<item.icon
className={cn(
"h-5 w-5 shrink-0",
hasUnread && !isSelected && "text-gray-900"
)}
/>
{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)
useEffect(() => {
setRenameDraft(node.label)
}, [node.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
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 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"
)
const rowStyle: CSSProperties = {
paddingLeft: 24 + depth * 16,
...(isStickyBranch ? { top: stickyTopPx, zIndex: 30 - depth } : {}),
}
const overflowMenu = (
<SidebarOverflowColumn
unread={unread}
menuOpen={menuOpen}
hoverGroup="folderrow"
isSelected={isSelected}
hasUnread={hasUnread}
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
>
<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 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)
}
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<div
data-nav-row
onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave}
onDrop={onFolderRowDrop}
className={rowClass}
style={rowStyle}
>
{isExpanded ? (
<SidebarNavDragHandle
label={node.label}
onDragStart={onFolderDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
{hasChildren ? (
<button
type="button"
draggable={false}
className={cn(
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected && "text-gray-900"
)}
aria-expanded={isBranchOpen}
aria-label={
isBranchOpen
? `Replier le dossier ${node.label}`
: `Déplier le dossier ${node.label}`
}
onClick={(e) => {
e.preventDefault()
toggleFolderExpanded(node.id)
}}
>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-transform duration-200",
isBranchOpen && "rotate-90"
)}
/>
</button>
) : (
<span
className="inline-flex h-8 w-5 shrink-0 items-center justify-center"
aria-hidden
/>
)}
<div
role="button"
tabIndex={0}
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"
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<span className={cn("block h-3 w-3 rounded-sm", dotClass)} />
</span>
<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>
</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>
<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 renderExpandedFolderSubtree = (
nodes: FolderTreeNode[],
depth: number
): ReactNode =>
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
})
.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 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"
)}
>
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
{hasUnread && (
<span
className={cn(
"absolute block h-3 w-3 rounded-sm opacity-75 animate-ping",
dotClass
)}
aria-hidden
/>
)}
<span className={cn("relative block h-3 w-3 rounded-sm", dotClass)} />
</span>
</button>
)
}
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)
useEffect(() => {
setRenameDraft(item.label)
}, [item.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
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 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"
)
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}
hoverGroup="labelrow"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
>
<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
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<div
data-nav-row
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"
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<span
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
/>
</span>
{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>
</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>
<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}
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",
isExpanded ? "w-60" : "w-[68px]",
hoverExpanded && "shadow-xl border-r border-gray-200",
!collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200",
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none"
)}
>
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4 sm:hidden">
<UltiMailLogo className="min-h-8" />
<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"
)}
>
<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}
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}
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}
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-31 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
title={!isExpanded ? "Dossiers" : undefined}
>
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
{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)
: folderTree.map((node) => (
<FolderButtonCollapsed key={node.id} node={node} />
))}
</div>
{/* Labels */}
<div className="mt-3 pt-1">
<div
className="sticky top-0 z-31 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}
>
<Tag 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(
"z-30 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>
)
}