@abuzareal/contour-ui 0.2.1 → 0.4.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 (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/COMPOSITION.md +193 -0
  3. package/CONTRACTS.md +25 -25
  4. package/FORMS.md +94 -0
  5. package/NEXTJS.md +5 -1
  6. package/PUBLIC-API.md +176 -174
  7. package/README.md +11 -9
  8. package/dist/components/ui/Button.d.ts +10 -2
  9. package/dist/components/ui/Button.js +7 -3
  10. package/dist/components/ui/Checkbox.d.ts +8 -2
  11. package/dist/components/ui/Checkbox.js +18 -7
  12. package/dist/components/ui/DepthButton.d.ts +5 -2
  13. package/dist/components/ui/DepthButton.js +8 -3
  14. package/dist/components/ui/Dialog.d.ts +21 -3
  15. package/dist/components/ui/Dialog.js +17 -11
  16. package/dist/components/ui/ExternalLink.d.ts +3 -2
  17. package/dist/components/ui/ExternalLink.js +7 -3
  18. package/dist/components/ui/IconButton.d.ts +10 -2
  19. package/dist/components/ui/IconButton.js +7 -3
  20. package/dist/components/ui/Popover.d.ts +19 -4
  21. package/dist/components/ui/Popover.js +16 -9
  22. package/dist/components/ui/RadioGroup.d.ts +13 -5
  23. package/dist/components/ui/RadioGroup.js +35 -8
  24. package/dist/components/ui/SelectField.js +16 -2
  25. package/dist/components/ui/Slider.d.ts +24 -7
  26. package/dist/components/ui/Slider.js +45 -7
  27. package/dist/components/ui/Switch.d.ts +26 -5
  28. package/dist/components/ui/Switch.js +54 -6
  29. package/dist/components/ui/Tabs.d.ts +16 -4
  30. package/dist/components/ui/Tabs.js +46 -24
  31. package/dist/components/ui/TextArea.js +2 -0
  32. package/dist/components/ui/TextField.js +5 -1
  33. package/dist/components/ui/ThemeToggle.d.ts +2 -1
  34. package/dist/components/ui/ThemeToggle.js +5 -3
  35. package/dist/hooks/useControlRef.js +2 -2
  36. package/dist/hooks/useFormReset.d.ts +1 -1
  37. package/dist/hooks/useTabRefs.d.ts +4 -0
  38. package/dist/hooks/useTabRefs.js +12 -0
  39. package/dist/lib/componentProps.d.ts +5 -0
  40. package/dist/lib/componentProps.js +1 -0
  41. package/dist/lib/composeEventHandlers.d.ts +4 -0
  42. package/dist/lib/composeEventHandlers.js +8 -0
  43. package/dist/lib/composeRefs.d.ts +3 -0
  44. package/dist/lib/composeRefs.js +24 -0
  45. package/examples/forms/HookFormSettingsForm.tsx +176 -0
  46. package/examples/forms/ManualSettingsForm-0.3.0.tsx +102 -0
  47. package/examples/forms/NativeSettingsForm.tsx +119 -0
  48. package/examples/forms/settings.ts +61 -0
  49. package/examples/forms/useSubmission.ts +77 -0
  50. package/package.json +8 -4
@@ -1,11 +1,49 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Range slider with a filled acid track and a live value readout. */
4
- import { useId } from "react";
5
- export default function Slider({ label, value, onChange, min = 0, max = 100, step = 1, format = String, disabled, }) {
6
- const id = useId();
3
+ /** Native range submission with controlled/default state, reset and a formatted readout. */
4
+ import { forwardRef, useId, useLayoutEffect, useState, } from "react";
5
+ import { cx } from "../../lib/classNames.js";
6
+ import useControlRef from "../../hooks/useControlRef.js";
7
+ import useFormReset from "../../hooks/useFormReset.js";
8
+ const Slider = forwardRef(function Slider({ label, value, defaultValue, onChange, min = 0, max = 100, step = 1, format = String, disabled, id: providedId, className, style, onInput, hint, error, "aria-describedby": describedBy, ...rest }, forwardedRef) {
9
+ const generatedId = useId();
10
+ const id = providedId ?? generatedId;
11
+ const [initial] = useState(defaultValue ?? value ?? (min + Math.max(min, max)) / 2);
12
+ const [readout, setReadout] = useState(value ?? initial);
13
+ const { ref, attach } = useControlRef(forwardedRef);
14
+ useLayoutEffect(() => {
15
+ const input = ref.current;
16
+ if (!input)
17
+ return;
18
+ input.defaultValue = String(initial);
19
+ // Read the browser's sanitized value; clamp/step behavior stays native.
20
+ if (value !== undefined)
21
+ input.value = String(value);
22
+ const actual = input.valueAsNumber;
23
+ if (Number.isFinite(actual))
24
+ setReadout(actual);
25
+ }, [readout, initial, min, max, step, ref, value]);
26
+ useFormReset(ref, () => {
27
+ const input = ref.current;
28
+ if (!input)
29
+ return;
30
+ if (value !== undefined)
31
+ input.value = String(value);
32
+ setReadout(input.valueAsNumber);
33
+ });
7
34
  const progress = max > min
8
- ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
35
+ ? Math.min(100, Math.max(0, ((readout - min) / (max - min)) * 100))
9
36
  : 0;
10
- return (_jsxs("div", { className: "slider", children: [_jsxs("div", { className: "slider-header mono", children: [_jsx("label", { htmlFor: id, className: "field-label", children: label }), _jsx("output", { htmlFor: id, children: format(value) })] }), _jsx("input", { id: id, type: "range", min: min, max: max, step: step, value: value, disabled: disabled, "aria-valuetext": format(value), style: { "--progress": `${progress}%` }, onChange: (event) => onChange(Number(event.target.value)) })] }));
11
- }
37
+ return (_jsxs("div", { className: "slider", children: [_jsxs("div", { className: "slider-header mono", children: [_jsx("label", { htmlFor: id, className: "field-label", children: label }), _jsx("output", { htmlFor: id, children: format(readout) })] }), _jsx("input", { ...rest, ref: attach, id: id, type: "range", min: min, max: max, step: step, value: value, defaultValue: value === undefined ? initial : undefined, disabled: disabled, className: className, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": cx(describedBy, hint && `${id}-hint`, error && `${id}-error`) ||
38
+ undefined, "aria-valuetext": rest["aria-valuetext"] ?? format(readout), style: { ...style, "--progress": `${progress}%` }, onInput: (event) => {
39
+ if (value === undefined)
40
+ setReadout(event.currentTarget.valueAsNumber);
41
+ onInput?.(event);
42
+ }, onChange: (event) => {
43
+ const next = event.currentTarget.valueAsNumber;
44
+ if (value === undefined)
45
+ setReadout(next);
46
+ onChange?.(next);
47
+ } }), hint && (_jsx("span", { id: `${id}-hint`, className: "choice-description", children: hint })), error && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: error }))] }));
48
+ });
49
+ export default Slider;
@@ -1,8 +1,29 @@
1
- export type SwitchProps = {
1
+ /** Button switch with a native checkbox backing submission, validation and reset. */
2
+ import { type ButtonHTMLAttributes, type FormEventHandler } from "react";
3
+ export type SwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "defaultValue" | "onInvalid" | "children" | "dangerouslySetInnerHTML"> & {
2
4
  label: string;
3
- checked: boolean;
4
- onChange: (checked: boolean) => void;
5
+ checked?: boolean;
6
+ defaultChecked?: boolean;
7
+ onChange?: (checked: boolean) => void;
8
+ /** Submitted only when checked; defaults to the native checkbox value "on". */
9
+ value?: string;
10
+ required?: boolean;
5
11
  description?: string;
6
- disabled?: boolean;
12
+ error?: string;
13
+ /** Native invalid event from the backing checkbox; preventDefault owns feedback/focus. */
14
+ onInvalid?: FormEventHandler<HTMLInputElement>;
7
15
  };
8
- export default function Switch({ label, checked, onChange, description, disabled, }: SwitchProps): import("react").JSX.Element;
16
+ declare const Switch: import("react").ForwardRefExoticComponent<Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "dangerouslySetInnerHTML" | "onChange" | "onInvalid" | "value" | "defaultValue"> & {
17
+ label: string;
18
+ checked?: boolean;
19
+ defaultChecked?: boolean;
20
+ onChange?: (checked: boolean) => void;
21
+ /** Submitted only when checked; defaults to the native checkbox value "on". */
22
+ value?: string;
23
+ required?: boolean;
24
+ description?: string;
25
+ error?: string;
26
+ /** Native invalid event from the backing checkbox; preventDefault owns feedback/focus. */
27
+ onInvalid?: FormEventHandler<HTMLInputElement>;
28
+ } & import("react").RefAttributes<HTMLButtonElement>>;
29
+ export default Switch;
@@ -1,8 +1,56 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** On/off switch (role="switch") with a pill track that fills acid when on. */
4
- import { useId } from "react";
5
- export default function Switch({ label, checked, onChange, description, disabled, }) {
6
- const id = useId();
7
- return (_jsxs("div", { className: "switch", children: [_jsx("button", { id: id, type: "button", role: "switch", "aria-checked": checked, "aria-describedby": description ? `${id}-description` : undefined, disabled: disabled, className: "switch-track", onClick: () => onChange(!checked), children: _jsx("span", { className: "switch-thumb" }) }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description }))] })] }));
8
- }
3
+ /** Button switch with a native checkbox backing submission, validation and reset. */
4
+ import { forwardRef, useId, useLayoutEffect, useRef, useState, } from "react";
5
+ import { cx } from "../../lib/classNames.js";
6
+ import useControlRef from "../../hooks/useControlRef.js";
7
+ import useFormReset from "../../hooks/useFormReset.js";
8
+ const Switch = forwardRef(function Switch({ label, checked, defaultChecked, onChange, description, error, disabled, required, name, form, value = "on", id: providedId, className, onClick, onInvalid, "aria-describedby": describedBy, ...rest }, forwardedRef) {
9
+ const generatedId = useId();
10
+ const id = providedId ?? generatedId;
11
+ const [initial] = useState(defaultChecked ?? checked ?? false);
12
+ const [localChecked, setLocalChecked] = useState(initial);
13
+ const [invalid, setInvalid] = useState(false);
14
+ const current = checked ?? localChecked;
15
+ const { ref: button, attach } = useControlRef(forwardedRef);
16
+ const input = useRef(null);
17
+ const message = error ??
18
+ (invalid && !current ? "Turn on this option to continue." : undefined);
19
+ useLayoutEffect(() => {
20
+ if (input.current) {
21
+ input.current.defaultChecked = initial;
22
+ input.current.checked = current;
23
+ }
24
+ });
25
+ useFormReset(input, () => {
26
+ if (checked === undefined)
27
+ setLocalChecked(input.current?.checked ?? initial);
28
+ else if (input.current)
29
+ input.current.checked = checked;
30
+ setInvalid(false);
31
+ });
32
+ return (_jsxs("div", { className: "switch", children: [_jsx("button", { ...rest, ref: attach, id: id, name: undefined, form: form, type: "button", role: "switch", "aria-labelledby": cx(`${id}-label`, rest["aria-labelledby"]), "aria-checked": current, "aria-required": required || undefined, "aria-invalid": message ? true : rest["aria-invalid"], "aria-describedby": cx(describedBy, description && `${id}-description`, message && `${id}-error`) || undefined, disabled: disabled, className: cx("switch-track", className), onClick: (event) => {
33
+ onClick?.(event);
34
+ if (event.defaultPrevented)
35
+ return;
36
+ if (event.currentTarget.matches(":disabled"))
37
+ return;
38
+ if (checked === undefined) {
39
+ if (input.current)
40
+ input.current.checked = !current;
41
+ setLocalChecked(!current);
42
+ }
43
+ setInvalid(false);
44
+ onChange?.(!current);
45
+ }, children: _jsx("span", { className: "switch-thumb" }) }), _jsx("input", { ref: input, type: "checkbox", hidden: true, "aria-hidden": "true", tabIndex: -1, name: name, form: form, value: value, disabled: disabled, required: required, checked: checked, defaultChecked: checked === undefined ? initial : undefined, onChange: () => { }, onInvalid: (event) => {
46
+ onInvalid?.(event);
47
+ const canceled = event.defaultPrevented;
48
+ // Native validation must never try to focus the noninteractive backing control.
49
+ event.preventDefault();
50
+ if (!canceled) {
51
+ setInvalid(true);
52
+ button.current?.focus();
53
+ }
54
+ } }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { id: `${id}-label`, className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description })), message && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: message }))] })] }));
55
+ });
56
+ export default Switch;
@@ -1,16 +1,28 @@
1
- /** Tabs with a sliding underline indicator, arrow-key navigation, and linked panels. */
1
+ /** Controlled/default tabs, with props and native refs for root/list/buttons/panels. */
2
2
  import { type ReactNode } from "react";
