@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
RadioGroup,
|
|
6
|
+
Slider,
|
|
7
|
+
Switch,
|
|
8
|
+
TextField,
|
|
9
|
+
} from "@abuzareal/contour-ui";
|
|
10
|
+
import {
|
|
11
|
+
defaultSettings,
|
|
12
|
+
readSettings,
|
|
13
|
+
type FieldErrors,
|
|
14
|
+
type SaveSettings,
|
|
15
|
+
} from "./settings.js";
|
|
16
|
+
import useSubmission from "./useSubmission.js";
|
|
17
|
+
|
|
18
|
+
/** Legacy 0.3.0 recipe: these three controls submit through application state. */
|
|
19
|
+
export function ManualSettingsForm({ save }: { save: SaveSettings }) {
|
|
20
|
+
const [settings, setSettings] = useState(defaultSettings);
|
|
21
|
+
const [errors, setErrors] = useState<FieldErrors>({});
|
|
22
|
+
const submission = useSubmission(save);
|
|
23
|
+
const pending = submission.status === "pending";
|
|
24
|
+
return (
|
|
25
|
+
<form
|
|
26
|
+
noValidate
|
|
27
|
+
method="post"
|
|
28
|
+
aria-label="Manual 0.3.0 settings"
|
|
29
|
+
onReset={() => {
|
|
30
|
+
submission.cancel();
|
|
31
|
+
setSettings(defaultSettings);
|
|
32
|
+
setErrors({});
|
|
33
|
+
}}
|
|
34
|
+
onSubmit={async (event) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
if (pending) return;
|
|
37
|
+
const data = new FormData(event.currentTarget);
|
|
38
|
+
// The generated radio name is not a stable service field; explicitly serialize state.
|
|
39
|
+
data.set("level", String(settings.level));
|
|
40
|
+
data.set("contact", settings.contact);
|
|
41
|
+
if (settings.updates) data.set("updates", "on");
|
|
42
|
+
else data.delete("updates");
|
|
43
|
+
const parsed = readSettings(data);
|
|
44
|
+
setErrors(parsed.errors);
|
|
45
|
+
if (!parsed.value) {
|
|
46
|
+
submission.validationFailed();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const result = await submission.run(parsed.value);
|
|
50
|
+
if (result) setErrors(result.fields);
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<fieldset disabled={pending} aria-busy={pending}>
|
|
54
|
+
<legend>Notification settings</legend>
|
|
55
|
+
<TextField
|
|
56
|
+
name="email"
|
|
57
|
+
label="Email"
|
|
58
|
+
type="email"
|
|
59
|
+
required
|
|
60
|
+
error={errors.email}
|
|
61
|
+
/>
|
|
62
|
+
<Switch
|
|
63
|
+
label="Updates"
|
|
64
|
+
checked={settings.updates}
|
|
65
|
+
onChange={(updates) =>
|
|
66
|
+
setSettings((current) => ({ ...current, updates }))
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
<Slider
|
|
70
|
+
label="Level"
|
|
71
|
+
value={settings.level}
|
|
72
|
+
step={5}
|
|
73
|
+
onChange={(level) =>
|
|
74
|
+
setSettings((current) => ({ ...current, level }))
|
|
75
|
+
}
|
|
76
|
+
/>
|
|
77
|
+
<RadioGroup
|
|
78
|
+
legend="Contact method"
|
|
79
|
+
value={settings.contact}
|
|
80
|
+
onChange={(contact) =>
|
|
81
|
+
setSettings((current) => ({ ...current, contact }))
|
|
82
|
+
}
|
|
83
|
+
options={[
|
|
84
|
+
{ value: "email", label: "Email contact" },
|
|
85
|
+
{ value: "phone", label: "Phone contact" },
|
|
86
|
+
]}
|
|
87
|
+
/>
|
|
88
|
+
<p aria-live="polite">
|
|
89
|
+
{errors.updates ?? errors.level ?? errors.contact}
|
|
90
|
+
</p>
|
|
91
|
+
</fieldset>
|
|
92
|
+
<p role="status" aria-label="Submission status">
|
|
93
|
+
{submission.message}
|
|
94
|
+
</p>
|
|
95
|
+
<Button type="submit" disabled={pending}>
|
|
96
|
+
Save settings
|
|
97
|
+
</Button>
|
|
98
|
+
<Button type="reset">Reset settings</Button>
|
|
99
|
+
{pending && <Button onClick={submission.cancel}>Stop waiting</Button>}
|
|
100
|
+
</form>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
RadioGroup,
|
|
6
|
+
Slider,
|
|
7
|
+
Switch,
|
|
8
|
+
TextField,
|
|
9
|
+
} from "@abuzareal/contour-ui";
|
|
10
|
+
import {
|
|
11
|
+
defaultSettings,
|
|
12
|
+
readSettings,
|
|
13
|
+
type FieldErrors,
|
|
14
|
+
type SaveSettings,
|
|
15
|
+
} from "./settings.js";
|
|
16
|
+
import useSubmission from "./useSubmission.js";
|
|
17
|
+
|
|
18
|
+
/** Provide a real save adapter. This example never invents a successful response. */
|
|
19
|
+
export function NativeSettingsForm({ save }: { save: SaveSettings }) {
|
|
20
|
+
const submission = useSubmission(save);
|
|
21
|
+
const [errors, setErrors] = useState<FieldErrors>({});
|
|
22
|
+
const email = useRef<HTMLInputElement>(null);
|
|
23
|
+
const level = useRef<HTMLInputElement>(null);
|
|
24
|
+
const contact = useRef<HTMLInputElement>(null);
|
|
25
|
+
const updates = useRef<HTMLButtonElement>(null);
|
|
26
|
+
const pending = submission.status === "pending";
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (pending) return;
|
|
29
|
+
if (errors.email) email.current?.focus();
|
|
30
|
+
else if (errors.updates) updates.current?.focus();
|
|
31
|
+
else if (errors.level) level.current?.focus();
|
|
32
|
+
else if (errors.contact) contact.current?.focus();
|
|
33
|
+
}, [errors, pending]);
|
|
34
|
+
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
if (pending) return;
|
|
37
|
+
const parsed = readSettings(new FormData(event.currentTarget));
|
|
38
|
+
setErrors(parsed.errors);
|
|
39
|
+
if (!parsed.value) {
|
|
40
|
+
submission.validationFailed();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const result = await submission.run(parsed.value);
|
|
44
|
+
if (result) setErrors(result.fields);
|
|
45
|
+
};
|
|
46
|
+
return (
|
|
47
|
+
<form
|
|
48
|
+
noValidate
|
|
49
|
+
method="post"
|
|
50
|
+
onSubmit={submit}
|
|
51
|
+
onReset={() => {
|
|
52
|
+
submission.cancel();
|
|
53
|
+
setErrors({});
|
|
54
|
+
}}
|
|
55
|
+
aria-label="Native settings"
|
|
56
|
+
>
|
|
57
|
+
<fieldset disabled={pending} aria-busy={pending}>
|
|
58
|
+
<legend>Notification settings</legend>
|
|
59
|
+
<TextField
|
|
60
|
+
ref={email}
|
|
61
|
+
name="email"
|
|
62
|
+
maxLength={80}
|
|
63
|
+
type="email"
|
|
64
|
+
label="Email"
|
|
65
|
+
required
|
|
66
|
+
autoComplete="email"
|
|
67
|
+
error={errors.email}
|
|
68
|
+
/>
|
|
69
|
+
<Switch
|
|
70
|
+
ref={updates}
|
|
71
|
+
name="updates"
|
|
72
|
+
label="Updates"
|
|
73
|
+
defaultChecked={defaultSettings.updates}
|
|
74
|
+
error={errors.updates}
|
|
75
|
+
/>
|
|
76
|
+
<Slider
|
|
77
|
+
ref={level}
|
|
78
|
+
name="level"
|
|
79
|
+
label="Level"
|
|
80
|
+
defaultValue={defaultSettings.level}
|
|
81
|
+
min={0}
|
|
82
|
+
max={100}
|
|
83
|
+
step={5}
|
|
84
|
+
format={(value) => `${value}%`}
|
|
85
|
+
error={errors.level}
|
|
86
|
+
/>
|
|
87
|
+
<RadioGroup
|
|
88
|
+
name="contact"
|
|
89
|
+
legend="Contact method"
|
|
90
|
+
defaultValue={defaultSettings.contact}
|
|
91
|
+
required
|
|
92
|
+
error={errors.contact}
|
|
93
|
+
options={[
|
|
94
|
+
{
|
|
95
|
+
value: "email",
|
|
96
|
+
label: "Email contact",
|
|
97
|
+
inputProps: { ref: contact },
|
|
98
|
+
},
|
|
99
|
+
{ value: "phone", label: "Phone contact" },
|
|
100
|
+
]}
|
|
101
|
+
/>
|
|
102
|
+
</fieldset>
|
|
103
|
+
<p role="status" aria-label="Submission status">
|
|
104
|
+
{submission.message}
|
|
105
|
+
</p>
|
|
106
|
+
<Button type="submit" disabled={pending}>
|
|
107
|
+
Save settings
|
|
108
|
+
</Button>
|
|
109
|
+
<Button type="reset" variant="secondary">
|
|
110
|
+
Reset settings
|
|
111
|
+
</Button>
|
|
112
|
+
{pending && (
|
|
113
|
+
<Button onClick={submission.cancel} variant="secondary">
|
|
114
|
+
Stop waiting
|
|
115
|
+
</Button>
|
|
116
|
+
)}
|
|
117
|
+
</form>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Copy these application contracts with the recipe; adapt them to your real service. */
|
|
2
|
+
export type Settings = {
|
|
3
|
+
email: string;
|
|
4
|
+
updates: boolean;
|
|
5
|
+
level: number;
|
|
6
|
+
contact: "email" | "phone";
|
|
7
|
+
};
|
|
8
|
+
export type FieldErrors = Partial<Record<keyof Settings, string>>;
|
|
9
|
+
export type SaveResult =
|
|
10
|
+
{ ok: true } | { ok: false; message: string; fields?: FieldErrors };
|
|
11
|
+
export type SaveSettings = (
|
|
12
|
+
settings: Settings,
|
|
13
|
+
signal: AbortSignal,
|
|
14
|
+
) => Promise<SaveResult>;
|
|
15
|
+
export const defaultSettings: Settings = {
|
|
16
|
+
email: "",
|
|
17
|
+
updates: true,
|
|
18
|
+
level: 20,
|
|
19
|
+
contact: "email",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function readSettings(data: FormData): {
|
|
23
|
+
value?: Settings;
|
|
24
|
+
errors: FieldErrors;
|
|
25
|
+
} {
|
|
26
|
+
const email = data.get("email"),
|
|
27
|
+
level = data.get("level"),
|
|
28
|
+
contact = data.get("contact");
|
|
29
|
+
const errors: FieldErrors = {};
|
|
30
|
+
if (
|
|
31
|
+
typeof email !== "string" ||
|
|
32
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())
|
|
33
|
+
)
|
|
34
|
+
errors.email = "Enter a valid email address.";
|
|
35
|
+
const numeric =
|
|
36
|
+
typeof level === "string" && level.trim() !== "" ? Number(level) : NaN;
|
|
37
|
+
if (
|
|
38
|
+
!Number.isFinite(numeric) ||
|
|
39
|
+
numeric < 0 ||
|
|
40
|
+
numeric > 100 ||
|
|
41
|
+
numeric % 5 !== 0
|
|
42
|
+
)
|
|
43
|
+
errors.level = "Choose a level from 0 to 100 in steps of five.";
|
|
44
|
+
if (contact !== "email" && contact !== "phone")
|
|
45
|
+
errors.contact = "Choose a contact method.";
|
|
46
|
+
if (
|
|
47
|
+
Object.keys(errors).length ||
|
|
48
|
+
typeof email !== "string" ||
|
|
49
|
+
(contact !== "email" && contact !== "phone")
|
|
50
|
+
)
|
|
51
|
+
return { errors };
|
|
52
|
+
return {
|
|
53
|
+
errors,
|
|
54
|
+
value: {
|
|
55
|
+
email: email.trim(),
|
|
56
|
+
updates: data.get("updates") === "on",
|
|
57
|
+
level: numeric,
|
|
58
|
+
contact,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { FieldErrors, SaveSettings, Settings } from "./settings.js";
|
|
4
|
+
|
|
5
|
+
type Submission = {
|
|
6
|
+
status: "idle" | "pending" | "error" | "success";
|
|
7
|
+
message: string;
|
|
8
|
+
fields: FieldErrors;
|
|
9
|
+
};
|
|
10
|
+
const ready: Submission = { status: "idle", message: "", fields: {} };
|
|
11
|
+
|
|
12
|
+
/** Application example: the injected adapter confirms success; cancellation cannot undo server work. */
|
|
13
|
+
export default function useSubmission(save: SaveSettings) {
|
|
14
|
+
const active = useRef<AbortController | null>(null);
|
|
15
|
+
const [submission, setSubmission] = useState(ready);
|
|
16
|
+
useEffect(
|
|
17
|
+
() => () => {
|
|
18
|
+
active.current?.abort();
|
|
19
|
+
active.current = null;
|
|
20
|
+
},
|
|
21
|
+
[],
|
|
22
|
+
);
|
|
23
|
+
const cancel = useCallback(() => {
|
|
24
|
+
const wasPending = active.current !== null;
|
|
25
|
+
active.current?.abort();
|
|
26
|
+
active.current = null;
|
|
27
|
+
setSubmission({
|
|
28
|
+
...ready,
|
|
29
|
+
message: wasPending
|
|
30
|
+
? "Stopped waiting for the response. The server may still finish this request."
|
|
31
|
+
: "",
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
const validationFailed = useCallback(() => {
|
|
35
|
+
if (!active.current)
|
|
36
|
+
setSubmission({
|
|
37
|
+
...ready,
|
|
38
|
+
status: "error",
|
|
39
|
+
message: "Check the highlighted fields.",
|
|
40
|
+
});
|
|
41
|
+
}, []);
|
|
42
|
+
const run = async (settings: Settings) => {
|
|
43
|
+
if (active.current) return null;
|
|
44
|
+
const operation = new AbortController();
|
|
45
|
+
active.current = operation;
|
|
46
|
+
setSubmission({
|
|
47
|
+
status: "pending",
|
|
48
|
+
message: "Saving settings…",
|
|
49
|
+
fields: {},
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
const result = await save(settings, operation.signal);
|
|
53
|
+
if (active.current !== operation || operation.signal.aborted) return null;
|
|
54
|
+
const next: Submission = result.ok
|
|
55
|
+
? { status: "success", message: "Settings saved.", fields: {} }
|
|
56
|
+
: {
|
|
57
|
+
status: "error",
|
|
58
|
+
message: result.message,
|
|
59
|
+
fields: result.fields ?? {},
|
|
60
|
+
};
|
|
61
|
+
setSubmission(next);
|
|
62
|
+
return next;
|
|
63
|
+
} catch {
|
|
64
|
+
if (active.current !== operation || operation.signal.aborted) return null;
|
|
65
|
+
const next: Submission = {
|
|
66
|
+
status: "error",
|
|
67
|
+
message: "Could not save settings. Try again.",
|
|
68
|
+
fields: {},
|
|
69
|
+
};
|
|
70
|
+
setSubmission(next);
|
|
71
|
+
return next;
|
|
72
|
+
} finally {
|
|
73
|
+
if (active.current === operation) active.current = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return { ...submission, run, cancel, validationFailed };
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abuzareal/contour-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Editorial React components, themes, restrained motion, and optional Three.js objects.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"NEXTJS.md",
|
|
17
17
|
"CONTRACTS.md",
|
|
18
18
|
"PUBLIC-API.md",
|
|
19
|
-
"COMPOSITION.md"
|
|
19
|
+
"COMPOSITION.md",
|
|
20
|
+
"FORMS.md",
|
|
21
|
+
"examples/forms"
|
|
20
22
|
],
|
|
21
23
|
"sideEffects": [
|
|
22
24
|
"**/*.css"
|
|
@@ -51,7 +53,7 @@
|
|
|
51
53
|
"scripts": {
|
|
52
54
|
"build": "node scripts/build.mjs",
|
|
53
55
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
-
"lint": "oxlint src --deny-warnings",
|
|
56
|
+
"lint": "oxlint src examples --deny-warnings",
|
|
55
57
|
"test": "node --test tests/*.test.mjs",
|
|
56
58
|
"test:browser": "node --test tests/*.browser.mjs",
|
|
57
59
|
"check:package": "node scripts/check-package.mjs",
|
|
@@ -61,7 +63,8 @@
|
|
|
61
63
|
"test:compat": "node scripts/test-compat.mjs",
|
|
62
64
|
"test:optional": "node scripts/test-optional-consumer.mjs",
|
|
63
65
|
"test:next": "npm run build && node scripts/test-next.mjs",
|
|
64
|
-
"verify:release": "node scripts/verify-release.mjs"
|
|
66
|
+
"verify:release": "node scripts/verify-release.mjs",
|
|
67
|
+
"test:form-recipes": "node scripts/test-form-recipes.mjs"
|
|
65
68
|
},
|
|
66
69
|
"dependencies": {
|
|
67
70
|
"@fontsource-variable/dm-sans": "^5.3.0",
|