ultisuite-client/components/gmail/sidebar/sidebar-label-item-row.tsx
2026-05-20 18:22:36 +02:00

695 lines
24 KiB
TypeScript

"use client"
import { useState, useRef, useEffect, type DragEvent } from "react"
import { MoreVertical } from "lucide-react"
import { cn } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import {
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import type {
LabelInMessageListVisibility,
LabelListSidebarVisibility,
NavItemPrefs,
} from "@/lib/sidebar-nav-context"
import {
readSidebarNavDragData,
resolveNavDropPlacement,
setSidebarNavDragData,
} from "@/lib/sidebar-nav-dnd"
import { LABEL_MENU_COLOR_SWATCHES } from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
SidebarNavSheetCheckOption,
SidebarNavSheetColorPicker,
SidebarNavSheetDivider,
SidebarNavSheetSectionLabel,
} from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
import {
LabelMenuOptionWithCheck,
ContextLabelMenuOptionWithCheck,
navRowRoundedWhenActive,
SidebarNavDragHandle,
SidebarNavIconSlot,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
navRowActivate,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
import type { SidebarNavDragBindings } from "@/components/gmail/sidebar/sidebar-nav-drag-bindings"
export type SidebarLabelItemRowProps = SidebarNavDragBindings & {
item: { id: string; label: string; color: string; count?: number }
unreadCount: number
isExpanded: boolean
selectedFolder: string
touchNav: boolean
onSelectFolder: (id: string) => void
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
updateFolderOrLabelColor: (id: string, color: string) => void
renameFolderOrLabel: (id: string, name: string) => void
removeFolderOrLabelRow: (id: string) => void
addChildLabelRow: (parentId: string, name: string) => void
}
export function SidebarLabelItemRow({
item,
unreadCount,
isExpanded: labelRowExpanded,
selectedFolder,
touchNav,
onSelectFolder,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
addChildLabelRow,
navDragRef,
navDropPlacementRef,
beginNavDrag,
clearNavDrag,
updateNavDropTarget,
clearNavDropTarget,
commitNavDrop,
}: SidebarLabelItemRowProps) {
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: DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label" && active.id !== item.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onLabelRowDragOver = (e: 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: 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: 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: 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>
</>
)
}