3
+ import type { NativePartProps } from "../../lib/componentProps.js";
3
4
  export type TabItem = {
4
5
  id: string;
5
6
  label: string;
6
7
  content: ReactNode;
8
+ /** Native styling/events/ref; disabled-tab semantics are not introduced by this part API. */
9
+ buttonProps?: Omit<NativePartProps<"button">, "disabled">;
10
+ panelProps?: NativePartProps<"div">;
7
11
  };
8
- export type TabsProps = {
12
+ export type TabsProps = Omit<NativePartProps<"div">, "ref"> & {
9
13
  label: string;
10
14
  tabs: TabItem[];
11
15
  defaultTabId?: string;
12
- /** Controlled selection. The caller must update this after onTabChange. */
13
16
  activeTabId?: string;
14
17
  onTabChange?: (id: string) => void;
18
+ listProps?: NativePartProps<"div">;
15
19
  };
16
- export default function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, }: TabsProps): import("react").JSX.Element;
20
+ declare const Tabs: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"div">, "ref"> & {
21
+ label: string;
22
+ tabs: TabItem[];
23
+ defaultTabId?: string;
24
+ activeTabId?: string;
25
+ onTabChange?: (id: string) => void;
26
+ listProps?: NativePartProps<"div">;
27
+ } & import("react").RefAttributes<HTMLDivElement>>;
28
+ export default Tabs;
@@ -1,48 +1,70 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Tabs with a sliding underline indicator, arrow-key navigation, and linked panels. */
4
- import { useId, useLayoutEffect, useRef, useState, } from "react";
3
+ import { createElement as _createElement } from "react";
4
+ /** Controlled/default tabs, with props and native refs for root/list/buttons/panels. */
5
+ import { forwardRef, useId, useLayoutEffect, useRef, useState, } from "react";
5
6
  import { nextIndexForKey } from "../../lib/keyboard.js";
