@abuzareal/contour-ui 0.3.0 → 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.
package/README.md CHANGED
@@ -6,18 +6,20 @@ Use it when you want this visual style with typed controls, feedback, navigation
6
6
 
7
7
  [Live catalogue](https://contour.abuzr.in/) · [npm package](https://www.npmjs.com/package/@abuzareal/contour-ui) · [Maintainer](https://github.com/abuzareal)
8
8
 
9
- This source checkout targets **0.3.0**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
9
+ This source checkout targets **0.4.0**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
10
10
 
11
11
  [Public API and maturity register](PUBLIC-API.md) · [Architecture, scope and compatibility](CONTRACTS.md)
12
12
 
13
13
  Version 0.3.0 adds native refs for actions/choices and typed root/part props for Dialog, Tabs and Popover. It preserves existing state callbacks, styles, runtime dependencies and optional entry boundaries. See [refs, parts and link composition](COMPOSITION.md). Check npm availability before installing; this remains an early 0.x library.
14
14
 
15
+ Version 0.4.0 adds native named/external form submission and controlled/default/reset support for Switch, Slider and RadioGroup, plus caller IDs for Checkbox. See [the form guide](FORMS.md) and the copyable native/RHF source recipes shipped in examples/forms. React Hook Form is an application dependency; no runtime dependency or CSS rewrite is added. New form props are absent from 0.3.0.
16
+
15
17
  ## Install
16
18
 
17
19
  Use an existing React application with a bundler that resolves package CSS imports. Contour ships ESM and TypeScript declarations. The package declares Node.js 22 or newer; repository tooling uses Vite 8, which needs Node.js 22.12+ or 24+.
18
20
 
19
21
  ```sh
20
- npm install @abuzareal/contour-ui@0.3.0 --save-exact
22
+ npm install @abuzareal/contour-ui@0.4.0 --save-exact
21
23
  ```
22
24
 
23
25
  Your app must provide matching `react` and `react-dom` versions: **18.3.1 or React 19**. The compatibility script covers 18.3.1, 19.0.0, and 19.3.0; it doesn't establish support for every framework or React Server Component setup. Put interactive components inside a client boundary when your framework requires one.
@@ -164,7 +166,7 @@ Give scenes a sized container and a usable static fallback. `SceneBoundary` hide
164
166
  **0.2.0** adds native field refs, controlled tabs and popover/menu open state, menu keyboard improvements, native reset synchronization, Three.js lifecycle fixes and a tested Next.js App Router integration. Existing uncontrolled defaults and React 18/19 support remain. `fonts-latin.css`, introduced in 0.1.2, now uses packaged relative font URLs for webpack resolution. `CHANGELOG.md` is included in the package archive and [npm's Code view](https://www.npmjs.com/package/@abuzareal/contour-ui?activeTab=code); it contains the upgrade details for each version.
165
167
 
166
168
  ```sh
167
- npm install @abuzareal/contour-ui@0.3.0 --save-exact
169
+ npm install @abuzareal/contour-ui@0.4.0 --save-exact
168
170
  ```
169
171
 
170
172
  Commit your manifest and lockfile, run your application's checks, and review both themes, dialogs, forms, and motion before deploying. Version 0.1.1 includes a document scroll lock for dialogs and drawers. Review any app-level lock before removing it, including nested dialogs and short screens. Package publication doesn't update deployed consumers automatically.
@@ -184,7 +186,7 @@ npm audit
184
186
  npm pack --dry-run
185
187
  ```
186
188
 
187
- `validate` checks types, lint, builds, Node/component tests, dialog and Three.js browser behavior, and archive contents. `test:compat` builds packed React 18/19 consumers and compiles the source with React 19 types. `test:consumer` checks one packed consumer, with optional peers absent. `test:optional` verifies strict `/three` and `/scroll` declarations and builds their imports with explicit peers and Three.js types. The compatibility gate runs both checks and verifies the Latin font assets. Compatibility runs need registry access for clean installs.
189
+ `validate` checks types, lint, builds, Node/component tests, dialog, native form/composition and Three.js browser behavior, and archive contents. `test:compat` builds packed React 18/19 consumers and compiles the source with React 19 types. `test:consumer` checks one packed consumer, with optional peers absent. `test:optional` verifies strict `/three` and `/scroll` declarations and builds their imports with explicit peers and Three.js types. The compatibility gate also runs shipped native/RHF recipe browser checks on React 18/19 and compiles the legacy form recipe against npm 0.3.0. It runs both optional/core checks and verifies the Latin font assets. Compatibility runs need registry access for clean installs.
188
190
 
189
191
  `src/` owns components, hooks, helpers, and public entries. `styles/` owns CSS. `tests/` owns regressions and browser checks; `scripts/` owns builds and package/consumer checks. `dist/` is generated. `RELEASING.md` in the source checkout and package archive covers validation and publication.
190
192
 
@@ -230,7 +232,7 @@ export function AccountControls() {
230
232
  }
231
233
  ```
232
234
 
233
- Interactive module boundaries retain `use client` in the built package. The pinned Next.js App Router consumer checks the packed 0.2.0 archive. Follow [Next.js setup](NEXTJS.md) for the pinned Next.js 16.4.0 / React 19.3.0 pair. Pure URL/theme-bootstrap helpers remain usable from the server. A separate fixture mode checks lazy optional Three.js loading and navigation cleanup. Lenis integration, other Next versions and alternate bundlers remain separate compatibility questions.
235
+ Interactive module boundaries retain `use client` in the built package. The pinned Next.js App Router consumer checks the packed candidate archive. Follow [Next.js setup](NEXTJS.md) for the pinned Next.js 16.4.0 / React 19.3.0 pair. Pure URL/theme-bootstrap helpers remain usable from the server. A separate fixture mode checks lazy optional Three.js loading and navigation cleanup. Lenis integration, other Next versions and alternate bundlers remain separate compatibility questions.
234
236
 
235
237
  Custom `useThreeStage` setup can register disposal through `context.onDispose?.(cleanup)` immediately after acquiring each resource, including resources allocated before setup returns. Registered resources are released on failed initialization and unmount; avoid also disposing the same resource in `StageScene.dispose`. A stage may implement `resize()` to reflow after aspect changes while motion is paused.
236
238
 
@@ -1,12 +1,12 @@
1
1
  /** Square checkbox with an acid check, optional description, error, and indeterminate state. */
2
2
  import { type InputHTMLAttributes } from "react";
3
- export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "id"> & {
3
+ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
4
4
  label: string;
5
5
  description?: string;
6
6
  error?: string;
7
7
  indeterminate?: boolean;
8
8
  };
9
- declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> & {
9
+ declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
10
10
  label: string;
11
11
  description?: string;
12
12
  error?: string;
@@ -4,14 +4,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { forwardRef, useEffect, useId } from "react";
5
5
  import { Check, Minus } from "lucide-react";
6
6
  import useControlRef from "../../hooks/useControlRef.js";
7
+ import useFormReset from "../../hooks/useFormReset.js";
7
8
  import { cx } from "../../lib/classNames.js";
8
- const Checkbox = forwardRef(function Checkbox({ label, description, error, indeterminate = false, className, ...rest }, forwardedRef) {
9
- const id = useId();
9
+ const Checkbox = forwardRef(function Checkbox({ label, description, error, indeterminate = false, className, id: providedId, ...rest }, forwardedRef) {
10
+ const generatedId = useId();
11
+ const id = providedId ?? generatedId;
10
12
  const { ref, attach } = useControlRef(forwardedRef);
11
13
  useEffect(() => {
12
14
  if (ref.current)
13
15
  ref.current.indeterminate = indeterminate;
14
16
  }, [indeterminate, ref]);
17
+ useFormReset(ref, () => {
18
+ if (!ref.current)
19
+ return;
20
+ if (rest.checked !== undefined)
21
+ ref.current.checked = rest.checked;
22
+ ref.current.indeterminate = indeterminate;
23
+ });
15
24
  return (_jsxs("div", { className: cx("choice", error && "choice-invalid", className), children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { ...rest, ref: attach, id: id, type: "checkbox", className: "choice-input", "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [
16
25
  rest["aria-describedby"],
17
26
  description && `${id}-description`,
@@ -1,14 +1,22 @@
1
- export type RadioGroupProps<Value extends string> = {
1
+ /** Native radios with caller form/name, controlled/default selection and option input refs. */
2
+ import { type FieldsetHTMLAttributes } from "react";
3
+ import type { NativePartProps } from "../../lib/componentProps.js";
4
+ export type RadioGroupProps<Value extends string> = Omit<FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "children" | "dangerouslySetInnerHTML"> & {
2
5
  legend: string;
3
6
  options: {
4
7
  value: Value;
5
8
  label: string;
6
9
  description?: string;
7
10
  disabled?: boolean;
11
+ /** Native focus/blur/ref/ID access for form controllers; selection stays group-owned. */
12
+ inputProps?: Omit<NativePartProps<"input">, "type" | "name" | "form" | "value" | "defaultValue" | "checked" | "defaultChecked" | "disabled" | "required" | "onChange">;
8
13
  }[];
9
- value: Value;
10
- onChange: (value: Value) => void;
11
- /** Lays options out in a row on wider screens. */
14
+ value?: Value;
15
+ defaultValue?: Value;
16
+ onChange?: (value: Value) => void;
12
17
  inline?: boolean;
18
+ required?: boolean;
19
+ description?: string;
20
+ error?: string;
13
21
  };
14
- export default function RadioGroup<Value extends string>({ legend, options, value, onChange, inline, }: RadioGroupProps<Value>): import("react").JSX.Element;
22
+ export default function RadioGroup<Value extends string>({ legend, options, value, defaultValue, onChange, inline, name: providedName, form, id: providedId, disabled, required, description, error, className, "aria-describedby": describedBy, ...rest }: RadioGroupProps<Value>): import("react").JSX.Element;
@@ -1,12 +1,39 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Fieldset of round radio options sharing one name, with a mono legend. */
4
- import { useId } from "react";
3
+ /** Native radios with caller form/name, controlled/default selection and option input refs. */
4
+ import { useId, useLayoutEffect, useRef, useState, } from "react";
5
5
  import { cx } from "../../lib/classNames.js";
6
- export default function RadioGroup({ legend, options, value, onChange, inline = false, }) {
7
- const name = useId();
8
- return (_jsxs("fieldset", { className: cx("radio-group", inline && "radio-group-inline"), children: [_jsx("legend", { className: "field-label mono", children: legend }), options.map((option) => {
9
- const id = `${name}-${option.value}`;
10
- return (_jsxs("div", { className: "choice choice-radio", children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { id: id, type: "radio", name: name, value: option.value, checked: option.value === value, disabled: option.disabled, className: "choice-input", onChange: () => onChange(option.value) }), _jsx("span", { className: "choice-box", "aria-hidden": "true" })] }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: option.label }), option.description && (_jsx("span", { className: "choice-description", children: option.description }))] })] }, option.value));
11
- })] }));
6
+ import useFormReset from "../../hooks/useFormReset.js";
7
+ export default function RadioGroup({ legend, options, value, defaultValue, onChange, inline = false, name: providedName, form, id: providedId, disabled, required, description, error, className, "aria-describedby": describedBy, ...rest }) {
8
+ const generatedId = useId();
9
+ const id = providedId ?? generatedId;
10
+ const name = providedName ?? generatedId;
11
+ const [initial] = useState(defaultValue ?? value);
12
+ const ref = useRef(null);
13
+ const inputs = () => ref.current?.querySelectorAll('input[type="radio"]');
14
+ useLayoutEffect(() => {
15
+ inputs()?.forEach((input) => {
16
+ input.defaultChecked = input.value === initial;
17
+ if (value !== undefined)
18
+ input.checked = input.value === value;
19
+ });
20
+ });
21
+ useFormReset(ref, () => {
22
+ if (value !== undefined)
23
+ inputs()?.forEach((input) => {
24
+ input.checked = input.value === value;
25
+ });
26
+ });
27
+ const context = cx(describedBy, description && `${id}-description`, error && `${id}-error`) || undefined;
28
+ return (_jsxs("fieldset", { ...rest, ref: ref, id: id, name: name, form: form, disabled: disabled, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": context, className: cx("radio-group", inline && "radio-group-inline", className), children: [_jsx("legend", { className: "field-label mono", children: legend }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description })), options.map((option) => {
29
+ const optionId = option.inputProps?.id ?? `${id}-${option.value}`;
30
+ return (_jsxs("div", { className: "choice choice-radio", children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { ...option.inputProps, id: optionId, type: "radio", name: name, form: form, value: option.value, checked: value === undefined ? undefined : option.value === value, defaultChecked: value === undefined ? option.value === initial : undefined, required: required, disabled: disabled || option.disabled, className: cx("choice-input", option.inputProps?.className), "aria-invalid": error
31
+ ? true
32
+ : (option.inputProps?.["aria-invalid"] ??
33
+ rest["aria-invalid"]), "aria-describedby": cx(option.inputProps?.["aria-describedby"], context, option.description && `${optionId}-description`) || undefined, onChange: (event) => {
34
+ if (!event.currentTarget.checked)
35
+ return;
36
+ onChange?.(option.value);
37
+ } }), _jsx("span", { className: "choice-box", "aria-hidden": "true" })] }), _jsxs("label", { htmlFor: optionId, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: option.label }), option.description && (_jsx("span", { id: `${optionId}-description`, className: "choice-description", children: option.description }))] })] }, option.value));
38
+ }), error && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: error }))] }));
12
39
  }
