@abuzareal/contour-ui 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/COMBOBOX.md +87 -0
  3. package/COMPOSITION.md +4 -0
  4. package/CONTRACTS.md +27 -27
  5. package/DOCS.md +86 -0
  6. package/FORMS.md +4 -0
  7. package/FOUNDATION.md +35 -0
  8. package/GETTING-STARTED.md +67 -0
  9. package/HELPERS.md +33 -0
  10. package/HOOKS.md +67 -0
  11. package/MANUAL-QA.md +38 -0
  12. package/MIGRATION.md +44 -0
  13. package/MODELS.md +18 -0
  14. package/NEXTJS.md +3 -1
  15. package/OVERLAYS.md +4 -0
  16. package/PROP-REFERENCE.md +37 -0
  17. package/PUBLIC-API.md +174 -164
  18. package/README.md +9 -5
  19. package/RECIPES.md +43 -0
  20. package/REFERENCE.md +193 -0
  21. package/RELEASING.md +8 -2
  22. package/SIZE.md +9 -3
  23. package/STYLING.md +4 -0
  24. package/SUPPORT.md +36 -0
  25. package/THREE.md +40 -0
  26. package/dist/components/ui/Combobox.d.ts +325 -0
  27. package/dist/components/ui/Combobox.js +135 -0
  28. package/dist/components/ui/Combobox.types.d.ts +46 -0
  29. package/dist/components/ui/Combobox.types.js +1 -0
  30. package/dist/components/ui/DataTable.js +5 -4
  31. package/dist/components/ui/Field.js +1 -1
  32. package/dist/components/ui/FloatingPortal.js +2 -40
  33. package/dist/components/ui/FloatingScope.d.ts +9 -0
  34. package/dist/components/ui/FloatingScope.js +43 -0
  35. package/dist/components/ui/Table.d.ts +20 -0
  36. package/dist/components/ui/Table.js +9 -0
  37. package/dist/hooks/useComboboxAnchor.d.ts +17 -0
  38. package/dist/hooks/useComboboxAnchor.js +61 -0
  39. package/dist/hooks/useInitialHashScroll.js +5 -1
  40. package/dist/hooks/usePrintPreparation.js +12 -1
  41. package/dist/hooks/useScrollSpy.js +6 -3
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +2 -0
  44. package/dist/kit/data-display.css +6 -0
  45. package/dist/kit/forms.css +34 -0
  46. package/dist/lib/componentClasses.js +1 -1
  47. package/dist/scoped/kit/data-display.css +6 -0
  48. package/dist/scoped/kit/forms.css +34 -0
  49. package/docs/prop-metadata.json +2668 -0
  50. package/examples/onboarding/SettingsForm.jsx +30 -0
  51. package/examples/onboarding/SettingsForm.tsx +50 -0
  52. package/examples/optional/LazyScene.tsx +78 -0
  53. package/examples/optional/OptionalSmoothScroll.tsx +25 -0
  54. package/examples/optional/SmoothScrollOwner.tsx +7 -0
  55. package/examples/optional/optional.css +25 -0
  56. package/examples/recipes/AsyncProjectPicker.tsx +82 -0
  57. package/examples/recipes/DeleteProjectConfirmation.tsx +97 -0
  58. package/examples/recipes/InputCompositions.tsx +49 -0
  59. package/examples/recipes/RemoteRecordsTable.tsx +256 -0
  60. package/examples/recipes/useRemoteResource.ts +81 -0
  61. package/examples/reference/HookExamples.tsx +146 -0
  62. package/examples/reproduction/README.md +9 -0
  63. package/examples/reproduction/index.html +14 -0
  64. package/examples/reproduction/package.json +27 -0
  65. package/examples/reproduction/src/main.tsx +33 -0
  66. package/examples/reproduction/src/style.css +19 -0
  67. package/examples/reproduction/tsconfig.json +18 -0
  68. package/examples/reproduction/vite.config.ts +19 -0
  69. package/examples/starters/next/README.md +18 -0
  70. package/examples/starters/next/app/Demo.tsx +65 -0
  71. package/examples/starters/next/app/layout.tsx +25 -0
  72. package/examples/starters/next/app/next-page/page.tsx +9 -0
  73. package/examples/starters/next/app/page.tsx +11 -0
  74. package/examples/starters/next/app/style.css +19 -0
  75. package/examples/starters/next/next-env.d.ts +2 -0
  76. package/examples/starters/next/next.config.mjs +1 -0
  77. package/examples/starters/next/package.json +27 -0
  78. package/examples/starters/next/proxy.ts +16 -0
  79. package/examples/starters/next/tsconfig.json +30 -0
  80. package/examples/starters/vite/README.md +16 -0
  81. package/examples/starters/vite/index.html +14 -0
  82. package/examples/starters/vite/package.json +27 -0
  83. package/examples/starters/vite/src/main.tsx +81 -0
  84. package/examples/starters/vite/src/style.css +19 -0
  85. package/examples/starters/vite/tsconfig.json +18 -0
  86. package/examples/starters/vite/vite.config.ts +19 -0
  87. package/package.json +37 -4