6
- export default function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, }) {
7
- const baseId = useId();
7
+ import { cx } from "../../lib/classNames.js";
8
+ import useTabRefs from "../../hooks/useTabRefs.js";
9
+ const Tabs = forwardRef(function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, listProps, className, id, ...rest }, ref) {
10
+ const generatedId = useId();
11
+ const baseId = id ?? generatedId;
8
12
  const [activeId, setActiveId] = useState(defaultTabId ?? tabs[0]?.id ?? "");
9
13
  const [indicator, setIndicator] = useState({ left: 0, width: 0 });
10
- const tabRefs = useRef([]);
14
+ const tabRefs = useRef(new Map());
11
15
  const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === (activeTabId ?? activeId)));
12
16
  const selectedId = tabs[activeIndex]?.id;
17
+ const buttonRefs = useTabRefs(tabs, tabRefs);
13
18
  useLayoutEffect(() => {
14
19
  const measure = () => {
15
- const tab = tabRefs.current[activeIndex];
16
- if (tab)
17
- setIndicator({ left: tab.offsetLeft, width: tab.offsetWidth });
20
+ const tab = selectedId === undefined ? undefined : tabRefs.current.get(selectedId);
21
+ if (tab) {
22
+ const next = { left: tab.offsetLeft, width: tab.offsetWidth };
23
+ setIndicator((current) => current.left === next.left && current.width === next.width
24
+ ? current
25
+ : next);
26
+ }
18
27
  };
19
28
  measure();
20
29
  window.addEventListener("resize", measure);
21
30
  return () => window.removeEventListener("resize", measure);
22
- }, [activeIndex, tabs]);
23
- const select = (index) => {
31
+ }, [
32
+ selectedId,
33
+ tabs,
34
+ className,
35
+ rest.style,
36
+ listProps?.className,
37
+ listProps?.style,
38
+ ]);
39
+ const select = (tab, focus = false) => {
24
40
  if (activeTabId === undefined)
25
- setActiveId(tabs[index].id);
26
- onTabChange?.(tabs[index].id);
27
- tabRefs.current[index]?.focus();
41
+ setActiveId(tab.id);
42
+ onTabChange?.(tab.id);
43
+ if (focus)
44
+ tabRefs.current.get(tab.id)?.focus();
28
45
  };