@@ -5,10 +5,24 @@ import { forwardRef, useId } from "react";
5
5
  import { ChevronDown } from "lucide-react";
6
6
  import { fieldDescribedBy } from "../../lib/a11y.js";
7
7
  import Field from "./Field.js";
8
- const SelectField = forwardRef(function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }, ref) {
8
+ import useControlRef from "../../hooks/useControlRef.js";
9
+ import useFormReset from "../../hooks/useFormReset.js";
10
+ /** Native reset does not preserve React's controlled selection on its own. */
11
+ function restoreSelection(control, value) {
12
+ const values = new Set(Array.isArray(value) ? value.map(String) : [String(value)]);
13
+ for (const option of control.options)
14
+ option.selected = values.has(option.value);
15
+ }
16
+ const SelectField = forwardRef(function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }, forwardedRef) {
9
17
  const generatedId = useId();
10
18
  const id = providedId ?? generatedId;
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)]
19
+ const { ref, attach } = useControlRef(forwardedRef);
20
+ useFormReset(ref, () => {
21
+ if (!ref.current || rest.value === undefined)
22
+ return;
23
+ restoreSelection(ref.current, rest.value);
24
+ });
25
+ 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: attach, id: id, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
12
26
  .filter(Boolean)
13
27
  .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" })] }) }));
