445 lines
11 KiB
TypeScript
445 lines
11 KiB
TypeScript
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 (
|
||
<Dialog
|
||
className={`ui-drawer ui-drawer--${placement}`}
|
||
closeLabel={closeLabel}
|
||
onClose={onClose}
|
||
open={open}
|
||
title={title}
|
||
>
|
||
{children}
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export type PopoverProps = Readonly<{
|
||
triggerLabel: string;
|
||
children: React.ReactNode;
|
||
}>;
|
||
|
||
export function Popover({ triggerLabel, children }: PopoverProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const triggerRef = useRef<HTMLButtonElement | null>(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 (
|
||
<div className="ui-popover" ref={rootRef}>
|
||
<Button
|
||
aria-controls={contentId}
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((current) => !current)}
|
||
ref={triggerRef}
|
||
variant="secondary"
|
||
>
|
||
{triggerLabel}
|
||
</Button>
|
||
{open ? (
|
||
<div className="ui-popover__content" id={contentId} role="dialog">
|
||
{children}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type TooltipProps = Readonly<{
|
||
content: string;
|
||
children: React.ReactElement;
|
||
}>;
|
||
|
||
export function Tooltip({ content, children }: TooltipProps) {
|
||
const tooltipId = useId();
|
||
return (
|
||
<span className="ui-tooltip">
|
||
<span aria-describedby={tooltipId} className="ui-tooltip__trigger">
|
||
{children}
|
||
</span>
|
||
<span className="ui-tooltip__content" id={tooltipId} role="tooltip">
|
||
{content}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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<HTMLButtonElement | null>(null);
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||
const searchRef = useRef("");
|
||
const resetSearchRef = useRef<number | undefined>(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 (
|
||
<div className="ui-menu" ref={rootRef}>
|
||
<Button
|
||
aria-expanded={open}
|
||
aria-haspopup="menu"
|
||
onClick={() => {
|
||
if (open) dismiss();
|
||
else openAt(enabledIndexes[0] ?? 0);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
openAt(enabledIndexes[0] ?? 0);
|
||
}
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
openAt(enabledIndexes.at(-1) ?? 0);
|
||
}
|
||
}}
|
||
ref={triggerRef}
|
||
variant="secondary"
|
||
>
|
||
{triggerLabel}
|
||
</Button>
|
||
{open ? (
|
||
<div
|
||
className="ui-menu__content"
|
||
onKeyDown={(event) => {
|
||
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) => (
|
||
<button
|
||
className="ui-menu__item"
|
||
disabled={item.disabled}
|
||
key={item.id}
|
||
onClick={() => {
|
||
item.onSelect();
|
||
dismiss();
|
||
}}
|
||
ref={(node) => {
|
||
itemRefs.current[index] = node;
|
||
}}
|
||
role="menuitem"
|
||
tabIndex={index === activeIndex ? 0 : -1}
|
||
type="button"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<ToastContextValue | null>(null);
|
||
|
||
export function ToastProvider({
|
||
children,
|
||
limit = 3,
|
||
}: Readonly<{ children: React.ReactNode; limit?: number }>) {
|
||
const [toasts, setToasts] = useState<readonly ToastEntry[]>([]);
|
||
|
||
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 (
|
||
<ToastContext.Provider value={value}>
|
||
{children}
|
||
<ToastRegion dismiss={dismiss} toasts={toasts} />
|
||
</ToastContext.Provider>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section
|
||
aria-label={message("toast.region")}
|
||
aria-live="polite"
|
||
className="ui-toast-region"
|
||
>
|
||
{toasts.map((toast) => (
|
||
<ToastItem dismiss={dismiss} key={toast.id} toast={toast} />
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<article
|
||
className={`ui-toast ui-toast--${toast.tone ?? "info"}`}
|
||
onBlur={(event) => {
|
||
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
|
||
}}
|
||
onFocus={() => setPaused(true)}
|
||
onMouseEnter={() => setPaused(true)}
|
||
onMouseLeave={() => setPaused(false)}
|
||
>
|
||
<div>
|
||
<strong>{toast.title}</strong>
|
||
{toast.count > 1 ? (
|
||
<span className="ui-toast__count"> ×{toast.count}</span>
|
||
) : null}
|
||
{toast.description ? <p>{toast.description}</p> : null}
|
||
</div>
|
||
<IconButton
|
||
accessibleName={message("action.closeNamed", { title: toast.title })}
|
||
onClick={() => dismiss(toast.id)}
|
||
variant="ghost"
|
||
>
|
||
<CloseIcon />
|
||
</IconButton>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog
|
||
actions={
|
||
<>
|
||
<Button onClick={onCancel} variant="secondary">
|
||
{cancelLabel}
|
||
</Button>
|
||
<Button onClick={onConfirm} variant={danger ? "danger" : "primary"}>
|
||
{confirmLabel}
|
||
</Button>
|
||
</>
|
||
}
|
||
description={description}
|
||
onClose={onCancel}
|
||
open={open}
|
||
title={title}
|
||
/>
|
||
);
|
||
}
|