@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.
- package/CHANGELOG.md +28 -0
- package/COMPOSITION.md +193 -0
- package/CONTRACTS.md +25 -25
- package/FORMS.md +94 -0
- package/NEXTJS.md +5 -1
- package/PUBLIC-API.md +176 -174
- package/README.md +11 -9
- package/dist/components/ui/Button.d.ts +10 -2
- package/dist/components/ui/Button.js +7 -3
- package/dist/components/ui/Checkbox.d.ts +8 -2
- package/dist/components/ui/Checkbox.js +18 -7
- package/dist/components/ui/DepthButton.d.ts +5 -2
- package/dist/components/ui/DepthButton.js +8 -3
- package/dist/components/ui/Dialog.d.ts +21 -3
- package/dist/components/ui/Dialog.js +17 -11
- package/dist/components/ui/ExternalLink.d.ts +3 -2
- package/dist/components/ui/ExternalLink.js +7 -3
- package/dist/components/ui/IconButton.d.ts +10 -2
- package/dist/components/ui/IconButton.js +7 -3
- package/dist/components/ui/Popover.d.ts +19 -4
- package/dist/components/ui/Popover.js +16 -9
- 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 +24 -7
- package/dist/components/ui/Slider.js +45 -7
- package/dist/components/ui/Switch.d.ts +26 -5
- package/dist/components/ui/Switch.js +54 -6
- package/dist/components/ui/Tabs.d.ts +16 -4
- package/dist/components/ui/Tabs.js +46 -24
- package/dist/components/ui/TextArea.js +2 -0
- package/dist/components/ui/TextField.js +5 -1
- package/dist/components/ui/ThemeToggle.d.ts +2 -1
- package/dist/components/ui/ThemeToggle.js +5 -3
- package/dist/hooks/useControlRef.js +2 -2
- package/dist/hooks/useFormReset.d.ts +1 -1
- package/dist/hooks/useTabRefs.d.ts +4 -0
- package/dist/hooks/useTabRefs.js +12 -0
- package/dist/lib/componentProps.d.ts +5 -0
- package/dist/lib/componentProps.js +1 -0
- package/dist/lib/composeEventHandlers.d.ts +4 -0
- package/dist/lib/composeEventHandlers.js +8 -0
- package/dist/lib/composeRefs.d.ts +3 -0
- package/dist/lib/composeRefs.js +24 -0
- 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 +8 -4
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
/**
|
|
4
|
-
import { 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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
4
|
-
import { useId } from "react";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
16
|
-
if (tab)
|
|
17
|
-
|
|
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
|
-
}, [
|
|
23
|
-
|
|
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(
|
|
26
|
-
onTabChange?.(
|
|
27
|
-
|
|
41
|
+
setActiveId(tab.id);
|
|
42
|
+
onTabChange?.(tab.id);
|
|
43
|
+
if (focus)
|
|
44
|
+
tabRefs.current.get(tab.id)?.focus();
|
|
28
45
|
};
|
|
29
|
-
|
|
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 (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
}
|
|
47
|
-
}) }), tabs.map((tab) => (
|
|
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, () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
+
}
|