@abuzareal/contour-ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/COMPOSITION.md +28 -28
  3. package/CONTRACTS.md +9 -3
  4. package/FORMS.md +94 -0
  5. package/NEXTJS.md +6 -2
  6. package/OVERLAYS.md +52 -0
  7. package/PUBLIC-API.md +181 -162
  8. package/README.md +44 -28
  9. package/RELEASING.md +1 -1
  10. package/SIZE.md +45 -0
  11. package/STYLING.md +68 -0
  12. package/dist/actions.css +7 -0
  13. package/dist/components/hero/Sculpture.js +2 -1
  14. package/dist/components/three/ObjectStudy.js +2 -1
  15. package/dist/components/ui/Alert.d.ts +2 -1
  16. package/dist/components/ui/Alert.js +3 -3
  17. package/dist/components/ui/Avatar.d.ts +3 -1
  18. package/dist/components/ui/Avatar.js +3 -3
  19. package/dist/components/ui/Badge.js +2 -2
  20. package/dist/components/ui/BrandMark.js +2 -1
  21. package/dist/components/ui/Breadcrumbs.d.ts +2 -1
  22. package/dist/components/ui/Breadcrumbs.js +3 -2
  23. package/dist/components/ui/Button.d.ts +2 -0
  24. package/dist/components/ui/Button.js +3 -2
  25. package/dist/components/ui/Card.js +2 -2
  26. package/dist/components/ui/Checkbox.d.ts +2 -2
  27. package/dist/components/ui/Checkbox.js +14 -5
  28. package/dist/components/ui/CountUp.d.ts +2 -1
  29. package/dist/components/ui/CountUp.js +6 -3
  30. package/dist/components/ui/DataTable.d.ts +1 -0
  31. package/dist/components/ui/DataTable.js +12 -8
  32. package/dist/components/ui/DepthButton.js +2 -2
  33. package/dist/components/ui/Dialog.d.ts +5 -1
  34. package/dist/components/ui/Dialog.js +49 -18
  35. package/dist/components/ui/Disclosure.js +2 -1
  36. package/dist/components/ui/Divider.js +3 -2
  37. package/dist/components/ui/DropdownMenu.d.ts +11 -4
  38. package/dist/components/ui/DropdownMenu.js +109 -66
  39. package/dist/components/ui/EmptyState.js +2 -1
  40. package/dist/components/ui/Field.js +2 -2
  41. package/dist/components/ui/FlipCard.js +2 -2
  42. package/dist/components/ui/FloatingPortal.d.ts +10 -0
  43. package/dist/components/ui/FloatingPortal.js +51 -0
  44. package/dist/components/ui/IconButton.js +2 -2
  45. package/dist/components/ui/Kbd.js +2 -1
  46. package/dist/components/ui/Magnetic.js +2 -1
  47. package/dist/components/ui/Marquee.js +3 -2
  48. package/dist/components/ui/OverlayTrigger.d.ts +11 -0
  49. package/dist/components/ui/OverlayTrigger.js +5 -0
  50. package/dist/components/ui/Pagination.d.ts +5 -1
  51. package/dist/components/ui/Pagination.js +3 -2
  52. package/dist/components/ui/Popover.d.ts +18 -8
  53. package/dist/components/ui/Popover.js +58 -17
  54. package/dist/components/ui/ProgressBar.js +2 -2
  55. package/dist/components/ui/RadioGroup.d.ts +13 -5
  56. package/dist/components/ui/RadioGroup.js +36 -9
  57. package/dist/components/ui/Reveal.d.ts +0 -1
  58. package/dist/components/ui/Reveal.js +4 -3
  59. package/dist/components/ui/RevealHeading.js +2 -1
  60. package/dist/components/ui/ScrollProgress.js +2 -1
  61. package/dist/components/ui/SectionLabel.js +2 -1
  62. package/dist/components/ui/SegmentedControl.js +6 -3
  63. package/dist/components/ui/SelectField.js +18 -3
  64. package/dist/components/ui/Skeleton.js +2 -2
  65. package/dist/components/ui/Slider.d.ts +23 -7
  66. package/dist/components/ui/Slider.js +43 -6
  67. package/dist/components/ui/Spinner.js +2 -2
  68. package/dist/components/ui/Stat.js +2 -1
  69. package/dist/components/ui/Stepper.js +3 -3
  70. package/dist/components/ui/Switch.d.ts +27 -5
  71. package/dist/components/ui/Switch.js +51 -5
  72. package/dist/components/ui/Tabs.js +7 -5
  73. package/dist/components/ui/Tag.d.ts +2 -1
  74. package/dist/components/ui/Tag.js +4 -3
  75. package/dist/components/ui/TextArea.js +4 -1
  76. package/dist/components/ui/TextField.js +8 -3
  77. package/dist/components/ui/ThemeScope.d.ts +13 -0
  78. package/dist/components/ui/ThemeScope.js +10 -0
  79. package/dist/components/ui/ThemeToggle.d.ts +3 -0
  80. package/dist/components/ui/ThemeToggle.js +4 -3
  81. package/dist/components/ui/TiltCard.js +3 -2
  82. package/dist/components/ui/Timeline.js +2 -2
  83. package/dist/components/ui/ToastProvider.d.ts +5 -2
  84. package/dist/components/ui/ToastProvider.js +5 -5
  85. package/dist/components/ui/Tooltip.d.ts +4 -4
  86. package/dist/components/ui/Tooltip.js +70 -10
  87. package/dist/data-display.css +5 -0
  88. package/dist/effects.css +6 -0
  89. package/dist/feedback.css +5 -0
  90. package/dist/fonts-latin.css +10 -4
  91. package/dist/fonts.css +8 -0
  92. package/dist/forms.css +5 -0
  93. package/dist/hooks/useDismiss.d.ts +12 -3
  94. package/dist/hooks/useDismiss.js +5 -21
  95. package/dist/hooks/useFloatingPanel.d.ts +14 -0
  96. package/dist/hooks/useFloatingPanel.js +157 -0
  97. package/dist/hooks/useFormReset.d.ts +1 -1
  98. package/dist/hooks/useLayerDismiss.d.ts +4 -0
  99. package/dist/hooks/useLayerDismiss.js +48 -0
  100. package/dist/hooks/useMobileMenu.d.ts +0 -1
  101. package/dist/hooks/useMobileMenu.js +11 -16
  102. package/dist/hooks/usePanelFocus.d.ts +10 -0
  103. package/dist/hooks/usePanelFocus.js +60 -0
  104. package/dist/hooks/useTheme.d.ts +1 -0
  105. package/dist/hooks/useTheme.js +18 -5
  106. package/dist/index.d.ts +5 -0
  107. package/dist/index.js +1 -0
  108. package/dist/kit/forms.css +11 -2
  109. package/dist/kit/navigation.css +4 -0
  110. package/dist/kit/overlays.css +8 -4
  111. package/dist/lib/classNames.d.ts +2 -0
  112. package/dist/lib/classNames.js +16 -1
  113. package/dist/lib/componentClasses.d.ts +1 -0
  114. package/dist/lib/componentClasses.js +2 -0
  115. package/dist/lib/keyboard.d.ts +1 -1
  116. package/dist/lib/keyboard.js +5 -2
  117. package/dist/lib/layers.d.ts +18 -0
  118. package/dist/lib/layers.js +128 -0
  119. package/dist/lib/themeScope.d.ts +5 -0
  120. package/dist/lib/themeScope.js +3 -0
  121. package/dist/minimal-reset.css +6 -0
  122. package/dist/navigation.css +5 -0
  123. package/dist/overlays.css +5 -0
  124. package/dist/scoped/base.css +183 -0
  125. package/dist/scoped/kit/actions.css +209 -0
  126. package/dist/scoped/kit/data-display.css +492 -0
  127. package/dist/scoped/kit/effects.css +140 -0
  128. package/dist/scoped/kit/feedback.css +309 -0
  129. package/dist/scoped/kit/forms.css +370 -0
  130. package/dist/scoped/kit/navigation.css +266 -0
  131. package/dist/scoped/kit/overlays.css +299 -0
  132. package/dist/scoped/marquee.css +58 -0
  133. package/dist/scoped/spinner.css +20 -0
  134. package/dist/scoped/theme-toggle.css +38 -0
  135. package/dist/scoped/tokens.css +119 -0
  136. package/dist/scoped/ui.css +127 -0
  137. package/dist/scoped/utilities.css +17 -0
  138. package/dist/scoped-tokens.css +1 -0
  139. package/dist/scoped.css +12 -0
  140. package/dist/theme-toggle.css +2 -2
  141. package/dist/tokens.json +710 -0
  142. package/examples/forms/HookFormSettingsForm.tsx +176 -0
  143. package/examples/forms/ManualSettingsForm-0.3.0.tsx +102 -0
  144. package/examples/forms/NativeSettingsForm.tsx +119 -0
  145. package/examples/forms/settings.ts +61 -0
  146. package/examples/forms/useSubmission.ts +77 -0
  147. package/package.json +30 -6
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Editorial data table with mono headers, optional column sorting, and horizontal scroll on small screens. */
4
5
  import { useMemo, useState } from "react";