29
- return (_jsxs("div", { className: "tabs", children: [_jsx("div", { className: "tabs-list", role: "tablist", "aria-label": label, style: {
46
+ const buttonId = (tab) => tab.buttonProps?.id ?? `${baseId}-tab-${tab.id}`;
47
+ const panelId = (tab) => tab.panelProps?.id ?? `${baseId}-panel-${tab.id}`;
48
+ return (_jsxs("div", { ...rest, ref: ref, id: id, className: cx("tabs", className), children: [_jsx("div", { ...listProps, className: cx("tabs-list", listProps?.className), role: "tablist", "aria-label": label, style: {
49
+ ...listProps?.style,
30
50
  "--indicator-left": `${indicator.left}px`,
31
51
  "--indicator-width": `${indicator.width}px`,
32
52
  }, children: tabs.map((tab, index) => {
33
53
  const selected = tab.id === selectedId;
34
- return (_jsx("button", { ref: (element) => {
35
- tabRefs.current[index] = element;
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);
54
+ return (_createElement("button", { ...tab.buttonProps, key: tab.id, ref: buttonRefs[index], id: buttonId(tab), type: "button", role: "tab", "aria-selected": selected, "aria-controls": panelId(tab), tabIndex: selected ? 0 : -1, className: cx("tabs-tab", tab.buttonProps?.className), onClick: (event) => {
55
+ tab.buttonProps?.onClick?.(event);
56
+ if (!event.defaultPrevented)
57
+ select(tab);
40
58
  }, onKeyDown: (event) => {
59
+ tab.buttonProps?.onKeyDown?.(event);
60
+ if (event.defaultPrevented)
61
+ return;
41
62
  const next = nextIndexForKey(event.key, index, tabs.length);
42
63
  if (next === null)
43
64
  return;
44
65
  event.preventDefault();
45
- select(next);
46
- }, children: tab.label }, tab.id));
47
- }) }), tabs.map((tab) => (_jsx("div", { id: `${baseId}-panel-${tab.id}`, role: "tabpanel", "aria-labelledby": `${baseId}-tab-${tab.id}`, hidden: tab.id !== selectedId, tabIndex: 0, className: "tabs-panel", children: tab.content }, tab.id)))] }));
48
- }
66
+ select(tabs[next], true);
67
+ } }, tab.label));
68
+ }) }), tabs.map((tab) => (_createElement("div", { ...tab.panelProps, key: tab.id, id: panelId(tab), role: "tabpanel", "aria-labelledby": cx(buttonId(tab), tab.panelProps?.["aria-labelledby"]), hidden: tab.id !== selectedId, tabIndex: 0, className: cx("tabs-panel", tab.panelProps?.className) }, tab.content)))] }));
69
+ });
70
+ export default Tabs;
@@ -20,6 +20,8 @@ const TextArea = forwardRef(function TextArea({ id: providedId, "aria-describedb
20
20
  };
21
21
  useLayoutEffect(fitContent);
22
22
  useFormReset(ref, () => {
23
+ if (ref.current && rest.value !== undefined)
24
+ ref.current.value = String(rest.value);
23
25
  setLength(ref.current?.value.length ?? 0);
24
26
  fitContent();
25
27
  });
@@ -11,7 +11,11 @@ const TextField = forwardRef(function TextField({ id: providedId, "aria-describe
11
11
  const generatedId = useId();
12
12
  const id = providedId ?? generatedId;
13
13
  const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
14
- useFormReset(ref, () => setLength(ref.current?.value.length ?? 0));
14
+ useFormReset(ref, () => {
15
+ if (ref.current && rest.value !== undefined)
16
+ ref.current.value = String(rest.value);
17
+ setLength(ref.current?.value.length ?? 0);
18
+ });
15
19
  return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, hideLabel: hideLabel, disabled: rest.disabled, counter: maxLength !== undefined
16
20
  ? `${rest.value !== undefined ? String(rest.value).length : length}/${maxLength}`
17
21
  : 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)]
@@ -2,4 +2,5 @@ export type ThemeToggleProps = {
2
2
  /** When false the theme switches instantly instead of animating. */
3
3
  animate: boolean;
4
4
  };
5
- export default function ThemeToggle({ animate }: ThemeToggleProps): import("react").JSX.Element;
5
+ declare const ThemeToggle: import("react").ForwardRefExoticComponent<ThemeToggleProps & import("react").RefAttributes<HTMLButtonElement>>;
6
+ export default ThemeToggle;
@@ -1,10 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /** Header button that switches between the light and dark themes. */
4
+ import { forwardRef } from "react";
4
5
  import { Moon, Sun } from "lucide-react";
5
6
  import useTheme from "../../hooks/useTheme.js";
6
- export default function ThemeToggle({ animate }) {
7
+ const ThemeToggle = forwardRef(function ThemeToggle({ animate }, ref) {
7
8
  const { theme, toggle } = useTheme(animate);
8
9
  const dark = theme === "dark";
9
- return (_jsxs("button", { type: "button", className: "theme-toggle", "aria-label": "Dark theme", "aria-pressed": dark, title: dark ? "Switch to light theme" : "Switch to dark theme", onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: "theme-icon-moon", size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: "theme-icon-sun", size: 17, "aria-hidden": "true" })] }));
10
- }
10
+ return (_jsxs("button", { ref: ref, type: "button", className: "theme-toggle", "aria-label": "Dark theme", "aria-pressed": dark, title: dark ? "Switch to light theme" : "Switch to dark theme", onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: "theme-icon-moon", size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: "theme-icon-sun", size: 17, "aria-hidden": "true" })] }));
11
+ });
12
+ export default ThemeToggle;
@@ -6,8 +6,8 @@ export default function useControlRef(forwarded) {
6
6
  useImperativeHandle(forwarded, () => {
7
7
  const element = ref.current;
8
8
  if (!element)
9
- throw new Error("The field control must be mounted before exposing its ref.");
9
+ throw new Error("The native target must be mounted before exposing its ref.");
10
10
  return element;
11
- });
11
+ }, []);
12
12
  return { ref, attach: ref };
