@abuzareal/contour-ui 0.1.1 → 0.2.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 (70) hide show
  1. package/CHANGELOG.md +86 -11
  2. package/CODE_OF_CONDUCT.md +5 -0
  3. package/CONTRIBUTING.md +15 -0
  4. package/NEXTJS.md +51 -0
  5. package/README.md +176 -76
  6. package/RELEASING.md +14 -28
  7. package/SECURITY.md +1 -1
  8. package/dist/components/hero/SceneBoundary.js +1 -0
  9. package/dist/components/hero/Sculpture.js +6 -4
  10. package/dist/components/three/ObjectStudy.js +7 -3
  11. package/dist/components/ui/Alert.js +1 -0
  12. package/dist/components/ui/BrandMark.js +1 -0
  13. package/dist/components/ui/Checkbox.js +1 -0
  14. package/dist/components/ui/CountUp.js +1 -0
  15. package/dist/components/ui/DataTable.js +1 -0
  16. package/dist/components/ui/Dialog.js +1 -0
  17. package/dist/components/ui/DropdownMenu.d.ts +7 -1
  18. package/dist/components/ui/DropdownMenu.js +62 -14
  19. package/dist/components/ui/FlipCard.js +1 -0
  20. package/dist/components/ui/Magnetic.js +1 -0
  21. package/dist/components/ui/Pagination.js +1 -0
  22. package/dist/components/ui/Popover.d.ts +4 -1
  23. package/dist/components/ui/Popover.js +7 -5
  24. package/dist/components/ui/RadioGroup.js +1 -0
  25. package/dist/components/ui/Reveal.js +1 -0
  26. package/dist/components/ui/ScrollProgress.js +1 -0
  27. package/dist/components/ui/SegmentedControl.js +1 -0
  28. package/dist/components/ui/SelectField.d.ts +11 -1
  29. package/dist/components/ui/SelectField.js +6 -4
  30. package/dist/components/ui/Slider.js +1 -0
  31. package/dist/components/ui/Switch.js +1 -0
  32. package/dist/components/ui/Tabs.d.ts +4 -1
  33. package/dist/components/ui/Tabs.js +12 -5
  34. package/dist/components/ui/Tag.js +1 -0
  35. package/dist/components/ui/TextArea.d.ts +8 -1
  36. package/dist/components/ui/TextArea.js +21 -7
  37. package/dist/components/ui/TextField.d.ts +10 -1
  38. package/dist/components/ui/TextField.js +17 -6
  39. package/dist/components/ui/ThemeToggle.js +1 -0
  40. package/dist/components/ui/TiltCard.js +1 -0
  41. package/dist/components/ui/ToastProvider.js +1 -0
  42. package/dist/components/ui/Tooltip.js +1 -0
  43. package/dist/fonts/dm-mono-LICENSE.txt +93 -0
  44. package/dist/fonts/dm-mono-latin-400-normal.woff2 +0 -0
  45. package/dist/fonts/dm-sans-LICENSE.txt +93 -0
  46. package/dist/fonts/dm-sans-latin-wght-normal.woff2 +0 -0
  47. package/dist/fonts/space-grotesk-LICENSE.txt +93 -0
  48. package/dist/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts-latin.css +35 -0
  50. package/dist/hooks/useClipboardCopy.js +1 -0
  51. package/dist/hooks/useControlRef.d.ts +6 -0
  52. package/dist/hooks/useControlRef.js +13 -0
  53. package/dist/hooks/useDismiss.js +1 -0
  54. package/dist/hooks/useFormReset.d.ts +3 -0
  55. package/dist/hooks/useFormReset.js +31 -0
  56. package/dist/hooks/useHeaderScroll.js +1 -0
  57. package/dist/hooks/useIdleTrigger.js +1 -0
  58. package/dist/hooks/useInitialHashScroll.js +1 -0
  59. package/dist/hooks/useMobileMenu.js +1 -0
  60. package/dist/hooks/useMotionPreference.js +1 -0
  61. package/dist/hooks/useOpenState.d.ts +2 -0
  62. package/dist/hooks/useOpenState.js +15 -0
  63. package/dist/hooks/usePrintPreparation.js +1 -0
  64. package/dist/hooks/useScrollSpy.js +1 -0
  65. package/dist/hooks/useSmoothScroll.js +1 -0
  66. package/dist/hooks/useTheme.js +1 -0
  67. package/dist/hooks/useThreeStage.d.ts +4 -0
  68. package/dist/hooks/useThreeStage.js +165 -128
  69. package/dist/hooks/useToast.js +1 -0
  70. package/package.json +29 -12
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Hero sculpture: three chrome/lime links that follow the pointer and drift while motion is on. */
3
4
  import { useRef } from "react";