@@ -0,0 +1,30 @@
1
+ // Generated from SettingsForm.tsx by scripts/build-onboarding.mjs.
2
+ import { useRef, useState } from "react";
3
+ import { Button, Checkbox, Combobox, TextField } from "@abuzareal/contour-ui";
4
+ const options = [
5
+ { value: "atlas", label: "Atlas" },
6
+ { value: "field", label: "Field notes" },
7
+ ];
8
+ /** Local inspection only. Validate and authorize real submissions on the server. */
9
+ export default function SettingsForm() {
10
+ const [payload, setPayload] = useState("");
11
+ const name = useRef(null);
12
+ return (<section aria-label="Settings example">
13
+ <h2>Project settings</h2>
14
+ <p>This form inspects local values; it does not save them.</p>
15
+ <form onSubmit={(event) => {
16
+ event.preventDefault();
17
+ setPayload(JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))));
18
+ }} onReset={() => setPayload("")}>
19
+ <TextField ref={name} label="Name" name="name" defaultValue="Ada" required/>
20
+ <Combobox label="Project" name="project" options={options} defaultValue="atlas" required/>
21
+ <Checkbox label="Allow updates" name="updates" defaultChecked/>
22
+ <Button type="submit">Inspect values</Button>
23
+ <Button type="reset" variant="secondary">
24
+ Reset form
25
+ </Button>
26
+ </form>
27
+ <output aria-label="Form values">{payload}</output>
28
+ <Button onClick={() => name.current?.focus()}>Focus name</Button>
29
+ </section>);
30
+ }
@@ -0,0 +1,50 @@
1
+ import { useRef, useState } from "react";
2
+ import { Button, Checkbox, Combobox, TextField } from "@abuzareal/contour-ui";
3
+ const options = [
4
+ { value: "atlas", label: "Atlas" },
5
+ { value: "field", label: "Field notes" },
6
+ ];
7
+ /** Local inspection only. Validate and authorize real submissions on the server. */
8
+ export default function SettingsForm() {
9
+ const [payload, setPayload] = useState("");
10
+ const name = useRef<HTMLInputElement>(null);
11
+ return (
12
+ <section aria-label="Settings example">
13
+ <h2>Project settings</h2>
14
+ <p>This form inspects local values; it does not save them.</p>
15
+ <form
16
+ onSubmit={(event) => {
17
+ event.preventDefault();
18
+ setPayload(
19
+ JSON.stringify(
20
+ Object.fromEntries(new FormData(event.currentTarget)),
21
+ ),
22
+ );
23
+ }}
24
+ onReset={() => setPayload("")}
25
+ >
26
+ <TextField
27
+ ref={name}
28
+ label="Name"
29
+ name="name"
30
+ defaultValue="Ada"
31
+ required
32
+ />
33
+ <Combobox
34
+ label="Project"
35
+ name="project"
36
+ options={options}
37
+ defaultValue="atlas"
38
+ required
39
+ />
40
+ <Checkbox label="Allow updates" name="updates" defaultChecked />
41
+ <Button type="submit">Inspect values</Button>
42
+ <Button type="reset" variant="secondary">
43
+ Reset form
44
+ </Button>
45
+ </form>
46
+ <output aria-label="Form values">{payload}</output>
47
+ <Button onClick={() => name.current?.focus()}>Focus name</Button>
48
+ </section>
49
+ );
50
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { lazy, Suspense, useState } from "react";
3
+ import {
4
+ Button,
5
+ SceneBoundary,
6
+ useMotionPreference,
7
+ } from "@abuzareal/contour-ui";
8
+ import "./optional.css";
9
+ const Sculpture = lazy(() =>
10
+ import("@abuzareal/contour-ui/three").then((module) => ({
11
+ default: module.Sculpture,
12
+ })),
13
+ );
14
+ export function LazyScene() {
15
+ const [loaded, setLoaded] = useState(false),
16
+ [paused, setPaused] = useState(false);
17
+ const reducedMotion = useMotionPreference();
18
+ return (
19
+ <figure>
20
+ <div className="recipe-scene-visual">
21
+ <div
22
+ className="recipe-scene-fallback"
23
+ role="img"
24
+ aria-label="Three interlocking sculptural links"
25
+ >
26
+ <svg aria-hidden="true" viewBox="0 0 300 180">
27
+ <g fill="none" stroke="currentColor" strokeWidth="16">
28
+ <ellipse
29
+ cx="95"
30
+ cy="78"
31
+ rx="52"
32
+ ry="29"
33
+ transform="rotate(-25 95 78)"
34
+ />
35
+ <ellipse
36
+ cx="165"
37
+ cy="95"
38
+ rx="52"
39
+ ry="29"
40
+ transform="rotate(25 165 95)"
41
+ />
42
+ <ellipse
43
+ cx="215"
44
+ cy="62"
45
+ rx="40"
46
+ ry="25"
47
+ transform="rotate(-35 215 62)"
48
+ />
49
+ </g>
50
+ </svg>
51
+ </div>
52
+ {loaded && (
53
+ <div className="recipe-scene-layer" aria-hidden="true">
54
+ <SceneBoundary>
55
+ <Suspense fallback={null}>
56
+ <Sculpture motionEnabled={!paused && !reducedMotion} />
57
+ </Suspense>
58
+ </SceneBoundary>
59
+ </div>
60
+ )}
61
+ </div>
62
+ <figcaption>
63
+ Three linked forms. The static study remains available if 3D cannot
64
+ load.
65
+ </figcaption>
66
+ <Button onClick={() => setLoaded(true)} disabled={loaded}>
67
+ Load 3D study
68
+ </Button>
69
+ <button
70
+ type="button"
71
+ aria-pressed={paused}
72
+ onClick={() => setPaused((value) => !value)}
73
+ >
74
+ Pause motion
75
+ </button>
76
+ </figure>
77
+ );
78
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { lazy, Suspense, useState } from "react";
3
+ import { useMotionPreference } from "@abuzareal/contour-ui";
4
+ const SmoothScrollOwner = lazy(() => import("./SmoothScrollOwner.js"));
5
+ /** Mount at most one owner for the page; ordinary native scrolling works before opt-in. */
6
+ export function OptionalSmoothScroll() {
7
+ const [enabled, setEnabled] = useState(false);
8
+ const reducedMotion = useMotionPreference();
9
+ return (
10
+ <>
11
+ <button
12
+ type="button"
13
+ aria-pressed={enabled}
14
+ onClick={() => setEnabled((value) => !value)}
15
+ >
16
+ Smooth scrolling
17
+ </button>
18
+ {enabled && !reducedMotion && (
19
+ <Suspense fallback={null}>
20
+ <SmoothScrollOwner enabled />
21
+ </Suspense>
22
+ )}
23
+ </>
24
+ );
25
+ }
@@ -0,0 +1,7 @@
1
+ "use client";
2
+ import { useSmoothScroll } from "@abuzareal/contour-ui/scroll";
3
+ import "lenis/dist/lenis.css";
4
+ export default function SmoothScrollOwner({ enabled }: { enabled: boolean }) {
5
+ useSmoothScroll(enabled);
6
+ return null;
7
+ }
@@ -0,0 +1,25 @@
1
+ .recipe-scene-visual {
2
+ position: relative;
3
+ block-size: 18rem;
4
+ overflow: hidden;
5
+ }
6
+ .recipe-scene-fallback,
7
+ .recipe-scene-layer {
8
+ position: absolute;
9
+ inset: 0;
10
+ }
11
+ .recipe-scene-fallback {
12
+ display: grid;
13
+ place-items: center;
14
+ }
15
+ .recipe-scene-fallback svg {
16
+ inline-size: min(100%, 24rem);
17
+ block-size: 100%;
18
+ }
19
+ .recipe-scene-layer .contour-sculpture-canvas {
20
+ position: absolute;
21
+ inset: 0;
22
+ }
23
+ .recipe-scene-visual:has([data-state="ready"]) .recipe-scene-fallback {
24
+ visibility: hidden;
25
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Combobox, type ComboboxOption } from "@abuzareal/contour-ui";
4
+ import { useRemoteResource, type Loader } from "./useRemoteResource.js";
5
+
6
+ export type ProjectSearch = Loader<string, readonly ComboboxOption[]>;
7
+ export type AsyncProjectPickerProps = {
8
+ /** Stable function: validate response IDs/labels before resolving. */
9
+ searchProjects: ProjectSearch;
10
+ initialProject?: ComboboxOption;
11
+ onProjectChange?: (project: ComboboxOption | null) => void;
12
+ };
13
+
14
+ /** Copy this into the application; fetching and selected-record retention are not library features. */
15
+ export function AsyncProjectPicker({
16
+ searchProjects,
17
+ initialProject,
18
+ onProjectChange,
19
+ }: AsyncProjectPickerProps) {
20
+ const [query, setQuery] = useState(initialProject?.label ?? "");
21
+ const [selected, setSelected] = useState<ComboboxOption | null>(
22
+ initialProject ?? null,
23
+ );
24
+ const search = useCallback(
25
+ async (text: string, signal: AbortSignal) => {
26
+ const matches = await searchProjects(text, signal);
27
+ if (!signal.aborted)
28
+ setSelected((previous) =>
29
+ previous
30
+ ? (matches.find((option) => option.value === previous.value) ??
31
+ previous)
32
+ : null,
33
+ );
34
+ return matches;
35
+ },
36
+ [searchProjects],
37
+ );
38
+ const result = useRemoteResource(query, search);
39
+ // Never display the previous query's rows as if they matched this query.
40
+ const matches = useMemo(
41
+ () =>
42
+ !result.loading && !result.error && result.appliedQuery === query
43
+ ? (result.data ?? [])
44
+ : [],
45
+ [result.loading, result.error, result.appliedQuery, result.data, query],
46
+ );
47
+ // Bounded retention: keep the current result set and one selected record, not an unbounded cache.
48
+ const options = useMemo(
49
+ () =>
50
+ selected && !matches.some((option) => option.value === selected.value)
51
+ ? [...matches, selected]
52
+ : matches,
53
+ [matches, selected],
54
+ );
55
+ return (
56
+ <Combobox
57
+ label="Project"
58
+ name="project"
59
+ required
60
+ options={options}
61
+ filteredOptions={matches}
62
+ value={selected?.value ?? null}
63
+ onValueChange={(value) => {
64
+ const next = options.find((option) => option.value === value) ?? null;
65
+ setSelected(next);
66
+ onProjectChange?.(next);
67
+ }}
68
+ inputValue={query}
69
+ onInputValueChange={setQuery}
70
+ loading={result.loading}
71
+ searchError={
72
+ result.error === "permission"
73
+ ? "You do not have permission to search projects."
74
+ : result.error
75
+ ? "Projects could not be loaded. Try again."
76
+ : undefined
77
+ }
78
+ onRetry={result.retry}
79
+ hint="Choose a project from the results. Typed text is not a submitted project."
80
+ />
81
+ );
82
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Button, Dialog } from "@abuzareal/contour-ui";
4
+
5
+ export type DeleteProjectConfirmationProps = {
6
+ project: { id: string; name: string };
7
+ /** Resolve only after server confirmation. Authorization/idempotency belong on the server. */
8
+ deleteProject: (id: string, signal: AbortSignal) => Promise<void>;
9
+ onDeleted?: (id: string) => void;
10
+ };
11
+
12
+ /** Native Dialog composition; no AlertDialog export or simulated successful deletion. */
13
+ export function DeleteProjectConfirmation(
14
+ props: DeleteProjectConfirmationProps,
15
+ ) {
16
+ // Target identity owns the whole interaction; changing projects cannot reuse a pending confirmation.
17
+ return <ProjectDeletion key={props.project.id} {...props} />;
18
+ }
19
+
20
+ function ProjectDeletion({
21
+ project,
22
+ deleteProject,
23
+ onDeleted,
24
+ }: DeleteProjectConfirmationProps) {
25
+ const [open, setOpen] = useState(false),
26
+ [pending, setPending] = useState(false),
27
+ [error, setError] = useState("");
28
+ const cancel = useRef<HTMLButtonElement>(null);
29
+ const active = useRef<AbortController | null>(null);
30
+ // A changed target or unmount invalidates the old operation, even if its transport ignores abort.
31
+ useEffect(() => {
32
+ return () => {
33
+ active.current?.abort();
34
+ active.current = null;
35
+ };
36
+ }, [project.id]);
37
+ const dismiss = () => {
38
+ active.current?.abort();
39
+ active.current = null;
40
+ setPending(false);
41
+ setOpen(false);
42
+ setError("");
43
+ };
44
+ const remove = async () => {
45
+ if (active.current) return;
46
+ const controller = new AbortController();
47
+ active.current = controller;
48
+ setPending(true);
49
+ setError("");
50
+ try {
51
+ await deleteProject(project.id, controller.signal);
52
+ } catch {
53
+ if (controller.signal.aborted || active.current !== controller) return;
54
+ active.current = null;
55
+ setPending(false);
56
+ setError(
57
+ "The deletion could not be confirmed. Check the project before retrying.",
58
+ );
59
+ return;
60
+ }
61
+ if (controller.signal.aborted || active.current !== controller) return;
62
+ active.current = null;
63
+ setPending(false);
64
+ setOpen(false);
65
+ onDeleted?.(project.id);
66
+ };
67
+ return (
68
+ <>
69
+ <Button onClick={() => setOpen(true)}>Delete {project.name}</Button>
70
+ <Dialog
71
+ title={`Delete ${project.name}?`}
72
+ open={open}
73
+ onClose={dismiss}
74
+ initialFocus={cancel}
75
+ description="Deleting this project permanently removes its saved settings. This action cannot be undone."
76
+ closeButtonProps={{ "aria-label": "Cancel deletion" }}
77
+ footer={
78
+ <>
79
+ <Button ref={cancel} variant="secondary" onClick={dismiss}>
80
+ Keep project
81
+ </Button>
82
+ <Button disabled={pending} onClick={() => void remove()}>
83
+ {pending ? "Deleting…" : "Permanently delete project"}
84
+ </Button>
85
+ </>
86
+ }
87
+ >
88
+ <p role="status">
89
+ {pending
90
+ ? "Waiting for server confirmation. Closing stops waiting; the server may still complete the deletion."
91
+ : "No action is taken until you confirm."}
92
+ </p>
93
+ {error && <p role="alert">{error}</p>}
94
+ </Dialog>
95
+ </>
96
+ );
97
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { useRef, useState } from "react";
3
+ import { TextField } from "@abuzareal/contour-ui";
4
+
5
+ /** Native number behavior is sufficient for a bounded integer quantity, not locale-aware money. */
6
+ export function QuantityField() {
7
+ return (
8
+ <TextField
9
+ leadingIcon={<span aria-hidden="true">#</span>}
10
+ label="Seats"
11
+ name="seats"
12
+ type="number"
13
+ min={1}
14
+ max={100}
15
+ step={1}
16
+ defaultValue={1}
17
+ trailing={<span aria-hidden="true">seats</span>}
18
+ hint="Whole number, 1–100 seats."
19
+ />
20
+ );
21
+ }
22
+
23
+ /** Keep the same native input mounted so form identity and password-manager hooks survive toggling. */
24
+ export function PasswordField() {
25
+ const [visible, setVisible] = useState(false);
26
+ const input = useRef<HTMLInputElement>(null);
27
+ return (
28
+ <TextField
29
+ ref={input}
30
+ label="Password"
31
+ name="password"
32
+ type={visible ? "text" : "password"}
33
+ autoComplete="current-password"
34
+ trailing={
35
+ <button
36
+ type="button"
37
+ aria-label="Show password"
38
+ aria-pressed={visible}
39
+ onClick={() => {
40
+ setVisible((previous) => !previous);
41
+ input.current?.focus();
42
+ }}
43
+ >
44
+ {visible ? "Hide" : "Show"}
45
+ </button>
46
+ }
47
+ />
48
+ );
49
+ }