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

2261 lines
77 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 {
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_ITEM_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
mailNavRowClass,
} from "@/lib/mail-chrome-classes"
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 { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
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"
import {
ancestorFolderIdsForTarget,
folderSubtreeContainsId,
} from "@/lib/sidebar-folder-tree-utils"
import {
mainItems,
CATEGORY_IDS_IN_PLUS_ONLY,
sortSystemLabelRows,
sidebarSecondaryActions,
hasPlusOnlyExtras,
LABEL_MENU_COLOR_SWATCHES,
type CategoryNavSourceItem,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
LabelMenuOptionWithCheck,
ContextLabelMenuOptionWithCheck,
folderParentSelectOptions,
navRowRoundedWhenActive,
SidebarNavIconSlot,
navRowActivate,
FolderTreeNavIcon,
SidebarNavDragHandle,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
import { CategoryNavRow } from "@/components/gmail/sidebar/category-nav-row"
import { useSidebarNavDrag } from "@/hooks/use-sidebar-nav-drag"
import {
MAIL_SIDEBAR_PANEL_SURFACE_CLASS,
MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS,
} from "@/lib/mail-chrome-classes"
interface SidebarProps {
selectedFolder: string
onSelectFolder: (folder: string) => void
collapsed: boolean
folderUnreadCounts?: Record<string, number>
splitView?: boolean
}
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,
navDropPlacementRef,
beginNavDrag,
clearNavDrag,
updateNavDropTarget,
clearNavDropTarget,
commitNavDrop,
} = useSidebarNavDrag({
reorderLabelRows,
moveFolderRelative,
setExpandedFolderIds,
})
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 (selectedFolder !== "search" && !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-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
{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 =
MAIL_SIDEBAR_MENU_SURFACE_CLASS
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(
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface">
<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={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<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(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
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 &&
(isOverlayOpen ? "mail-sidebar-overlay-panel" : "bg-app-canvas"),
isSelected && "bg-mail-nav-selected font-medium text-mail-nav-selected",
!isSelected && hasUnread && "text-gray-900",
isOver && "bg-mail-nav-drop text-foreground",
rowHoverHeld && "bg-mail-nav-hover text-foreground",
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
setMenuOpen(false)
}}
>
Renommer
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
setMenuOpen(false)
}}
>
Déplacer
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
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-destructive/15"
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-mail-nav-hover",
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
<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 =
MAIL_SIDEBAR_MENU_SURFACE_CLASS
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(
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface">
<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={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<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(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
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-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
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-destructive/15"
onClick={() => {
removeFolderOrLabelRow(item.id)
setMenuOpen(false)
}}
>
Supprimer le libellé
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
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>
</>
)
}
const panelSurfaceClass = isOverlayOpen
? MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS
: MAIL_SIDEBAR_PANEL_SURFACE_CLASS
return (
<aside
ref={sidebarRef}
data-sidebar
data-sidebar-overlay={isOverlayOpen ? "" : undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden transition-[width,transform] duration-200 z-40 select-none",
panelSurfaceClass,
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",
panelSurfaceClass,
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"
onClick={() =>
useMailSettingsStore.getState().setQuickSettingsOpen(true)
}
>
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
)}
</>
)}
</div>
<div
className={cn(
"hidden shrink-0 z-10 pt-1 pb-3 pl-2 sm:flex",
panelSurfaceClass,
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-border bg-mail-surface text-sm font-medium text-foreground shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-accent 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-mail-nav-hover",
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={cn(
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
panelSurfaceClass
)}
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-mail-nav-hover 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={cn(
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
panelSurfaceClass
)}
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-mail-nav-hover 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 pt-2",
panelSurfaceClass,
"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-mail-nav-hover",
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>
)
}