5
6
  import { ArrowDown, ArrowUp } from "lucide-react";
@@ -8,18 +9,21 @@ export default function DataTable({ caption, columns, rows, rowKey, }) {
8
9
  const sortedRows = useMemo(() => {
9
10
  if (!sort)
10
11
  return rows;
12
+ const compare = columns.find((column) => column.key === sort.key)?.compare;
11
13
  const factor = sort.direction === "ascending" ? 1 : -1;
12
- return [...rows].sort((a, b) => a[sort.key] > b[sort.key]
13
- ? factor
14
- : a[sort.key] < b[sort.key]
15
- ? -factor
16
- : 0);
17
- }, [rows, sort]);
14
+ return [...rows].sort((a, b) => compare
15
+ ? factor * compare(a, b)
16
+ : a[sort.key] > b[sort.key]
17
+ ? factor
18
+ : a[sort.key] < b[sort.key]
19
+ ? -factor
20
+ : 0);
21
+ }, [rows, sort, columns]);
18
22
  const toggleSort = (key) => setSort((current) => current?.key === key && current.direction === "ascending"
19
23
  ? { key, direction: "descending" }
20
24
  : { key, direction: "ascending" });
21
- return (_jsx("div", { className: "table-scroll", tabIndex: 0, role: "region", "aria-label": caption, children: _jsxs("table", { className: "data-table", children: [_jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { className: "mono", children: _jsx("tr", { children: columns.map((column) => {
25
+ return (_jsx("div", { className: ccx("table-scroll"), tabIndex: 0, role: "region", "aria-label": caption, children: _jsxs("table", { className: ccx("data-table"), children: [_jsx("caption", { className: ccx("visually-hidden"), children: caption }), _jsx("thead", { className: ccx("mono"), children: _jsx("tr", { children: columns.map((column) => {
22
26
  const direction = sort?.key === column.key ? sort.direction : undefined;
23
- return (_jsx("th", { scope: "col", "aria-sort": column.sortable ? (direction ?? "none") : undefined, "data-align": column.align, children: column.sortable ? (_jsxs("button", { type: "button", onClick: () => toggleSort(column.key), children: [column.header, direction === "descending" ? (_jsx(ArrowDown, { size: 12, "aria-hidden": "true" })) : (_jsx(ArrowUp, { size: 12, "aria-hidden": "true", className: direction ? undefined : "data-table-idle" }))] })) : (column.header) }, column.key));
27
+ return (_jsx("th", { scope: "col", "aria-sort": column.sortable ? (direction ?? "none") : undefined, "data-align": column.align, children: column.sortable ? (_jsxs("button", { type: "button", onClick: () => toggleSort(column.key), children: [column.header, direction === "descending" ? (_jsx(ArrowDown, { size: 12, "aria-hidden": "true" })) : (_jsx(ArrowUp, { size: 12, "aria-hidden": "true", className: ccx(direction ? undefined : "data-table-idle") }))] })) : (column.header) }, column.key));
24
28
  }) }) }), _jsx("tbody", { children: sortedRows.map((row) => (_jsx("tr", { children: columns.map((column) => (_jsx("td", { "data-align": column.align, children: column.render ? column.render(row) : row[column.key] }, column.key))) }, String(row[rowKey])))) })] }) }));
25
29
  }
@@ -2,8 +2,8 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  /** Physical, layered button that presses down into its base (CSS 3D depth). */
4
4
  import { forwardRef } from "react";
5
- import { cx } from "../../lib/classNames.js";
5
+ import { ccx } from "../../lib/classNames.js";
6
6
  const DepthButton = forwardRef(function DepthButton({ children, className, type = "button", ...rest }, ref) {
7
- return (_jsx("button", { ref: ref, ...rest, type: type, className: cx("depth-button", className), children: _jsx("span", { className: "depth-button-face", children: children }) }));
7
+ return (_jsx("button", { ref: ref, ...rest, type: type, className: ccx("depth-button", className), children: _jsx("span", { className: ccx("depth-button-face"), children: children }) }));
8
8
  });
9
9
  export default DepthButton;
@@ -1,5 +1,5 @@
1
1
  /** Native controlled modal, with refs and props for its existing DOM parts. */
2
- import { type ReactNode } from "react";
2
+ import { type RefObject, type ReactNode } from "react";
3
3
  import type { NativePartProps } from "../../lib/componentProps.js";
4
4
  export type DialogProps = Omit<NativePartProps<"dialog">, "ref" | "open" | "onClose" | "title"> & {
5
5
  open: boolean;
@@ -9,6 +9,8 @@ export type DialogProps = Omit<NativePartProps<"dialog">, "ref" | "open" | "onCl
9
9
  description?: string;
10
10
  footer?: ReactNode;
11
11
  placement?: "center" | "left" | "right";
12
+ initialFocus?: RefObject<HTMLElement | null>;
13
+ onOpenAutoFocus?: (event: Event) => void;
12
14
  panelProps?: NativePartProps<"div">;
13
15
  bodyProps?: NativePartProps<"div">;
14
16
  footerProps?: NativePartProps<"footer">;
@@ -22,6 +24,8 @@ declare const Dialog: import("react").ForwardRefExoticComponent<Omit<NativePartP
22
24
  description?: string;
23
25
  footer?: ReactNode;
24
26
  placement?: "center" | "left" | "right";
27
+ initialFocus?: RefObject<HTMLElement | null>;
28
+ onOpenAutoFocus?: (event: Event) => void;
25
29
  panelProps?: NativePartProps<"div">;
26
30
  bodyProps?: NativePartProps<"div">;
27
31
  footerProps?: NativePartProps<"footer">;
@@ -1,25 +1,45 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /** Native controlled modal, with refs and props for its existing DOM parts. */
4
- import { forwardRef, useEffect, useId } from "react";
4
+ import { useContext, forwardRef, useEffect, useId, useRef, } from "react";
5
5
  import { X } from "lucide-react";
6
- import { cx } from "../../lib/classNames.js";
6
+ import { cx, ccx } from "../../lib/classNames.js";
7
7
  import { lockDocumentScroll } from "../../lib/lockDocumentScroll.js";
8
8
  import { composeEventHandlers } from "../../lib/composeEventHandlers.js";
9
+ import useDismiss from "../../hooks/useLayerDismiss.js";
10
+ import { LayerVisibility, LayerParent, isTopLayer } from "../../lib/layers.js";
9
11
  import useControlRef from "../../hooks/useControlRef.js";
10
- const Dialog = forwardRef(function Dialog({ open, onClose, title, children, description, footer, placement = "center", className, onCancel, onClick, panelProps, bodyProps, footerProps, closeButtonProps, ...rest }, forwardedRef) {
12
+ const Dialog = forwardRef(function Dialog({ open, initialFocus, onOpenAutoFocus, onClose, title, children, description, footer, placement = "center", className, onCancel, onClick, onPointerDown, panelProps, bodyProps, footerProps, closeButtonProps, ...rest }, forwardedRef) {
11
13
  const { ref, attach } = useControlRef(forwardedRef);
14
+ const suppressBackdrop = useRef(false);
12
15
  const titleId = useId();
13
16
  const descriptionId = useId();
17
+ const parentVisible = useContext(LayerVisibility);
18
+ const visibleOpen = open && parentVisible;
19
+ const layer = useDismiss(ref, visibleOpen, (reason) => {
20
+ const node = ref.current;
21
+ if (reason === "escape" && node) {
22
+ const EventType = node.ownerDocument.defaultView?.Event ?? Event;
23
+ node.dispatchEvent(new EventType("cancel", { cancelable: true }));
24
+ }
25
+ }, { outside: false });
14
26
  useEffect(() => {
15
27
  const dialog = ref.current;
16
28
  if (!dialog)
17
29
  return;
18
- if (open && !dialog.open)
30
+ if (visibleOpen && !dialog.open) {
19
31
  dialog.showModal();
20
- if (!open && dialog.open)
32
+ const EventType = dialog.ownerDocument.defaultView?.Event ?? Event;
33
+ const event = new EventType("contour-open-auto-focus", {
34
+ cancelable: true,
35
+ });
36
+ onOpenAutoFocus?.(event);
37
+ if (!event.defaultPrevented)
38
+ initialFocus?.current?.focus();
39
+ }
40
+ if (!visibleOpen && dialog.open)
21
41
  dialog.close();
22
- if (!open)
42
+ if (!visibleOpen)
23
43
  return;
24
44
  const release = lockDocumentScroll(dialog.ownerDocument);
25
45
  dialog.addEventListener("close", release);
@@ -27,17 +47,28 @@ const Dialog = forwardRef(function Dialog({ open, onClose, title, children, desc
27
47
  dialog.removeEventListener("close", release);
28
48
  release();
29
49
  };
30
- }, [open, ref]);
31
- return (_jsx("dialog", { ...rest, ref: attach, role: "dialog", "aria-modal": open || undefined, className: cx("dialog", `dialog-${placement}`, className), "aria-labelledby": cx(titleId, rest["aria-labelledby"]), "aria-describedby": cx(description && descriptionId, rest["aria-describedby"]) || undefined, onCancel: (event) => {
32
- onCancel?.(event);
33
- const canceled = event.defaultPrevented;
34
- // The browser must never close the element independently of the controlled open prop.
35
- event.preventDefault();
36
- if (!canceled)
37
- onClose();
38
- }, onClick: composeEventHandlers(onClick, (event) => {
39
- if (event.target === event.currentTarget)
40
- onClose();
41
- }), children: _jsxs("div", { ...panelProps, className: cx("dialog-panel", panelProps?.className), children: [_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: "dialog-description", children: description }))] }), _jsx("button", { ...closeButtonProps, type: "button", className: cx("dialog-close", closeButtonProps?.className), "aria-label": closeButtonProps?.["aria-label"] ?? "Close dialog", onClick: composeEventHandlers(closeButtonProps?.onClick, () => onClose()), children: _jsx(X, { size: 18 }) })] }), _jsx("div", { ...bodyProps, className: cx("dialog-body", bodyProps?.className), children: children }), footer && (_jsx("footer", { ...footerProps, className: cx("dialog-footer", footerProps?.className), children: footer }))] }) }));
50
+ }, [visibleOpen, ref, initialFocus, onOpenAutoFocus]);
51
+ return (_jsx(LayerVisibility.Provider, { value: visibleOpen, children: _jsx(LayerParent.Provider, { value: layer, children: _jsx("dialog", { ...rest, ref: attach, role: "dialog", "aria-modal": visibleOpen || undefined, className: ccx("dialog", `dialog-${placement}`, className), "aria-labelledby": cx(titleId, rest["aria-labelledby"]), "aria-describedby": cx(description && descriptionId, rest["aria-describedby"]) ||
52
+ undefined, onCancel: (event) => {
53
+ onCancel?.(event);
54
+ const canceled = event.defaultPrevented;
55
+ // The browser must never close the element independently of the controlled open prop.
56
+ event.preventDefault();
57
+ if (!canceled &&
58
+ isTopLayer(event.currentTarget.ownerDocument, layer))
59
+ onClose();
60
+ }, onPointerDown: (event) => {
61
+ onPointerDown?.(event);
62
+ if (event.target === event.currentTarget)
63
+ suppressBackdrop.current = !isTopLayer(event.currentTarget.ownerDocument, layer);
64
+ }, onClick: (event) => {
65
+ const suppressed = suppressBackdrop.current;
66
+ suppressBackdrop.current = false;
67
+ onClick?.(event);
68
+ if (!event.defaultPrevented &&
69
+ !suppressed &&
70
+ event.target === event.currentTarget)
71
+ onClose();
72
+ }, children: _jsxs("div", { ...panelProps, className: ccx("dialog-panel", panelProps?.className), children: [_jsxs("header", { className: ccx("dialog-header"), children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: ccx("dialog-description"), children: description }))] }), _jsx("button", { ...closeButtonProps, type: "button", className: ccx("dialog-close", closeButtonProps?.className), "aria-label": closeButtonProps?.["aria-label"] ?? "Close dialog", onClick: composeEventHandlers(closeButtonProps?.onClick, () => onClose()), children: _jsx(X, { size: 18 }) })] }), _jsx("div", { ...bodyProps, className: ccx("dialog-body", bodyProps?.className), children: children }), footer && (_jsx("footer", { ...footerProps, className: ccx("dialog-footer", footerProps?.className), children: footer }))] }) }) }) }));
42
73
  });
