ultisuite-client/components/gmail/sidebar.tsx
2026-05-15 23:51:57 +02:00

2103 lines
72 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,
Pencil,
ShoppingCart,
MapPin,
Share2,
Bell,
MessageSquare,
BadgePercent,
Plus,
Bot,
Folder,
MoreVertical,
Sparkles,
Newspaper,
LayoutGrid,
Rss,
CreditCard,
Mail,
ShieldAlert,
Check,
} from "lucide-react"
import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
import { useEmailDropTarget } from "@/lib/drag-context"
import { useComposeActions } from "@/lib/compose-context"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { type FolderTreeNode } 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 },
]
const categoryItemsSource = [
{ id: "purchases", label: "Achats", icon: ShoppingCart },
{ id: "travel", label: "Déplacements", icon: MapPin },
{ id: "social", label: "Réseaux sociaux", icon: Share2 },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "updates", label: "Mises à jour", icon: Sparkles },
{ id: "forums", label: "Forums", icon: MessageSquare },
{ id: "finance", label: "Finance", icon: CreditCard },
{ id: "promotions", label: "Promotions", icon: BadgePercent },
]
/** Ids catégories : menu ⋮ (Afficher / Masquer) du survol. */
const CATEGORY_MENU_IDS = new Set(categoryItemsSource.map((c) => c.id))
/** Catégories affichées sous « Plus » uniquement (Mises à jour, Finance, …). */
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["updates", "finance"])
/** 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 =
categoryItemsSource.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 = (typeof categoryItemsSource)[number]
/** 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"
}
/** 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,
onHideCategory,
onShowCategory,
variant = "listed",
}: {
item: CategoryNavSourceItem
isSelected: boolean
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onHideCategory: (id: string) => void
onShowCategory: (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 = CATEGORY_MENU_IDS.has(item.id) && isExpanded
const hasUnread = unreadCount > 0
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
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"
>
<item.icon className="h-5 w-5 shrink-0 opacity-70" />
<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={() => {
onShowCategory(item.id)
setMenuOpen(false)
}}
>
Afficher
</DropdownMenuItem>
<DropdownMenuItem disabled className="text-gray-400">
Masquer
</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"
)}
>
<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-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={() => {
onHideCategory(item.id)
setMenuOpen(false)
}}
>
Masquer
</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 [hiddenCategoryIds, setHiddenCategoryIds] = 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,
addSubfolder,
addChildLabelRow,
} = useSidebarNav()
const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => {
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 c of categoryItemsSource) s.add(c.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 vis = categoryItemsSource.filter((c) => !hiddenCategoryIds.has(c.id))
return {
primaryVisibleCategories: vis.filter((c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
plusOnlyVisibleCategories: vis.filter((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
}
}, [hiddenCategoryIds])
const hiddenCategoryItems = useMemo(
() => categoryItemsSource.filter((c) => hiddenCategoryIds.has(c.id)),
[hiddenCategoryIds]
)
const visibleMainItems = useMemo(() => {
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
if (scheduledTotal > 0) return mainItems
return mainItems.filter((item) => item.id !== "scheduled")
}, [folderUnreadCounts.scheduled])
const hideCategory = (id: string) => {
setHiddenCategoryIds((prev) => {
const next = new Set(prev)
next.add(id)
return next
})
}
const showCategory = (id: string) => {
setHiddenCategoryIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
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(() => {
if (hiddenCategoryIds.has(selectedFolder)) {
onSelectFolder("inbox")
}
}, [hiddenCategoryIds, 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 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 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",
isSelected || isOver ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
!isSelected && hasUnread && "text-gray-900",
isOver && "bg-yellow-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>
)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div {...dropHandlers} className={rowClass} style={rowStyle}>
{hasChildren ? (
<button
type="button"
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
/>
)}
<button
type="button"
onClick={() => 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",
!isSelected &&
!isOver &&
"rounded-r-none hover:rounded-r-full hover:bg-gray-100",
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>
</button>
{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 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)
useEffect(() => {
setRenameDraft(item.label)
}, [item.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
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),
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"
)
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"
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>
<ContextMenuTrigger asChild>
<div {...dropHandlers} className={rowClass}>
<button
type="button"
title={!labelRowExpanded ? item.label : undefined}
onClick={() => 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",
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>
)}
</button>
{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}
onHideCategory={hideCategory}
onShowCategory={showCategory}
/>
))}
{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}
onHideCategory={hideCategory}
onShowCategory={showCategory}
/>
))}
{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 && hiddenCategoryItems.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">
Masquées
</div>
{hiddenCategoryItems.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={false}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
onHideCategory={hideCategory}
onShowCategory={showCategory}
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>
)
}