13
13
  }
@@ -1,3 +1,3 @@
1
1
  import { type RefObject } from "react";
2
2
  /** Native reset fires before the browser restores default values. */
3
- export default function useFormReset<T extends HTMLInputElement | HTMLTextAreaElement>(ref: RefObject<T | null>, sync: () => void): void;
3
+ export default function useFormReset<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLFieldSetElement>(ref: RefObject<T | null>, sync: () => void): void;
@@ -0,0 +1,4 @@
1
+ import { type RefObject } from "react";
2
+ import type { TabItem } from "../components/ui/Tabs.js";
3
+ /** Keep ID-based internal focus targets and caller button refs in the same lifecycle. */
4
+ export default function useTabRefs(tabs: TabItem[], targets: RefObject<Map<string, HTMLButtonElement>>): import("react").RefCallback<HTMLButtonElement>[];
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import { composeRefs } from "../lib/composeRefs.js";
4
+ /** Keep ID-based internal focus targets and caller button refs in the same lifecycle. */
5
+ export default function useTabRefs(tabs, targets) {
6
+ return useMemo(() => tabs.map((tab) => composeRefs((element) => {
7
+ if (element)
8
+ targets.current?.set(tab.id, element);
9
+ else
10
+ targets.current?.delete(tab.id);
11
+ }, tab.buttonProps?.ref)), [tabs, targets]);
12
+ }
@@ -0,0 +1,5 @@
1
+ import type { ComponentPropsWithRef, JSX } from "react";
2
+ /** Props customize existing owned DOM parts; children and raw markup stay component-owned. */
3
+ export type NativePartProps<Tag extends keyof JSX.IntrinsicElements> = Omit<ComponentPropsWithRef<Tag>, "children" | "dangerouslySetInnerHTML"> & {
4
+ [name: `data-${string}`]: string | number | boolean | undefined;
5
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ /** Caller vetoes an internal action without replacing required state/ARIA wiring. */
2
+ export declare function composeEventHandlers<Event extends {
3
+ defaultPrevented: boolean;
4
+ }>(caller: ((event: Event) => void) | undefined, internal: (event: Event) => void): (event: Event) => void;
@@ -0,0 +1,8 @@
1
+ /** Caller vetoes an internal action without replacing required state/ARIA wiring. */
2
+ export function composeEventHandlers(caller, internal) {
3
+ return (event) => {
4
+ caller?.(event);
5
+ if (!event.defaultPrevented)
6
+ internal(event);
7
+ };
8
+ }
@@ -0,0 +1,3 @@
1
+ import type { Ref, RefCallback } from "react";
2
+ /** Retain internal nodes and caller refs, honoring React 19 cleanup callbacks on detach. */
3
+ export declare function composeRefs<Element>(...refs: (Ref<Element> | undefined)[]): RefCallback<Element>;
@@ -0,0 +1,24 @@
1
+ /** Retain internal nodes and caller refs, honoring React 19 cleanup callbacks on detach. */
2
+ export function composeRefs(...refs) {
3
+ let cleanups = [];
4
+ return (element) => {
5
+ for (const cleanup of cleanups)
6
+ cleanup();
7
+ cleanups = [];
8
+ if (element === null)
9
+ return;
10
+ for (const ref of refs) {
11
+ if (typeof ref === "function") {
12
+ const cleanup = ref(element);
13
+ cleanups.push(typeof cleanup === "function" ? () => cleanup() : () => ref(null));
14
+ }
15
+ else if (ref) {
16
+ // RefObject is read-only in React 18's consumer types; assigning is React's ref contract.
17
+ Object.assign(ref, { current: element });
18
+ cleanups.push(() => {
19
+ Object.assign(ref, { current: null });
20
+ });
21
+ }
22
+ }
23
+ };
24
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { Controller, useForm } from "react-hook-form";
4
+ import {
5
+ Button,
6
+ RadioGroup,
7
+ Slider,
8
+ Switch,
9
+ TextField,
10
+ } from "@abuzareal/contour-ui";
11
+ import {
12
+ defaultSettings,
13
+ type SaveSettings,
14
+ type Settings,
15
+ } from "./settings.js";
16
+ import useSubmission from "./useSubmission.js";
17
+
18
+ /** RHF is an application dependency. Native fields register; value callbacks use Controller. */
19
+ export function HookFormSettingsForm({ save }: { save: SaveSettings }) {
20
+ const {
21
+ register,
22
+ control,
23
+ handleSubmit,
24
+ reset,
25
+ setError,
26
+ setFocus,
27
+ formState: { errors },
28
+ } = useForm<Settings>({ defaultValues: defaultSettings });
29
+ const submission = useSubmission(save);
30
+ const pending = submission.status === "pending";
31
+ useEffect(() => {
32
+ if (pending) return;
33
+ for (const key of ["email", "updates", "level", "contact"] as const) {
34
+ if (errors[key]) {
35
+ setFocus(key);
36
+ break;
37
+ }
38
+ }
39
+ }, [errors, pending, setFocus]);
40
+ const submit = handleSubmit(async (values) => {
41
+ if (pending) return;
42
+ const result = await submission.run({
43
+ ...values,
44
+ email: values.email.trim(),
45
+ });
46
+ if (result) {
47
+ for (const key of ["email", "updates", "level", "contact"] as const) {
48
+ const message = result.fields[key];
49
+ if (message)
50
+ setError(key, { type: "server", message }, { shouldFocus: false });
51
+ }
52
+ }
53
+ }, submission.validationFailed);
54
+ return (
55
+ <form
56
+ noValidate
57
+ method="post"
58
+ aria-label="Hook Form settings"
59
+ onSubmit={submit}
60
+ >
61
+ <fieldset disabled={pending} aria-busy={pending}>
62
+ <legend>Notification settings</legend>
63
+ <TextField
64
+ maxLength={80}
65
+ type="email"
66
+ label="Email"
67
+ required
68
+ autoComplete="email"
69
+ error={errors.email?.message}
70
+ {...register("email", {
71
+ required: "Enter an email address.",
72
+ validate: (value) =>
73
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) ||
74
+ "Enter a valid email address.",
75
+ })}
76
+ />
77
+ <Controller
78
+ control={control}
79
+ name="updates"
80
+ render={({ field }) => (
81
+ <Switch
82
+ ref={field.ref}
83
+ name={field.name}
84
+ label="Updates"
85
+ checked={field.value}
86
+ onChange={field.onChange}
87
+ onBlur={field.onBlur}
88
+ error={errors.updates?.message}
89
+ />
90
+ )}
91
+ />
92
+ <Controller
93
+ control={control}
94
+ name="level"
95
+ rules={{
96
+ validate: (value) =>
97
+ (Number.isFinite(value) && value % 5 === 0) ||
98
+ "Use steps of five.",
99
+ min: { value: 0, message: "Choose at least zero." },
100
+ max: { value: 100, message: "Choose at most 100." },
101
+ }}
102
+ render={({ field }) => (
103
+ <Slider
104
+ ref={field.ref}
105
+ name={field.name}
106
+ label="Level"
107
+ value={field.value}
108
+ onChange={field.onChange}
109
+ onBlur={field.onBlur}
110
+ min={0}
111
+ max={100}
112
+ step={5}
113
+ format={(value) => `${value}%`}
114
+ error={errors.level?.message}
115
+ />
116
+ )}
117
+ />
118
+ <Controller
119
+ control={control}
120
+ name="contact"
121
+ rules={{
122
+ validate: (value) =>
123
+ value === "email" ||
124
+ value === "phone" ||
125
+ "Choose a contact method.",
126
+ }}
127
+ render={({ field }) => (
128
+ <RadioGroup
129
+ name={field.name}
130
+ legend="Contact method"
131
+ value={field.value}
132
+ onChange={field.onChange}
133
+ required
134
+ error={errors.contact?.message}
135
+ options={[
136
+ {
137
+ value: "email",
138
+ label: "Email contact",
139
+ inputProps: { ref: field.ref, onBlur: field.onBlur },
140
+ },
141
+ {
142
+ value: "phone",
143
+ label: "Phone contact",
144
+ inputProps: { onBlur: field.onBlur },
145
+ },
146
+ ]}
147
+ />
148
+ )}
149
+ />
150
+ </fieldset>
151
+ <p role="status" aria-label="Submission status">
152
+ {submission.message}
153
+ </p>
154
+ <Button type="submit" disabled={pending}>
155
+ Save settings
156
+ </Button>
157
+ <Button
158
+ type="button"
159
+ variant="secondary"
160
+ onClick={(event) => {
161
+ submission.cancel();
162
+ // Native reset synchronizes registered counters/auto-resize; RHF resets its own state.
163
+ event.currentTarget.form?.reset();
164
+ reset(defaultSettings);
165
+ }}
166
+ >
167
+ Reset settings
168
+ </Button>
169
+ {pending && (
170
+ <Button onClick={submission.cancel} variant="secondary">
171
+ Stop waiting
172
+ </Button>
173
+ )}
174
+ </form>
175
+ );
176
+ }