@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/CHANGELOG.md +11 -0
- package/COMPOSITION.md +28 -28
- package/CONTRACTS.md +3 -3
- package/FORMS.md +94 -0
- package/NEXTJS.md +4 -2
- package/PUBLIC-API.md +157 -155
- package/README.md +7 -5
- package/dist/components/ui/Checkbox.d.ts +2 -2
- package/dist/components/ui/Checkbox.js +11 -2
- package/dist/components/ui/RadioGroup.d.ts +13 -5
- package/dist/components/ui/RadioGroup.js +35 -8
- package/dist/components/ui/SelectField.js +16 -2
- package/dist/components/ui/Slider.d.ts +23 -7
- package/dist/components/ui/Slider.js +43 -6
- package/dist/components/ui/Switch.d.ts +25 -5
- package/dist/components/ui/Switch.js +52 -5
- package/dist/components/ui/TextArea.js +2 -0
- package/dist/components/ui/TextField.js +5 -1
- package/dist/hooks/useFormReset.d.ts +1 -1
- package/examples/forms/HookFormSettingsForm.tsx +176 -0
- package/examples/forms/ManualSettingsForm-0.3.0.tsx +102 -0
- package/examples/forms/NativeSettingsForm.tsx +119 -0
- package/examples/forms/settings.ts +61 -0
- package/examples/forms/useSubmission.ts +77 -0
- package/package.json +7 -4
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.
|
|
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.
|
|
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.
|
|
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
|
|
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"
|
|
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>, "
|
|
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
|
|
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
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
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
|
|
10
|
+
step?: number | "any";
|
|
11
|
+
/** Formats the readout and accessible value text. */
|
|
9
12
|
format?: (value: number) => string;
|
|
10
|
-
|
|
13
|
+
hint?: string;
|
|
14
|
+
error?: string;
|
|
11
15
|
};
|
|
12
|
-
declare const Slider: import("react").ForwardRefExoticComponent<
|
|
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
|
-
/**
|
|
4
|
-
import { forwardRef, useId } from "react";
|
|
5
|
-
|
|
6
|
-
|
|
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, ((
|
|
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(
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
/**
|
|
4
|
-
import { forwardRef, useId } from "react";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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, () =>
|
|
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
|
+
}
|