43
74
  export default Dialog;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  import { Plus } from "lucide-react";
3
4
  export default function Disclosure({ className, summary, children, defaultOpen = false, iconSize = 23, iconStrokeWidth = 1.25, }) {
4
- return (_jsxs("details", { className: `disclosure ${className}`, open: defaultOpen, children: [_jsxs("summary", { children: [summary, _jsx(Plus, { className: "disclosure-icon", size: iconSize, strokeWidth: iconStrokeWidth, "aria-hidden": "true" })] }), children] }));
5
+ return (_jsxs("details", { className: ccx("disclosure", className), open: defaultOpen, children: [_jsxs("summary", { children: [summary, _jsx(Plus, { className: ccx("disclosure-icon"), size: iconSize, strokeWidth: iconStrokeWidth, "aria-hidden": "true" })] }), children] }));
5
6
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  export default function Divider({ label }) {
3
4
  if (!label)
4
- return _jsx("hr", { className: "divider" });
5
- return (_jsx("div", { className: "divider divider-labelled mono", role: "separator", children: _jsx("span", { children: label }) }));
5
+ return _jsx("hr", { className: ccx("divider") });
6
+ return (_jsx("div", { className: ccx("divider divider-labelled mono"), role: "separator", children: _jsx("span", { children: label }) }));
6
7
  }
@@ -1,5 +1,8 @@
1
1
  /** Button-triggered action menu with roving focus, Escape/outside dismissal, and focus return. */
2
- import { type ReactNode } from "react";
2
+ import { type ReactElement, type ReactNode } from "react";
3
+ import type { FloatingOptions } from "../../hooks/useFloatingPanel.js";
4
+ import type { NativePartProps } from "../../lib/componentProps.js";
5
+ import { type DismissReason } from "../../lib/layers.js";
3
6
  export type MenuItem = {
4
7
  label: string;
5
8
  onSelect: () => void;
@@ -9,12 +12,16 @@ export type MenuItem = {
9
12
  /** Stable identity when labels are duplicated or translated. */
10
13
  id?: string;
11
14
  };
12
- export type DropdownMenuProps = {
15
+ export type DropdownMenuProps = FloatingOptions & {
13
16
  label: string;
14
17
  items: MenuItem[];
15
- align?: "start" | "end";
18
+ triggerProps?: NativePartProps<"button">;
19
+ renderTrigger?: (props: NativePartProps<"button"> & {
20
+ children: ReactNode;
21
+ }) => ReactElement;
22
+ onDismiss?: (reason: DismissReason, event: KeyboardEvent | PointerEvent) => void;
16
23
  open?: boolean;
17
24
  defaultOpen?: boolean;
18
25
  onOpenChange?: (open: boolean) => void;
19
26
  };
20
- export default function DropdownMenu({ label, items, align, open: controlledOpen, defaultOpen, onOpenChange, }: DropdownMenuProps): import("react").JSX.Element;
27
+ export default function DropdownMenu({ label, items, align, side, sideOffset, collisionPadding, portal, container: portalContainer, triggerProps, renderTrigger, onDismiss, open: controlledOpen, defaultOpen, onOpenChange, }: DropdownMenuProps): import("react").JSX.Element;
@@ -1,17 +1,27 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /** Button-triggered action menu with roving focus, Escape/outside dismissal, and focus return. */
4
- import { useCallback, useEffect, useId, useRef } from "react";
4
+ import { useContext, useCallback, useEffect, useId, useRef, useMemo, useState, } from "react";
5
5
  import { ChevronDown } from "lucide-react";
6
6
  import useOpenState from "../../hooks/useOpenState.js";
7
- import useDismiss from "../../hooks/useDismiss.js";
8
- import { cx } from "../../lib/classNames.js";
7
+ import OverlayTrigger from "./OverlayTrigger.js";
8
+ import useControlRef from "../../hooks/useControlRef.js";
9
+ import { composeRefs } from "../../lib/composeRefs.js";
10
+ import FloatingPortal from "./FloatingPortal.js";
11
+ import { LayerVisibility, LayerParent, } from "../../lib/layers.js";
12
+ import useDismiss from "../../hooks/useLayerDismiss.js";
13
+ import { ccx } from "../../lib/classNames.js";
9
14
  import { nextIndexForKey } from "../../lib/keyboard.js";
10
- export default function DropdownMenu({ label, items, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, }) {
15
+ export default function DropdownMenu({ label, items, align = "start", side = "bottom", sideOffset = 8, collisionPadding = 8, portal = true, container: portalContainer, triggerProps, renderTrigger, onDismiss, open: controlledOpen, defaultOpen = false, onOpenChange, }) {
11
16
  const menuId = useId();
12
17
  const [open, setOpen] = useOpenState(controlledOpen, defaultOpen, onOpenChange);
18
+ const parentVisible = useContext(LayerVisibility);
19
+ const visibleOpen = open && parentVisible;
13
20
  const container = useRef(null);
14
- const trigger = useRef(null);
21
+ const panel = useRef(null);
22
+ const { ref: trigger, attach: attachButton } = useControlRef(triggerProps?.ref ?? null);
23
+ const [anchorElement, setAnchorElement] = useState(null);
24
+ const attachTrigger = useMemo(() => composeRefs(attachButton, setAnchorElement), [attachButton]);
15
25
  const initialFocus = useRef("first");
16
26
  const search = useRef({ value: "", time: 0 });
17
27
  const itemRefs = useRef([]);
@@ -19,68 +29,101 @@ export default function DropdownMenu({ label, items, align = "start", open: cont
19
29
  setOpen(false);
20
30
  if (returnFocus)
21
31
  trigger.current?.focus();
22
- }, [setOpen]);
23
- useDismiss(container, open, useCallback((reason) => close(reason === "escape"), [close]));
32
+ }, [setOpen, trigger]);
33
+ const layer = useDismiss(container, visibleOpen, (reason, event) => {
34
+ onDismiss?.(reason, event);
35
+ if (!event.defaultPrevented)
36
+ close(reason === "escape");
37
+ }, { branches: [panel, trigger], layerElement: panel });
24
38
  useEffect(() => {
25
- if (!open)
39
+ if (!visibleOpen)
26
40
  return;
27
41
  search.current = { value: "", time: 0 };
28
42
  const enabled = itemRefs.current.filter((item) => item && !item.disabled);
29
- (initialFocus.current === "last" ? enabled.at(-1) : enabled[0])?.focus();
30
- }, [open]);
31
- return (_jsxs("div", { ref: container, className: cx("menu", `menu-${align}`), children: [_jsxs("button", { ref: trigger, type: "button", className: "menu-trigger", "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": menuId, onClick: () => {
32
- initialFocus.current = "first";
33
- setOpen(!open);
34
- }, onKeyDown: (event) => {
35
- if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
36
- return;
37
- event.preventDefault();
38
- initialFocus.current = event.key === "ArrowUp" ? "last" : "first";
39
- if (open) {
40
- const enabled = itemRefs.current.filter((item) => item && !item.disabled);
41
- (initialFocus.current === "last"
42
- ? enabled.at(-1)
43
- : enabled[0])?.focus();
44
- }
45
- else
46
- setOpen(true);
47
- }, children: [label, _jsx(ChevronDown, { size: 15, "aria-hidden": "true", className: "menu-chevron" })] }), _jsx("div", { id: menuId, role: "menu", "aria-label": label, className: "menu-list", hidden: !open, children: items.map((item, index) => (_jsxs("button", { ref: (element) => {
48
- itemRefs.current[index] = element;
49
- }, type: "button", role: "menuitem", tabIndex: -1, disabled: item.disabled, className: cx("menu-item", item.danger && "menu-item-danger"), onClick: () => {
50
- item.onSelect();
51
- close();
52
- }, onKeyDown: (event) => {
53
- if (event.key === "Tab")
54
- return close(false);
55
- const enabled = items
56
- .map((entry, position) => (entry.disabled ? -1 : position))
57
- .filter((position) => position !== -1);
58
- const next = nextIndexForKey(event.key, enabled.indexOf(index), enabled.length, "vertical");
59
- if (next !== null) {
60
- event.preventDefault();
61
- itemRefs.current[enabled[next]]?.focus();
62
- }
63
- else if (event.key.length === 1 &&
64
- !event.ctrlKey &&
65
- !event.altKey &&
66
- !event.metaKey) {
67
- const now = performance.now();
68
- const previous = now - search.current.time < 500 ? search.current.value : "";
69
- const value = previous &&
70
- [...previous].every((letter) => letter === event.key.toLowerCase())
71
- ? event.key.toLowerCase()
72
- : previous + event.key.toLowerCase();
73
- search.current = { value, time: now };
74
- const start = enabled.indexOf(index) + 1;
75
- const ordered = [
76
- ...enabled.slice(start),
77
- ...enabled.slice(0, start),
78
- ];
79
- const match = ordered.find((position) => items[position].label.toLowerCase().startsWith(value));
80
- if (match !== undefined) {
81
- event.preventDefault();
82
- itemRefs.current[match]?.focus();
83
- }
84
- }
85
- }, children: [item.icon, item.label] }, item.id ?? item.label))) })] }));
43
+ const first = initialFocus.current === "last" ? enabled.at(-1) : enabled[0];
44
+ (first ?? panel.current)?.focus();
45
+ }, [visibleOpen, anchorElement]);
46
+ const ownedTrigger = {
47
+ ...triggerProps,
48
+ ref: attachTrigger,
49
+ type: "button",
50
+ className: ccx("menu-trigger", triggerProps?.className),
51
+ "aria-haspopup": "menu",
52
+ "aria-expanded": visibleOpen,
53
+ "aria-controls": menuId,
54
+ onClick: (event) => {
55
+ triggerProps?.onClick?.(event);
56
+ if (!event.defaultPrevented && !triggerProps?.disabled) {
57
+ initialFocus.current = "first";
58
+ setOpen(!open);
59
+ }
60
+ },
61
+ onKeyDown: (event) => {
62
+ triggerProps?.onKeyDown?.(event);
63
+ if (event.defaultPrevented)
64
+ return;
65
+ if (triggerProps?.disabled ||
66
+ (event.key !== "ArrowDown" && event.key !== "ArrowUp"))
67
+ return;
68
+ event.preventDefault();
69
+ initialFocus.current = event.key === "ArrowUp" ? "last" : "first";
70
+ if (!open)
71
+ setOpen(true);
72
+ else {
73
+ const enabled = itemRefs.current.filter((item) => item && !item.disabled);
74
+ (initialFocus.current === "last"
75
+ ? enabled.at(-1)
76
+ : enabled[0])?.focus();
77
+ }
78
+ },
79
+ children: (_jsxs(_Fragment, { children: [label, _jsx(ChevronDown, { size: 15, "aria-hidden": "true", className: ccx("menu-chevron") })] })),
80
+ };
81
+ return (_jsx(LayerVisibility.Provider, { value: visibleOpen, children: _jsx(LayerParent.Provider, { value: layer, children: _jsxs("div", { ref: container, className: ccx("menu", `menu-${align}`), children: [_jsx(OverlayTrigger, { renderer: renderTrigger, props: ownedTrigger }), _jsx(FloatingPortal, { anchorElement: anchorElement, anchor: trigger, panel: panel, open: visibleOpen, options: {
82
+ side,
83
+ align,
84
+ sideOffset,
85
+ collisionPadding,
86
+ portal,
87
+ container: portalContainer,
88
+ }, children: _jsx("div", { ref: panel, id: menuId, role: "menu", tabIndex: -1, "aria-label": label, className: ccx("menu-list"), hidden: !visibleOpen, children: items.map((item, index) => (_jsxs("button", { ref: (element) => {
89
+ itemRefs.current[index] = element;
90
+ }, type: "button", role: "menuitem", tabIndex: -1, disabled: item.disabled, className: ccx("menu-item", item.danger && "menu-item-danger"), onClick: () => {
91
+ item.onSelect();
92
+ close();
93
+ }, onKeyDown: (event) => {
94
+ if (event.key === "Tab")
95
+ return close(false);
96
+ const enabled = items
97
+ .map((entry, position) => entry.disabled ? -1 : position)
98
+ .filter((position) => position !== -1);
99
+ const next = nextIndexForKey(event.key, enabled.indexOf(index), enabled.length, "vertical");
100
+ if (next !== null) {
101
+ event.preventDefault();
102
+ itemRefs.current[enabled[next]]?.focus();
103
+ }
104
+ else if (event.key.length === 1 &&
105
+ !event.ctrlKey &&
106
+ !event.altKey &&
107
+ !event.metaKey) {
108
+ const now = performance.now();
109
+ const previous = now - search.current.time < 500
110
+ ? search.current.value
111
+ : "";
112
+ const value = previous &&
113
+ [...previous].every((letter) => letter === event.key.toLowerCase())
114
+ ? event.key.toLowerCase()
115
+ : previous + event.key.toLowerCase();
116
+ search.current = { value, time: now };
117
+ const start = enabled.indexOf(index) + 1;
118
+ const ordered = [
119
+ ...enabled.slice(start),
120
+ ...enabled.slice(0, start),
121
+ ];
122
+ const match = ordered.find((position) => items[position].label.toLowerCase().startsWith(value));
123
+ if (match !== undefined) {
124
+ event.preventDefault();
125
+ itemRefs.current[match]?.focus();
126
+ }
127
+ }
128
+ }, children: [item.icon, item.label] }, item.id ?? item.label))) }) })] }) }) }));
86
129
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  export default function EmptyState({ icon, title, message, action, }) {
3
- return (_jsxs("div", { className: "empty-state", children: [_jsx("span", { className: "empty-state-icon", "aria-hidden": "true", children: icon }), _jsx("h3", { children: title }), _jsx("p", { children: message }), action] }));
4
+ return (_jsxs("div", { className: ccx("empty-state"), children: [_jsx("span", { className: ccx("empty-state-icon"), "aria-hidden": "true", children: icon }), _jsx("h3", { children: title }), _jsx("p", { children: message }), action] }));
4
5
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AlertCircle } from "lucide-react";
3
- import { cx } from "../../lib/classNames.js";
3
+ import { ccx } from "../../lib/classNames.js";
4
4
  export default function Field({ id, label, children, hint, error, required, hideLabel, counter, disabled, }) {
5
- return (_jsxs("div", { className: cx("field", error && "field-invalid", disabled && "field-disabled"), children: [_jsxs("label", { htmlFor: id, className: cx("field-label mono", hideLabel && "visually-hidden"), children: [label, required && (_jsx("span", { className: "field-required", "aria-hidden": "true", children: "*" }))] }), children, (hint || error || counter) && (_jsxs("div", { className: "field-footer mono", children: [error ? (_jsxs("span", { id: `${id}-error`, className: "field-error", children: [_jsx(AlertCircle, { size: 13, "aria-hidden": "true" }), error] })) : (hint && _jsx("span", { id: `${id}-hint`, children: hint })), counter && _jsx("span", { className: "field-counter", children: counter })] }))] }));
5
+ return (_jsxs("div", { className: ccx("field", error && "field-invalid", disabled && "field-disabled"), children: [_jsxs("label", { htmlFor: id, className: ccx("field-label mono", hideLabel && "visually-hidden"), children: [label, required && (_jsx("span", { className: ccx("field-required"), "aria-hidden": "true", children: "*" }))] }), children, (hint || error || counter) && (_jsxs("div", { className: ccx("field-footer mono"), children: [error ? (_jsxs("span", { id: `${id}-error`, className: ccx("field-error"), children: [_jsx(AlertCircle, { size: 13, "aria-hidden": "true" }), error] })) : (hint && _jsx("span", { id: `${id}-hint`, children: hint })), counter && _jsx("span", { className: ccx("field-counter"), children: counter })] }))] }));
6
6
  }
@@ -3,12 +3,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /** Two-sided card that flips in 3D; a button toggles it so the back is reachable without hover. */
4
4
  import { useState, version } from "react";
5
5
  import { RefreshCw } from "lucide-react";
6
- import { cx } from "../../lib/classNames.js";
6
+ import { ccx } from "../../lib/classNames.js";
7
7
  // React 18 treats inert as an unknown string attribute; React 19 treats it as boolean.
8
8
  const inertAttributes = {
9
9
  inert: Number.parseInt(version, 10) >= 19 ? true : "",
10
10
  };
11
11
  export default function FlipCard({ front, back, label }) {
12
12
  const [flipped, setFlipped] = useState(false);
13
- return (_jsxs("div", { className: "flip-stage", children: [_jsxs("div", { className: cx("flip-card", flipped && "is-flipped"), children: [_jsx("div", { className: "flip-face flip-front", "aria-hidden": flipped, ...(flipped ? inertAttributes : {}), children: front }), _jsx("div", { className: "flip-face flip-back", "aria-hidden": !flipped, ...(!flipped ? inertAttributes : {}), children: back })] }), _jsxs("button", { type: "button", className: "flip-toggle mono", "aria-pressed": flipped, onClick: () => setFlipped((value) => !value), children: [_jsx(RefreshCw, { size: 13, "aria-hidden": "true" }), label] })] }));
13
+ return (_jsxs("div", { className: ccx("flip-stage"), children: [_jsxs("div", { className: ccx("flip-card", flipped && "is-flipped"), children: [_jsx("div", { className: ccx("flip-face flip-front"), "aria-hidden": flipped, ...(flipped ? inertAttributes : {}), children: front }), _jsx("div", { className: ccx("flip-face flip-back"), "aria-hidden": !flipped, ...(!flipped ? inertAttributes : {}), children: back })] }), _jsxs("button", { type: "button", className: ccx("flip-toggle mono"), "aria-pressed": flipped, onClick: () => setFlipped((value) => !value), children: [_jsx(RefreshCw, { size: 13, "aria-hidden": "true" }), label] })] }));
14
14
  }
@@ -0,0 +1,10 @@
1
+ import { type ReactNode, type RefObject } from "react";
2
+ import { type FloatingOptions } from "../../hooks/useFloatingPanel.js";
3
+ export default function FloatingPortal({ anchor, anchorElement, panel, open, options, children, }: {
4
+ anchor: RefObject<HTMLElement | null>;
5
+ anchorElement: HTMLElement | null;
6
+ panel: RefObject<HTMLElement | null>;
7
+ open: boolean;
8
+ options: FloatingOptions;
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useLayoutEffect, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import useFloatingPanel, { floatingContainer, } from "../../hooks/useFloatingPanel.js";
6
+ export default function FloatingPortal({ anchor, anchorElement, panel, open, options, children, }) {
7
+ const scope = useRef(null);
8
+ const target = anchorElement && options.portal !== false
9
+ ? floatingContainer(anchorElement, options)
10
+ : null;
11
+ useLayoutEffect(() => {
12
+ const root = scope.current;
13
+ if (!open || !anchorElement || !root)
14
+ return;
15
+ const view = anchorElement.ownerDocument.defaultView;
16
+ if (!view)
17
+ return;
18
+ const synchronize = () => {
19
+ const computed = view.getComputedStyle(anchorElement);
20
+ // Copy inherited CSS variables without controlling the host document's theme.
21
+ for (let index = 0; index < computed.length; index++) {
22
+ const name = computed.item(index);
23
+ if (name.startsWith("--"))
24
+ root.style.setProperty(name, computed.getPropertyValue(name));
25
+ }
26
+ root.dir = computed.direction;
27
+ root.style.fontFamily = computed.fontFamily;
28
+ root.style.color = computed.color;
29
+ };
30
+ synchronize();
31
+ const observer = new view.MutationObserver(synchronize);
32
+ let ancestor = anchorElement;
33
+ while (ancestor) {
34
+ observer.observe(ancestor, {
35
+ attributes: true,
36
+ attributeFilter: [
37
+ "style",
38
+ "class",
39
+ "dir",
40
+ "data-theme",
41
+ "data-contour-theme",
42
+ ],
43
+ });
44
+ ancestor = ancestor.parentElement;
45
+ }
46
+ return () => observer.disconnect();
47
+ }, [anchorElement, target, open]);
48
+ useFloatingPanel(anchor, panel, open, { ...options, container: target });
49
+ const content = (_jsx("span", { ref: scope, className: "contour-floating-root contour-root", style: { display: "contents" }, children: children }));
50
+ return target ? createPortal(content, target) : content;
51
+ }
@@ -2,8 +2,8 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  /** Circular icon-only button (the portfolio's source/copy button), with a required accessible label. */
4
4
  import { forwardRef } from "react";
5
- import { cx } from "../../lib/classNames.js";
5
+ import { ccx } from "../../lib/classNames.js";
6
6
  const IconButton = forwardRef(function IconButton({ label, children, variant = "outline", size = "md", spin = false, className, type = "button", ...rest }, ref) {
7
- return (_jsx("button", { ref: ref, ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: cx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
7
+ return (_jsx("button", { ref: ref, ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: ccx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
8
8
  });
9
9
  export default IconButton;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  export default function Kbd({ children }) {
3
- return _jsx("kbd", { className: "kbd", children: children });
4
+ return _jsx("kbd", { className: ccx("kbd"), children: children });
4
5
  }
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Pulls its child toward a fine pointer while hovered, then springs back (an awwwards staple). */
4
5
  import { useRef } from "react";
5
6
  import { motionDisabledFor } from "../../lib/motion.js";
6
7
  export default function Magnetic({ children, strength = 0.35 }) {
7
8
  const ref = useRef(null);
8
9
  const reset = () => ref.current?.style.removeProperty("--magnet");
9
- return (_jsx("span", { ref: ref, className: "magnetic", onPointerMove: (event) => {
10
+ return (_jsx("span", { ref: ref, className: ccx("magnetic"), onPointerMove: (event) => {
10
11
  const element = ref.current;
11
12
  if (!element ||
12
13
  event.pointerType !== "mouse" ||
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  import { Asterisk } from "lucide-react";
3
4
  export default function Marquee({ items, label, duration = 28 }) {
4
5
  // The track is rendered twice so the loop has no visible seam.
5
- const track = (hidden) => (_jsx("ul", { className: "marquee-track", "aria-hidden": hidden || undefined, children: items.map((item) => (_jsxs("li", { children: [_jsx(Asterisk, { size: 18, "aria-hidden": "true" }), item] }, item))) }));
6
- return (_jsx("div", { className: "marquee", role: "region", "aria-label": label, style: { "--marquee-duration": `${duration}s` }, children: _jsxs("div", { className: "marquee-inner", children: [track(false), track(true)] }) }));
6
+ const track = (hidden) => (_jsx("ul", { className: ccx("marquee-track"), "aria-hidden": hidden || undefined, children: items.map((item) => (_jsxs("li", { children: [_jsx(Asterisk, { size: 18, "aria-hidden": "true" }), item] }, item))) }));
7
+ return (_jsx("div", { className: ccx("marquee"), role: "region", "aria-label": label, style: { "--marquee-duration": `${duration}s` }, children: _jsxs("div", { className: ccx("marquee-inner"), children: [track(false), track(true)] }) }));
7
8
  }
@@ -0,0 +1,11 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { NativePartProps } from "../../lib/componentProps.js";
3
+ type Props = NativePartProps<"button"> & {
4
+ children: ReactNode;
5
+ };
6
+ /** One owned native-button contract shared by default and consumer-rendered triggers. */
7
+ export default function OverlayTrigger({ renderer, props, }: {
8
+ renderer?: (props: Props) => ReactElement;
9
+ props: Props;
10
+ }): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** One owned native-button contract shared by default and consumer-rendered triggers. */
3
+ export default function OverlayTrigger({ renderer, props, }) {
4
+ return renderer ? renderer(props) : _jsx("button", { ...props });
5
+ }
@@ -3,5 +3,9 @@ export type PaginationProps = {
3
3
  pageCount: number;
4
4
  onChange: (page: number) => void;
5
5
  label?: string;
6
+ previousLabel?: string;
7
+ nextLabel?: string;
8
+ pageLabel?: (page: number) => string;
9
+ formatPage?: (page: number) => string;
6
10
  };
7
- export default function Pagination({ page, pageCount, onChange, label, }: PaginationProps): import("react").JSX.Element;
11
+ export default function Pagination({ page, pageCount, onChange, label, previousLabel, nextLabel, pageLabel, formatPage, }: PaginationProps): import("react").JSX.Element;