14
28
  });
@@ -1,13 +1,29 @@
1
- export type SliderProps = {
1
+ /** Native range submission with controlled/default state, reset and a formatted readout. */
2
+ import { type InputHTMLAttributes } from "react";
3
+ export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange" | "required" | "dangerouslySetInnerHTML" | "children" | "min" | "max" | "step"> & {
2
4
  label: string;
3
- value: number;
4
- onChange: (value: number) => void;
5
+ value?: number;
6
+ defaultValue?: number;
7
+ onChange?: (value: number) => void;
5
8
  min?: number;
6
9
  max?: number;
7
- step?: number;
8
- /** Formats the readout, e.g. (value) => `${value}%`. */
10
+ step?: number | "any";
11
+ /** Formats the readout and accessible value text. */
9
12
  format?: (value: number) => string;
10
- disabled?: boolean;
13
+ hint?: string;
14
+ error?: string;
11
15
  };
12
- declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLInputElement>>;
16
+ declare const Slider: import("react").ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "children" | "max" | "min" | "type" | "dangerouslySetInnerHTML" | "onChange" | "step" | "value" | "defaultValue" | "required"> & {
17
+ label: string;
18
+ value?: number;
19
+ defaultValue?: number;
20
+ onChange?: (value: number) => void;
21
+ min?: number;
22
+ max?: number;
23
+ step?: number | "any";
24
+ /** Formats the readout and accessible value text. */
25
+ format?: (value: number) => string;
26
+ hint?: string;
27
+ error?: string;
28
+ } & import("react").RefAttributes<HTMLInputElement>>;
13
29
  export default Slider;
@@ -1,12 +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 { forwardRef, useId } from "react";
5
- const Slider = forwardRef(function Slider({ label, value, onChange, min = 0, max = 100, step = 1, format = String, disabled, }, ref) {
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", { ref: ref, 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)) })] }));
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 }))] }));
11
48
  });
12
49
  export default Slider;
@@ -1,9 +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
- declare const Switch: import("react").ForwardRefExoticComponent<SwitchProps & import("react").RefAttributes<HTMLButtonElement>>;
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>>;
9
29
  export default Switch;
@@ -1,9 +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 { forwardRef, useId } from "react";
5
- const Switch = forwardRef(function Switch({ label, checked, onChange, description, disabled }, ref) {
6
- const id = useId();
7
- return (_jsxs("div", { className: "switch", children: [_jsx("button", { ref: ref, 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 }))] })] }));
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 }))] })] }));
8
55
  });
9
56
  export default Switch;
@@ -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)]
@@ -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,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
+ }