import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from "react"; import { CloseIcon } from "../icons/semantic-icons.js"; import { Button, Dialog, IconButton } from "./core.js"; import { useLocale } from "../../i18n/index.js"; export type DrawerProps = Readonly<{ open: boolean; onClose(): void; title: string; closeLabel?: string; placement?: "start" | "end"; children: React.ReactNode; }>; export function Drawer({ open, onClose, title, closeLabel, placement = "start", children, }: DrawerProps) { return ( {children} ); } export type PopoverProps = Readonly<{ triggerLabel: string; children: React.ReactNode; }>; export function Popover({ triggerLabel, children }: PopoverProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); const contentId = useId(); useEffect(() => { if (!open) return; function closeOutside(event: PointerEvent) { if ( event.target instanceof Node && !rootRef.current?.contains(event.target) ) { setOpen(false); triggerRef.current?.focus(); } } function closeOnEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } } document.addEventListener("pointerdown", closeOutside); document.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("pointerdown", closeOutside); document.removeEventListener("keydown", closeOnEscape); }; }, [open]); return (
{open ? ( ) : null}
); } export type TooltipProps = Readonly<{ content: string; children: React.ReactElement; }>; export function Tooltip({ content, children }: TooltipProps) { const tooltipId = useId(); return ( {children} {content} ); } export type MenuItemDefinition = Readonly<{ id: string; label: string; disabled?: boolean; onSelect(): void; }>; export type MenuProps = Readonly<{ triggerLabel: string; items: readonly MenuItemDefinition[]; }>; export function Menu({ triggerLabel, items }: MenuProps) { const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const triggerRef = useRef(null); const rootRef = useRef(null); const itemRefs = useRef>([]); const searchRef = useRef(""); const resetSearchRef = useRef(undefined); const enabledIndexes = useMemo( () => items.flatMap((item, index) => (item.disabled ? [] : [index])), [items], ); const focusIndex = useCallback( (index: number) => { setActiveIndex(index); queueMicrotask(() => itemRefs.current[index]?.focus()); }, [], ); function openAt(index: number) { setOpen(true); focusIndex(index); } function dismiss() { setOpen(false); queueMicrotask(() => triggerRef.current?.focus()); } function move(direction: 1 | -1) { const currentPosition = Math.max(enabledIndexes.indexOf(activeIndex), 0); const nextPosition = (currentPosition + direction + enabledIndexes.length) % enabledIndexes.length; const nextIndex = enabledIndexes[nextPosition]; if (nextIndex !== undefined) focusIndex(nextIndex); } useEffect(() => { if (!open) return; function closeOutside(event: PointerEvent) { if ( event.target instanceof Node && !rootRef.current?.contains(event.target) ) { setOpen(false); queueMicrotask(() => triggerRef.current?.focus()); } } document.addEventListener("pointerdown", closeOutside); return () => document.removeEventListener("pointerdown", closeOutside); }, [open]); return (
{open ? (
{ if (event.key === "Escape") { event.preventDefault(); dismiss(); } else if (event.key === "ArrowDown") { event.preventDefault(); move(1); } else if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } else if (event.key === "Home") { event.preventDefault(); focusIndex(enabledIndexes[0] ?? 0); } else if (event.key === "End") { event.preventDefault(); focusIndex(enabledIndexes.at(-1) ?? 0); } else if ( event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey ) { searchRef.current += event.key.toLocaleLowerCase(); window.clearTimeout(resetSearchRef.current); resetSearchRef.current = window.setTimeout(() => { searchRef.current = ""; }, 500); const match = items.findIndex( (item) => !item.disabled && item.label .toLocaleLowerCase() .startsWith(searchRef.current), ); if (match >= 0) focusIndex(match); } }} role="menu" > {items.map((item, index) => ( ))}
) : null}
); } type ToastTone = "info" | "success" | "warning" | "danger"; type ToastInput = Readonly<{ id: string; title: string; description?: string; tone?: ToastTone; durationMs?: number; }>; type ToastEntry = ToastInput & Readonly<{ count: number }>; type ToastContextValue = Readonly<{ push(toast: ToastInput): void; dismiss(id: string): void; }>; const ToastContext = createContext(null); export function ToastProvider({ children, limit = 3, }: Readonly<{ children: React.ReactNode; limit?: number }>) { const [toasts, setToasts] = useState([]); const dismiss = useCallback((id: string) => { setToasts((current) => current.filter((toast) => toast.id !== id)); }, []); const push = useCallback( (toast: ToastInput) => { setToasts((current) => { const existing = current.find((entry) => entry.id === toast.id); if (existing) { return current.map((entry) => entry.id === toast.id ? { ...entry, ...toast, count: entry.count + 1 } : entry, ); } return [...current, { ...toast, count: 1 }].slice(-limit); }); }, [limit], ); const value = useMemo(() => ({ push, dismiss }), [dismiss, push]); return ( {children} ); } export function useToast() { const context = useContext(ToastContext); if (!context) { throw new Error("ToastProvider is required."); } return context; } function ToastRegion({ toasts, dismiss, }: Readonly<{ toasts: readonly ToastEntry[]; dismiss(id: string): void; }>) { const { message } = useLocale(); return (
{toasts.map((toast) => ( ))}
); } function ToastItem({ toast, dismiss, }: Readonly<{ toast: ToastEntry; dismiss(id: string): void; }>) { const { message } = useLocale(); const [paused, setPaused] = useState(false); useEffect(() => { if (paused) return; const timeout = window.setTimeout( () => dismiss(toast.id), toast.durationMs ?? 5000, ); return () => window.clearTimeout(timeout); }, [dismiss, paused, toast.durationMs, toast.id, toast.count]); return (
{ if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false); }} onFocus={() => setPaused(true)} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} >
{toast.title} {toast.count > 1 ? ( ×{toast.count} ) : null} {toast.description ?

{toast.description}

: null}
dismiss(toast.id)} variant="ghost" >
); } export function ConfirmationDialog({ open, title, description, confirmLabel, cancelLabel, danger = false, onConfirm, onCancel, }: Readonly<{ open: boolean; title: string; description: string; confirmLabel: string; cancelLabel: string; danger?: boolean; onConfirm(): void; onCancel(): void; }>) { return ( } description={description} onClose={onCancel} open={open} title={title} /> ); }