@@ -5,10 +6,13 @@ import * as THREE from "three";
5
6
  import useThreeStage from "../../hooks/useThreeStage.js";
6
7
  import { createChrome, createLime, createLinkGeometry, } from "../../lib/threeMaterials.js";
7
8
  const restRotation = new THREE.Euler(-0.15, -0.1, -0.1);
8
- function setupSculpture({ scene }) {
9
+ function setupSculpture({ scene, onDispose }) {
9
10
  const chrome = createChrome();
11
+ onDispose?.(() => chrome.dispose());
10
12
  const lime = createLime();
13
+ onDispose?.(() => lime.dispose());
11
14
  const geometry = createLinkGeometry();
15
+ onDispose?.(() => geometry.dispose());
12
16
  // Three interlocking links arranged as one assembly.
13
17
  const assembly = new THREE.Group();
14
18
  const firstLink = new THREE.Mesh(geometry, chrome);
@@ -36,9 +40,7 @@ function setupSculpture({ scene }) {
36
40
  assembly.position.y = 0;
37
41
  },
38
42
  dispose: () => {
39
- geometry.dispose();
40
- chrome.dispose();
41
- lime.dispose();
43
+ scene.remove(assembly);
42
44
  },
43
45
  };
44
46
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Lays out a set of 3D objects in a responsive grid; each turns slowly and the set leans toward the pointer. */
3
4
  import { useRef } from "react";
@@ -22,8 +23,9 @@ export default function ObjectStudy({ label, motionEnabled, study, }) {
22
23
  useThreeStage(hostRef, {
23
24
  motionEnabled,
24
25
  cameraDistance,
25
- setup: ({ scene, camera }) => {
26
+ setup: ({ scene, camera, onDispose }) => {
26
27
  const set = objectStudies[study]();
28
+ onDispose?.(set.dispose);
27
29
  const group = new THREE.Group();
28
30
  set.objects.forEach((object) => {
29
31
  object.rotation.copy(restTilt);
@@ -42,7 +44,6 @@ export default function ObjectStudy({ label, motionEnabled, study, }) {
42
44
  arrange();
43
45
  return {
44
46
  update: ({ delta, elapsed, pointer }) => {
45
- arrange();
46
47
  set.objects.forEach((object, index) => {
47
48
  object.rotation.y += delta * 0.45;
48
49
  object.rotation.x =
@@ -57,7 +58,10 @@ export default function ObjectStudy({ label, motionEnabled, study, }) {
57
58
  group.rotation.set(0, 0, 0);
58
59
  set.objects.forEach((object) => object.rotation.copy(restTilt));
59
60
  },
60
- dispose: set.dispose,
61
+ resize: arrange,
62
+ dispose: () => {
63
+ scene.remove(group);
64
+ },
61
65
  };
62
66
  },
63
67
  });
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { Asterisk, CircleCheck, CircleX, Info, TriangleAlert, X, } from "lucide-react";
3
4
  import { cx } from "../../lib/classNames.js";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { safeHref } from "../../lib/safeHref.js";
3
4
  export default function BrandMark({ label, href = "#hero", onClick, }) {
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Square checkbox with an acid check, optional description, error, and indeterminate state. */
3
4
  import { useEffect, useId, useRef } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Number that counts up the first time it scrolls into view; screen readers get the final value. */
3
4
  import { useEffect, useRef, useState } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Editorial data table with mono headers, optional column sorting, and horizontal scroll on small screens. */
3
4
  import { useMemo, useState } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Modal dialog on the native <dialog> element; also slides in from either edge as a drawer. */
3
4
  import { useEffect, useId, useRef } from "react";
@@ -5,10 +5,16 @@ export type MenuItem = {
5
5
  onSelect: () => void;
6
6
  icon?: ReactNode;
7
7
  danger?: boolean;
8
+ disabled?: boolean;
9
+ /** Stable identity when labels are duplicated or translated. */
10
+ id?: string;
8
11
  };
9
12
  export type DropdownMenuProps = {
10
13
  label: string;
11
14
  items: MenuItem[];
12
15
  align?: "start" | "end";
16
+ open?: boolean;
17
+ defaultOpen?: boolean;
18
+ onOpenChange?: (open: boolean) => void;
13
19
  };
14
- export default function DropdownMenu({ label, items, align, }: DropdownMenuProps): import("react").JSX.Element;
20
+ export default function DropdownMenu({ label, items, align, open: controlledOpen, defaultOpen, onOpenChange, }: DropdownMenuProps): import("react").JSX.Element;
@@ -1,38 +1,86 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Button-triggered action menu with roving focus, Escape/outside dismissal, and focus return. */
3
- import { useCallback, useEffect, useId, useRef, useState, } from "react";
4
+ import { useCallback, useEffect, useId, useRef } from "react";
4
5
  import { ChevronDown } from "lucide-react";
6
+ import useOpenState from "../../hooks/useOpenState.js";
5
7
  import useDismiss from "../../hooks/useDismiss.js";
6
8
  import { cx } from "../../lib/classNames.js";
7
9
  import { nextIndexForKey } from "../../lib/keyboard.js";
8
- export default function DropdownMenu({ label, items, align = "start", }) {
10
+ export default function DropdownMenu({ label, items, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, }) {
9
11
  const menuId = useId();
10
- const [open, setOpen] = useState(false);
12
+ const [open, setOpen] = useOpenState(controlledOpen, defaultOpen, onOpenChange);
11
13
  const container = useRef(null);
12
14
  const trigger = useRef(null);
15
+ const initialFocus = useRef("first");
16
+ const search = useRef({ value: "", time: 0 });
13
17
  const itemRefs = useRef([]);
14
18
  const close = useCallback((returnFocus = true) => {
15
19
  setOpen(false);
16
20
  if (returnFocus)
17
21
  trigger.current?.focus();
18
- }, []);
22
+ }, [setOpen]);
19
23
  useDismiss(container, open, useCallback((reason) => close(reason === "escape"), [close]));
20
24
  useEffect(() => {
21
- if (open)
22
- itemRefs.current[0]?.focus();
25
+ if (!open)
26
+ return;
27
+ search.current = { value: "", time: 0 };
28
+ const enabled = itemRefs.current.filter((item) => item && !item.disabled);
29
+ (initialFocus.current === "last" ? enabled.at(-1) : enabled[0])?.focus();
23
30
  }, [open]);
24
- 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: () => setOpen((value) => !value), 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) => {
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) => {
25
48
  itemRefs.current[index] = element;
26
- }, type: "button", role: "menuitem", tabIndex: -1, className: cx("menu-item", item.danger && "menu-item-danger"), onClick: () => {
49
+ }, type: "button", role: "menuitem", tabIndex: -1, disabled: item.disabled, className: cx("menu-item", item.danger && "menu-item-danger"), onClick: () => {
27
50
  item.onSelect();
28
51
  close();
29
52
  }, onKeyDown: (event) => {
30
53
  if (event.key === "Tab")
31
54
  return close(false);
32
- const next = nextIndexForKey(event.key, index, items.length, "vertical");
33
- if (next === null)
34
- return;
35
- event.preventDefault();
36
- itemRefs.current[next]?.focus();
37
- }, children: [item.icon, item.label] }, item.label))) })] }));
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))) })] }));
38
86
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Two-sided card that flips in 3D; a button toggles it so the back is reachable without hover. */
3
4
  import { useState, version } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Pulls its child toward a fine pointer while hovered, then springs back (an awwwards staple). */
3
4
  import { useRef } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Page navigation with previous/next controls and a condensed list of two-digit page numbers. */
3
4
  import { ArrowLeft, ArrowRight } from "lucide-react";
@@ -6,5 +6,8 @@ export type PopoverProps = {
6
6
  title: string;
7
7
  children: ReactNode;
8
8
  align?: "start" | "end";
9
+ open?: boolean;
10
+ defaultOpen?: boolean;
11
+ onOpenChange?: (open: boolean) => void;
9
12
  };
10
- export default function Popover({ trigger, title, children, align, }: PopoverProps): import("react").JSX.Element;
13
+ export default function Popover({ trigger, title, children, align, open: controlledOpen, defaultOpen, onOpenChange, }: PopoverProps): import("react").JSX.Element;
@@ -1,18 +1,20 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Non-modal floating panel anchored to a trigger; closes on Escape or an outside press. */
3
- import { useCallback, useId, useRef, useState } from "react";
4
+ import { useCallback, useId, useRef } from "react";
5
+ import useOpenState from "../../hooks/useOpenState.js";
4
6
  import useDismiss from "../../hooks/useDismiss.js";
5
7
  import { cx } from "../../lib/classNames.js";
6
- export default function Popover({ trigger, title, children, align = "start", }) {
8
+ export default function Popover({ trigger, title, children, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, }) {
7
9
  const panelId = useId();
8
10
  const titleId = useId();
9
- const [open, setOpen] = useState(false);
11
+ const [open, setOpen] = useOpenState(controlledOpen, defaultOpen, onOpenChange);
10
12
  const container = useRef(null);
11
13
  const button = useRef(null);
12
14
  useDismiss(container, open, useCallback((reason) => {
13
15
  setOpen(false);
14
16
  if (reason === "escape")
15
17
  button.current?.focus();
16
- }, []));
17
- return (_jsxs("div", { ref: container, className: cx("popover", `popover-${align}`), children: [_jsx("button", { ref: button, type: "button", className: "menu-trigger", "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((value) => !value), children: trigger }), _jsxs("div", { id: panelId, role: "dialog", "aria-labelledby": titleId, className: "popover-panel", hidden: !open, children: [_jsx("strong", { id: titleId, className: "popover-title", children: title }), children] })] }));
18
+ }, [setOpen]));
19
+ return (_jsxs("div", { ref: container, className: cx("popover", `popover-${align}`), children: [_jsx("button", { ref: button, type: "button", className: "menu-trigger", "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen(!open), children: trigger }), _jsxs("div", { id: panelId, role: "dialog", "aria-labelledby": titleId, className: "popover-panel", hidden: !open, children: [_jsx("strong", { id: titleId, className: "popover-title", children: title }), children] })] }));
18
20
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Fieldset of round radio options sharing one name, with a mono legend. */
3
4
  import { useId } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Fades and lifts its children into view the first time they enter the viewport. */
3
4
  import { useEffect, useRef } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Thin acid bar fixed to the top of the viewport that tracks page scroll progress. */
3
4
  import { useEffect, useRef } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Single-choice pill group (filter buttons) with arrow-key navigation. */
3
4
  import { useRef } from "react";
@@ -10,4 +10,14 @@ export type SelectFieldProps = SelectHTMLAttributes<HTMLSelectElement> & {
10
10
  error?: string;
11
11
  placeholder?: string;
12
12
  };
13
- export default function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }: SelectFieldProps): import("react").JSX.Element;
13
+ declare const SelectField: import("react").ForwardRefExoticComponent<SelectHTMLAttributes<HTMLSelectElement> & {
14
+ label: string;
15
+ options: {
16
+ value: string;
17
+ label: string;
18
+ }[];
19
+ hint?: string;
20
+ error?: string;
21
+ placeholder?: string;
22
+ } & import("react").RefAttributes<HTMLSelectElement>>;
23
+ export default SelectField;
@@ -1,13 +1,15 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Native select styled as an underlined field with a chevron. */
3
- import { useId } from "react";
4
+ import { forwardRef, useId } from "react";
4
5
  import { ChevronDown } from "lucide-react";
5
6
  import { fieldDescribedBy } from "../../lib/a11y.js";
6
7
  import Field from "./Field.js";
7
- export default function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }) {
8
+ const SelectField = forwardRef(function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }, ref) {
8
9
  const generatedId = useId();
9
10
  const id = providedId ?? generatedId;
10
- return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, children: _jsxs("div", { className: "field-control field-select", children: [_jsxs("select", { ...rest, id: id, "aria-invalid": error ? true : undefined, "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
11
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, children: _jsxs("div", { className: "field-control field-select", children: [_jsxs("select", { ...rest, ref: ref, id: id, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
11
12
  .filter(Boolean)
12
13
  .join(" ") || undefined, children: [placeholder && (_jsx("option", { value: "", disabled: true, children: placeholder })), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] }), _jsx(ChevronDown, { className: "field-chevron", size: 16, "aria-hidden": "true" })] }) }));
13
- }
14
+ });
15
+ export default SelectField;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Range slider with a filled acid track and a live value readout. */
3
4
  import { useId } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** On/off switch (role="switch") with a pill track that fills acid when on. */
3
4
  import { useId } from "react";
@@ -9,5 +9,8 @@ export type TabsProps = {
9
9
  label: string;
10
10
  tabs: TabItem[];
11
11
  defaultTabId?: string;
12
+ /** Controlled selection. The caller must update this after onTabChange. */
13
+ activeTabId?: string;
14
+ onTabChange?: (id: string) => void;
12
15
  };
13
- export default function Tabs({ label, tabs, defaultTabId }: TabsProps): import("react").JSX.Element;
16
+ export default function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, }: TabsProps): import("react").JSX.Element;
@@ -1,13 +1,14 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Tabs with a sliding underline indicator, arrow-key navigation, and linked panels. */
3
4
  import { useId, useLayoutEffect, useRef, useState, } from "react";
4
5
  import { nextIndexForKey } from "../../lib/keyboard.js";
5
- export default function Tabs({ label, tabs, defaultTabId }) {
6
+ export default function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, }) {
6
7
  const baseId = useId();
7
8
  const [activeId, setActiveId] = useState(defaultTabId ?? tabs[0]?.id ?? "");
8
9
  const [indicator, setIndicator] = useState({ left: 0, width: 0 });
9
10
  const tabRefs = useRef([]);
10
- const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === activeId));
11
+ const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === (activeTabId ?? activeId)));
11
12
  const selectedId = tabs[activeIndex]?.id;
12
13
  useLayoutEffect(() => {
13
14
  const measure = () => {
@@ -18,9 +19,11 @@ export default function Tabs({ label, tabs, defaultTabId }) {
18
19
  measure();
19
20
  window.addEventListener("resize", measure);
20
21
  return () => window.removeEventListener("resize", measure);
21
- }, [activeIndex]);
22
+ }, [activeIndex, tabs]);
22
23
  const select = (index) => {
23
- setActiveId(tabs[index].id);
24
+ if (activeTabId === undefined)
25
+ setActiveId(tabs[index].id);
26
+ onTabChange?.(tabs[index].id);
24
27
  tabRefs.current[index]?.focus();
25
28
  };
26
29
  return (_jsxs("div", { className: "tabs", children: [_jsx("div", { className: "tabs-list", role: "tablist", "aria-label": label, style: {
@@ -30,7 +33,11 @@ export default function Tabs({ label, tabs, defaultTabId }) {
30
33
  const selected = tab.id === selectedId;
31
34
  return (_jsx("button", { ref: (element) => {
32
35
  tabRefs.current[index] = element;
33
- }, id: `${baseId}-tab-${tab.id}`, type: "button", role: "tab", "aria-selected": selected, "aria-controls": `${baseId}-panel-${tab.id}`, tabIndex: selected ? 0 : -1, className: "tabs-tab", onClick: () => setActiveId(tab.id), onKeyDown: (event) => {
36
+ }, id: `${baseId}-tab-${tab.id}`, type: "button", role: "tab", "aria-selected": selected, "aria-controls": `${baseId}-panel-${tab.id}`, tabIndex: selected ? 0 : -1, className: "tabs-tab", onClick: () => {
37
+ if (activeTabId === undefined)
38
+ setActiveId(tab.id);
39
+ onTabChange?.(tab.id);
40
+ }, onKeyDown: (event) => {
34
41
  const next = nextIndexForKey(event.key, index, tabs.length);
35
42
  if (next === null)
36
43
  return;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Mono "+ tag" label (as in project notes); becomes a removable chip when given onRemove. */
3
4
  import { X } from "lucide-react";
@@ -7,4 +7,11 @@ export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
7
7
  /** Grows with its content instead of scrolling. */
8
8
  autoResize?: boolean;
9
9
  };
10
- export default function TextArea({ id: providedId, "aria-describedby": describedBy, label, hint, error, autoResize, maxLength, onChange, rows, ...rest }: TextAreaProps): import("react").JSX.Element;
10
+ declare const TextArea: import("react").ForwardRefExoticComponent<TextareaHTMLAttributes<HTMLTextAreaElement> & {
11
+ label: string;
12
+ hint?: string;
13
+ error?: string;
14
+ /** Grows with its content instead of scrolling. */
15
+ autoResize?: boolean;
16
+ } & import("react").RefAttributes<HTMLTextAreaElement>>;
17
+ export default TextArea;
@@ -1,12 +1,15 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  /** Multi-line text input with optional auto-resize and character counter. */
3
- import { useId, useLayoutEffect, useRef, useState, } from "react";
4
+ import { forwardRef, useId, useLayoutEffect, useState, } from "react";
4
5
  import { fieldDescribedBy } from "../../lib/a11y.js";
5
6
  import Field from "./Field.js";
6
- export default function TextArea({ id: providedId, "aria-describedby": describedBy, label, hint, error, autoResize = false, maxLength, onChange, rows = 3, ...rest }) {
7
+ import useControlRef from "../../hooks/useControlRef.js";
8
+ import useFormReset from "../../hooks/useFormReset.js";
9
+ const TextArea = forwardRef(function TextArea({ id: providedId, "aria-describedby": describedBy, label, hint, error, autoResize = false, maxLength, onChange, rows = 3, ...rest }, forwardedRef) {
7
10
  const generatedId = useId();
8
11
  const id = providedId ?? generatedId;
9
- const ref = useRef(null);
12
+ const { ref, attach } = useControlRef(forwardedRef);
10
13
  const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
11
14
  const fitContent = () => {
12
15
  const element = ref.current;
@@ -16,13 +19,24 @@ export default function TextArea({ id: providedId, "aria-describedby": described
16
19
  element.style.height = `${element.scrollHeight}px`;
17
20
  };
18
21
  useLayoutEffect(fitContent);
19
- return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, counter: maxLength
22
+ useFormReset(ref, () => {
23
+ setLength(ref.current?.value.length ?? 0);
24
+ fitContent();
25
+ });
26
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, counter: maxLength !== undefined
20
27
  ? `${rest.value !== undefined ? String(rest.value).length : length}/${maxLength}`
21
- : undefined, children: _jsx("div", { className: "field-control", children: _jsx("textarea", { ...rest, ref: ref, id: id, rows: rows, maxLength: maxLength, "data-auto-resize": autoResize || undefined, "aria-invalid": error ? true : undefined, "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
28
+ : undefined, children: _jsx("div", { className: "field-control", children: _jsx("textarea", { ...rest, ref: attach, id: id, rows: rows, maxLength: maxLength, "data-auto-resize": autoResize || undefined, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
22
29
  .filter(Boolean)
23
- .join(" ") || undefined, onChange: (event) => {
30
+ .join(" ") || undefined,
31
+ // Native input also runs when React deduplicates a repeated value after form.reset().
32
+ onInput: (event) => {
33
+ setLength(event.currentTarget.value.length);
34
+ fitContent();
35
+ rest.onInput?.(event);
36
+ }, onChange: (event) => {
24
37
  setLength(event.target.value.length);
25
38
  fitContent();
26
39
  onChange?.(event);
27
40
  } }) }) }));
28
- }
41
+ });
42
+ export default TextArea;
@@ -9,4 +9,13 @@ export type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
9
9
  /** Content after the input, e.g. a unit or an icon button. */
10
10
  trailing?: ReactNode;
11
11
  };
12
- export default function TextField({ id: providedId, "aria-describedby": describedBy, label, hint, error, hideLabel, leadingIcon, trailing, maxLength, onChange, ...rest }: TextFieldProps): import("react").JSX.Element;
12
+ declare const TextField: import("react").ForwardRefExoticComponent<InputHTMLAttributes<HTMLInputElement> & {
13
+ label: string;
14
+ hint?: string;
15
+ error?: string;
16
+ hideLabel?: boolean;
17
+ leadingIcon?: ReactNode;
18
+ /** Content after the input, e.g. a unit or an icon button. */
19
+ trailing?: ReactNode;
20
+ } & import("react").RefAttributes<HTMLInputElement>>;
21
+ export default TextField;
@@ -1,18 +1,29 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Single-line text input with underline styling, optional icon/adornment, and character counter. */
3
- import { useId, useState, } from "react";
4
+ import { forwardRef, useId, useState, } from "react";
4
5
  import { fieldDescribedBy } from "../../lib/a11y.js";
5
6
  import Field from "./Field.js";
6
- export default function TextField({ id: providedId, "aria-describedby": describedBy, label, hint, error, hideLabel, leadingIcon, trailing, maxLength, onChange, ...rest }) {
7
+ import useControlRef from "../../hooks/useControlRef.js";
8
+ import useFormReset from "../../hooks/useFormReset.js";
9
+ const TextField = forwardRef(function TextField({ id: providedId, "aria-describedby": describedBy, label, hint, error, hideLabel, leadingIcon, trailing, maxLength, onChange, ...rest }, forwardedRef) {
10
+ const { ref, attach } = useControlRef(forwardedRef);
7
11
  const generatedId = useId();
8
12
  const id = providedId ?? generatedId;
9
13
  const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
10
- return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, hideLabel: hideLabel, disabled: rest.disabled, counter: maxLength
14
+ useFormReset(ref, () => setLength(ref.current?.value.length ?? 0));
15
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, hideLabel: hideLabel, disabled: rest.disabled, counter: maxLength !== undefined
11
16
  ? `${rest.value !== undefined ? String(rest.value).length : length}/${maxLength}`
12
- : undefined, children: _jsxs("div", { className: "field-control", children: [leadingIcon && _jsx("span", { className: "field-icon", children: leadingIcon }), _jsx("input", { ...rest, id: id, maxLength: maxLength, "aria-invalid": error ? true : undefined, "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
17
+ : undefined, children: _jsxs("div", { className: "field-control", children: [leadingIcon && _jsx("span", { className: "field-icon", children: leadingIcon }), _jsx("input", { ...rest, ref: attach, id: id, maxLength: maxLength, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
13
18
  .filter(Boolean)
14
- .join(" ") || undefined, onChange: (event) => {
19
+ .join(" ") || undefined,
20
+ // Native input also runs when React deduplicates a repeated value after form.reset().
21
+ onInput: (event) => {
22
+ setLength(event.currentTarget.value.length);
23
+ rest.onInput?.(event);
24
+ }, onChange: (event) => {
15
25
  setLength(event.target.value.length);
16
26
  onChange?.(event);
17
27
  } }), trailing && _jsx("span", { className: "field-trailing", children: trailing })] }) }));
18
- }
28
+ });
29
+ export default TextField;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Header button that switches between the light and dark themes. */
3
4
  import { Moon, Sun } from "lucide-react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Card that tilts in 3D toward a fine pointer with a moving glare highlight. */
3
4
  import { useRef } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Renders queued toasts in a polite live region; each auto-dismisses unless hovered or focused. */
3
4
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
3
4
  import { cloneElement, useId, useState } from "react";