@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/COMPOSITION.md +193 -0
  3. package/CONTRACTS.md +25 -25
  4. package/FORMS.md +94 -0
  5. package/NEXTJS.md +5 -1
  6. package/PUBLIC-API.md +176 -174
  7. package/README.md +11 -9
  8. package/dist/components/ui/Button.d.ts +10 -2
  9. package/dist/components/ui/Button.js +7 -3
  10. package/dist/components/ui/Checkbox.d.ts +8 -2
  11. package/dist/components/ui/Checkbox.js +18 -7
  12. package/dist/components/ui/DepthButton.d.ts +5 -2
  13. package/dist/components/ui/DepthButton.js +8 -3
  14. package/dist/components/ui/Dialog.d.ts +21 -3
  15. package/dist/components/ui/Dialog.js +17 -11
  16. package/dist/components/ui/ExternalLink.d.ts +3 -2
  17. package/dist/components/ui/ExternalLink.js +7 -3
  18. package/dist/components/ui/IconButton.d.ts +10 -2
  19. package/dist/components/ui/IconButton.js +7 -3
  20. package/dist/components/ui/Popover.d.ts +19 -4
  21. package/dist/components/ui/Popover.js +16 -9
  22. package/dist/components/ui/RadioGroup.d.ts +13 -5
  23. package/dist/components/ui/RadioGroup.js +35 -8
  24. package/dist/components/ui/SelectField.js +16 -2
  25. package/dist/components/ui/Slider.d.ts +24 -7
  26. package/dist/components/ui/Slider.js +45 -7
  27. package/dist/components/ui/Switch.d.ts +26 -5
  28. package/dist/components/ui/Switch.js +54 -6
  29. package/dist/components/ui/Tabs.d.ts +16 -4
  30. package/dist/components/ui/Tabs.js +46 -24
  31. package/dist/components/ui/TextArea.js +2 -0
  32. package/dist/components/ui/TextField.js +5 -1
  33. package/dist/components/ui/ThemeToggle.d.ts +2 -1
  34. package/dist/components/ui/ThemeToggle.js +5 -3
  35. package/dist/hooks/useControlRef.js +2 -2
  36. package/dist/hooks/useFormReset.d.ts +1 -1
  37. package/dist/hooks/useTabRefs.d.ts +4 -0
  38. package/dist/hooks/useTabRefs.js +12 -0
  39. package/dist/lib/componentProps.d.ts +5 -0
  40. package/dist/lib/componentProps.js +1 -0
  41. package/dist/lib/composeEventHandlers.d.ts +4 -0
  42. package/dist/lib/composeEventHandlers.js +8 -0
  43. package/dist/lib/composeRefs.d.ts +3 -0
  44. package/dist/lib/composeRefs.js +24 -0
  45. package/examples/forms/HookFormSettingsForm.tsx +176 -0
  46. package/examples/forms/ManualSettingsForm-0.3.0.tsx +102 -0
  47. package/examples/forms/NativeSettingsForm.tsx +119 -0
  48. package/examples/forms/settings.ts +61 -0
  49. package/examples/forms/useSubmission.ts +77 -0
  50. package/package.json +8 -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.2.1**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
9
+ This source checkout targets **0.4.0**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
10
10
 
11
11
  [Public API and maturity register](PUBLIC-API.md) · [Architecture, scope and compatibility](CONTRACTS.md)
12
12
 
13
- Version 0.2.1 documents baseline contracts and limitations and includes compatible Checkbox ARIA and clipboard lifecycle fixes. Export names, prop types, style defaults, dependencies and optional entry boundaries match 0.2.0. It remains an early 0.x library.
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
+
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.
14
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.2.1 --save-exact
22
+ npm install @abuzareal/contour-ui@0.4.0 --save-exact
21
23
  ```
22
24
 
23
25
  Your app must provide matching `react` and `react-dom` versions: **18.3.1 or React 19**. The compatibility script covers 18.3.1, 19.0.0, and 19.3.0; it doesn't establish support for every framework or React Server Component setup. Put interactive components inside a client boundary when your framework requires one.
@@ -118,7 +120,7 @@ The root entry also exports theme, motion, clipboard, dismissal, navigation, and
118
120
 
119
121
  Use visible field labels, accessible names for icon buttons, and text alongside status colors. `DataTable` sorts scalar string/number values; `Pagination` expects a positive integer page count and a page within that range. Flip cards use a button and make the hidden face inert.
120
122
 
121
- Props support varies between components. Don't assume every export forwards DOM refs, accepts arbitrary native attributes, supports router `asChild` composition, or integrates with form libraries through refs. Verify the relevant type before adopting it.
123
+ Props support varies between components. Button/IconButton/DepthButton/ThemeToggle, ExternalLink and the native fields/choices expose the targets listed in [the composition guide](COMPOSITION.md#native-ref-targets). Dialog/Tabs/Popover support their documented root and owned parts. Don't assume every wrapper or multi-control group forwards a single DOM ref, accepts arbitrary native attributes, supports router `asChild` composition, or integrates with form libraries through refs. Verify the relevant type before adopting it.
122
124
 
123
125
  ## Optional scenes and scrolling
124
126
 
@@ -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.2.1 --save-exact
169
+ npm install @abuzareal/contour-ui@0.4.0 --save-exact
168
170
  ```
169
171
 
170
172
  Commit your manifest and lockfile, run your application's checks, and review both themes, dialogs, forms, and motion before deploying. Version 0.1.1 includes a document scroll lock for dialogs and drawers. Review any app-level lock before removing it, including nested dialogs and short screens. Package publication doesn't update deployed consumers automatically.
@@ -184,7 +186,7 @@ npm audit
184
186
  npm pack --dry-run
185
187
  ```
186
188
 
187
- `validate` checks types, lint, builds, Node/component tests, dialog and Three.js browser behavior, and archive contents. `test:compat` builds packed React 18/19 consumers and compiles the source with React 19 types. `test:consumer` checks one packed consumer, with optional peers absent. `test:optional` verifies strict `/three` and `/scroll` declarations and builds their imports with explicit peers and Three.js types. The compatibility gate runs both checks and verifies the Latin font assets. Compatibility runs need registry access for clean installs.
189
+ `validate` checks types, lint, builds, Node/component tests, dialog, native form/composition and Three.js browser behavior, and archive contents. `test:compat` builds packed React 18/19 consumers and compiles the source with React 19 types. `test:consumer` checks one packed consumer, with optional peers absent. `test:optional` verifies strict `/three` and `/scroll` declarations and builds their imports with explicit peers and Three.js types. The compatibility gate also runs shipped native/RHF recipe browser checks on React 18/19 and compiles the legacy form recipe against npm 0.3.0. It runs both optional/core checks and verifies the Latin font assets. Compatibility runs need registry access for clean installs.
188
190
 
189
191
  `src/` owns components, hooks, helpers, and public entries. `styles/` owns CSS. `tests/` owns regressions and browser checks; `scripts/` owns builds and package/consumer checks. `dist/` is generated. `RELEASING.md` in the source checkout and package archive covers validation and publication.
190
192
 
@@ -192,9 +194,9 @@ MIT licensed. Fontsource fonts and dependencies retain their own licenses.
192
194
 
193
195
  ## APIs added in 0.2.0
194
196
 
195
- Version 0.2.0 adds native refs to TextField, TextArea and SelectField; synchronizes uncontrolled counters and auto-resize after native form reset; adds controlled Tabs (`activeTabId`, `onTabChange`) and Popover/DropdownMenu (`open`, `defaultOpen`, `onOpenChange`); and adds disabled menu items, stable item ids and typeahead. These additions are absent from 0.1.2. Switching a field/disclosure between controlled and uncontrolled ownership during its lifetime is unsupported.
197
+ Version 0.2.0 adds native refs to TextField, TextArea and SelectField; synchronizes uncontrolled counters and auto-resize after native form reset; adds controlled Tabs (`activeTabId`, `onTabChange`) and Popover/DropdownMenu (`open`, `defaultOpen`, `onOpenChange`); and adds disabled menu items, stable item ids and typeahead. These additions are absent from 0.1.2. Switching a field/open-state control between controlled and uncontrolled ownership during its lifetime is unsupported.
196
198
 
197
- Existing uncontrolled calls remain valid. Use a native ref to focus a field or integrate a form controller; controlled tabs and disclosures require the caller to update the selected id or open state:
199
+ Existing uncontrolled calls remain valid. Use a native ref to focus a field or integrate a form controller; controlled tabs and popover/menu open state require the caller to update the selected id or open state:
198
200
 
199
201
  ```tsx
200
202
  import { useRef, useState } from "react";
@@ -230,7 +232,7 @@ export function AccountControls() {
230
232
  }
231
233
  ```
232
234
 
233
- Interactive module boundaries retain `use client` in the built package. The pinned Next.js App Router consumer checks the packed 0.2.0 archive. Follow [Next.js setup](NEXTJS.md) for the pinned Next.js 16.4.0 / React 19.3.0 pair. Pure URL/theme-bootstrap helpers remain usable from the server. A separate fixture mode checks lazy optional Three.js loading and navigation cleanup. Lenis integration, other Next versions and alternate bundlers remain separate compatibility questions.
235
+ Interactive module boundaries retain `use client` in the built package. The pinned Next.js App Router consumer checks the packed candidate archive. Follow [Next.js setup](NEXTJS.md) for the pinned Next.js 16.4.0 / React 19.3.0 pair. Pure URL/theme-bootstrap helpers remain usable from the server. A separate fixture mode checks lazy optional Three.js loading and navigation cleanup. Lenis integration, other Next versions and alternate bundlers remain separate compatibility questions.
234
236
 
235
237
  Custom `useThreeStage` setup can register disposal through `context.onDispose?.(cleanup)` immediately after acquiring each resource, including resources allocated before setup returns. Registered resources are released on failed initialization and unmount; avoid also disposing the same resource in `StageScene.dispose`. A stage may implement `resize()` to reflow after aspect changes while motion is paused.
236
238
 
@@ -1,5 +1,5 @@
1
1
  /** Pill button in four emphasis levels and three sizes, with loading and icon slots. */
2
- import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import { type ButtonHTMLAttributes, type ReactNode } from "react";
3
3
  import { type ButtonSize, type ButtonVariant } from "../../lib/classNames.js";
4
4
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
5
5
  variant?: ButtonVariant;
@@ -9,4 +9,12 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
9
9
  leadingIcon?: ReactNode;
10
10
  loading?: boolean;
11
11
  };
12
- export default function Button({ variant, size, icon, leadingIcon, loading, disabled, className, children, type, ...rest }: ButtonProps): import("react").JSX.Element;
12
+ declare const Button: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
13
+ variant?: ButtonVariant;
14
+ size?: ButtonSize;
15
+ /** Icon placed after the label; it nudges forward on hover. */
16
+ icon?: ReactNode;
17
+ leadingIcon?: ReactNode;
18
+ loading?: boolean;
19
+ } & import("react").RefAttributes<HTMLButtonElement>>;
20
+ export default Button;
@@ -1,6 +1,10 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /** Pill button in four emphasis levels and three sizes, with loading and icon slots. */
4
+ import { forwardRef } from "react";
2
5
  import { buttonClassName, } from "../../lib/classNames.js";
3
6
  import Spinner from "./Spinner.js";
4
- export default function Button({ variant, size, icon, leadingIcon, loading = false, disabled, className, children, type = "button", ...rest }) {
5
- return (_jsxs("button", { ...rest, type: type, className: buttonClassName(variant, size, className), disabled: disabled || loading, "aria-busy": loading || undefined, children: [loading ? _jsx(Spinner, { size: "sm", label: "Loading" }) : leadingIcon, _jsx("span", { children: children }), icon && _jsx("span", { className: "button-icon", children: icon })] }));
6
- }
7
+ const Button = forwardRef(function Button({ variant, size, icon, leadingIcon, loading = false, disabled, className, children, type = "button", ...rest }, ref) {
8
+ return (_jsxs("button", { ref: ref, ...rest, type: type, className: buttonClassName(variant, size, className), disabled: disabled || loading, "aria-busy": loading || undefined, children: [loading ? _jsx(Spinner, { size: "sm", label: "Loading" }) : leadingIcon, _jsx("span", { children: children }), icon && _jsx("span", { className: "button-icon", children: icon })] }));
9
+ });
10
+ export default Button;
@@ -1,9 +1,15 @@
1
1
  /** Square checkbox with an acid check, optional description, error, and indeterminate state. */
2
2
  import { type InputHTMLAttributes } from "react";
3
- export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "id"> & {
3
+ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
4
4
  label: string;
5
5
  description?: string;
6
6
  error?: string;
7
7
  indeterminate?: boolean;
8
8
  };
9
- export default function Checkbox({ label, description, error, indeterminate, className, ...rest }: CheckboxProps): import("react").JSX.Element;
9
+ declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
10
+ label: string;
11
+ description?: string;
12
+ error?: string;
13
+ indeterminate?: boolean;
14
+ } & import("react").RefAttributes<HTMLInputElement>>;
15
+ export default Checkbox;
@@ -1,21 +1,32 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /** Square checkbox with an acid check, optional description, error, and indeterminate state. */
4
- import { useEffect, useId, useRef } from "react";
4
+ import { forwardRef, useEffect, useId } from "react";
5
5
  import { Check, Minus } from "lucide-react";
6
+ import useControlRef from "../../hooks/useControlRef.js";
7
+ import useFormReset from "../../hooks/useFormReset.js";
6
8
  import { cx } from "../../lib/classNames.js";
7
- export default function Checkbox({ label, description, error, indeterminate = false, className, ...rest }) {
8
- const id = useId();
9
- const ref = useRef(null);
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;
12
+ const { ref, attach } = useControlRef(forwardedRef);
10
13
  useEffect(() => {
11
14
  if (ref.current)
12
15
  ref.current.indeterminate = indeterminate;
13
- }, [indeterminate]);
14
- return (_jsxs("div", { className: cx("choice", error && "choice-invalid", className), children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { ...rest, ref: ref, id: id, type: "checkbox", className: "choice-input", "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [
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
+ });
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": [
15
25
  rest["aria-describedby"],
16
26
  description && `${id}-description`,
17
27
  error && `${id}-error`,
18
28
  ]
19
29
  .filter(Boolean)
20
30
  .join(" ") || undefined }), _jsx("span", { className: "choice-box", "aria-hidden": "true", children: indeterminate ? (_jsx(Minus, { size: 13, strokeWidth: 3 })) : (_jsx(Check, { size: 13, strokeWidth: 3 })) })] }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description })), error && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: error }))] })] }));
21
- }
31
+ });
32
+ export default Checkbox;
@@ -1,6 +1,9 @@
1
1
  /** Physical, layered button that presses down into its base (CSS 3D depth). */
2
- import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import { type ButtonHTMLAttributes, type ReactNode } from "react";
3
3
  export type DepthButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
4
4
  children: ReactNode;
5
5
  };
6
- export default function DepthButton({ children, type, ...rest }: DepthButtonProps): import("react").JSX.Element;
6
+ declare const DepthButton: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
7
+ children: ReactNode;
8
+ } & import("react").RefAttributes<HTMLButtonElement>>;
9
+ export default DepthButton;
@@ -1,4 +1,9 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
- export default function DepthButton({ children, type = "button", ...rest }) {
3
- return (_jsx("button", { ...rest, type: type, className: "depth-button", children: _jsx("span", { className: "depth-button-face", children: children }) }));
4
- }
3
+ /** Physical, layered button that presses down into its base (CSS 3D depth). */
4
+ import { forwardRef } from "react";
5
+ import { cx } from "../../lib/classNames.js";
6
+ const DepthButton = forwardRef(function DepthButton({ children, className, type = "button", ...rest }, ref) {
7
+ return (_jsx("button", { ref: ref, ...rest, type: type, className: cx("depth-button", className), children: _jsx("span", { className: "depth-button-face", children: children }) }));
8
+ });
9
+ export default DepthButton;
@@ -1,6 +1,7 @@
1
- /** Modal dialog on the native <dialog> element; also slides in from either edge as a drawer. */
1
+ /** Native controlled modal, with refs and props for its existing DOM parts. */
2
2
  import { type ReactNode } from "react";
3
- export type DialogProps = {
3
+ import type { NativePartProps } from "../../lib/componentProps.js";
4
+ export type DialogProps = Omit<NativePartProps<"dialog">, "ref" | "open" | "onClose" | "title"> & {
4
5
  open: boolean;
5
6
  onClose: () => void;
6
7
  title: string;
@@ -8,5 +9,22 @@ export type DialogProps = {
8
9
  description?: string;
9
10
  footer?: ReactNode;
10
11
  placement?: "center" | "left" | "right";
12
+ panelProps?: NativePartProps<"div">;
13
+ bodyProps?: NativePartProps<"div">;
14
+ footerProps?: NativePartProps<"footer">;
15
+ closeButtonProps?: NativePartProps<"button">;
11
16
  };
12
- export default function Dialog({ open, onClose, title, children, description, footer, placement, }: DialogProps): import("react").JSX.Element;
17
+ declare const Dialog: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"dialog">, "title" | "ref" | "onClose" | "open"> & {
18
+ open: boolean;
19
+ onClose: () => void;
20
+ title: string;
21
+ children: ReactNode;
22
+ description?: string;
23
+ footer?: ReactNode;
24
+ placement?: "center" | "left" | "right";
25
+ panelProps?: NativePartProps<"div">;
26
+ bodyProps?: NativePartProps<"div">;
27
+ footerProps?: NativePartProps<"footer">;
28
+ closeButtonProps?: NativePartProps<"button">;
29
+ } & import("react").RefAttributes<HTMLDialogElement>>;
30
+ export default Dialog;
@@ -1,12 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Modal dialog on the native <dialog> element; also slides in from either edge as a drawer. */
4
- import { useEffect, useId, useRef } from "react";
3
+ /** Native controlled modal, with refs and props for its existing DOM parts. */
4
+ import { forwardRef, useEffect, useId } from "react";
5
5
  import { X } from "lucide-react";
6
6
  import { cx } from "../../lib/classNames.js";
7
7
  import { lockDocumentScroll } from "../../lib/lockDocumentScroll.js";
8
- export default function Dialog({ open, onClose, title, children, description, footer, placement = "center", }) {
9
- const ref = useRef(null);
8
+ import { composeEventHandlers } from "../../lib/composeEventHandlers.js";
9
+ import useControlRef from "../../hooks/useControlRef.js";
10
+ const Dialog = forwardRef(function Dialog({ open, onClose, title, children, description, footer, placement = "center", className, onCancel, onClick, panelProps, bodyProps, footerProps, closeButtonProps, ...rest }, forwardedRef) {
11
+ const { ref, attach } = useControlRef(forwardedRef);
10
12
  const titleId = useId();
11
13
  const descriptionId = useId();
12
14
  useEffect(() => {
@@ -25,13 +27,17 @@ export default function Dialog({ open, onClose, title, children, description, fo
25
27
  dialog.removeEventListener("close", release);
26
28
  release();
27
29
  };
28
- }, [open]);
29
- return (_jsx("dialog", { ref: ref, className: cx("dialog", `dialog-${placement}`), "aria-labelledby": titleId, "aria-describedby": description ? descriptionId : undefined, onCancel: (event) => {
30
+ }, [open, ref]);
31
+ return (_jsx("dialog", { ...rest, ref: attach, role: "dialog", "aria-modal": open || undefined, className: cx("dialog", `dialog-${placement}`, className), "aria-labelledby": cx(titleId, rest["aria-labelledby"]), "aria-describedby": cx(description && descriptionId, rest["aria-describedby"]) || undefined, onCancel: (event) => {
32
+ onCancel?.(event);
33
+ const canceled = event.defaultPrevented;
34
+ // The browser must never close the element independently of the controlled open prop.
30
35
  event.preventDefault();
31
- onClose();
32
- }, onClick: (event) => {
33
- // A click on the element itself (not its panel) is a backdrop click.
36
+ if (!canceled)
37
+ onClose();
38
+ }, onClick: composeEventHandlers(onClick, (event) => {
34
39
  if (event.target === event.currentTarget)
35
40
  onClose();
36
- }, children: _jsxs("div", { className: "dialog-panel", children: [_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: "dialog-description", children: description }))] }), _jsx("button", { type: "button", className: "dialog-close", "aria-label": "Close dialog", onClick: onClose, children: _jsx(X, { size: 18 }) })] }), _jsx("div", { className: "dialog-body", children: children }), footer && _jsx("footer", { className: "dialog-footer", children: footer })] }) }));
37
- }
41
+ }), children: _jsxs("div", { ...panelProps, className: cx("dialog-panel", panelProps?.className), children: [_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: "dialog-description", children: description }))] }), _jsx("button", { ...closeButtonProps, type: "button", className: cx("dialog-close", closeButtonProps?.className), "aria-label": closeButtonProps?.["aria-label"] ?? "Close dialog", onClick: composeEventHandlers(closeButtonProps?.onClick, () => onClose()), children: _jsx(X, { size: 18 }) })] }), _jsx("div", { ...bodyProps, className: cx("dialog-body", bodyProps?.className), children: children }), footer && (_jsx("footer", { ...footerProps, className: cx("dialog-footer", footerProps?.className), children: footer }))] }) }));
42
+ });
43
+ export default Dialog;
@@ -1,4 +1,5 @@
1
1
  /** Anchor for off-site destinations; always opens a new tab without leaking the opener. */
2
- import type { AnchorHTMLAttributes } from "react";
2
+ import { type AnchorHTMLAttributes } from "react";
3
3
  export type ExternalLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">;
4
- export default function ExternalLink({ href, ...props }: ExternalLinkProps): import("react").JSX.Element;
4
+ declare const ExternalLink: import("react").ForwardRefExoticComponent<ExternalLinkProps & import("react").RefAttributes<HTMLAnchorElement>>;
5
+ export default ExternalLink;
@@ -1,5 +1,9 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ /** Anchor for off-site destinations; always opens a new tab without leaking the opener. */
4
+ import { forwardRef } from "react";
2
5
  import { safeHref } from "../../lib/safeHref.js";
3
- export default function ExternalLink({ href, ...props }) {
4
- return (_jsx("a", { ...props, href: safeHref(href), target: "_blank", rel: "noopener noreferrer" }));
5
- }
6
+ const ExternalLink = forwardRef(function ExternalLink({ href, ...props }, ref) {
7
+ return (_jsx("a", { ref: ref, ...props, href: safeHref(href), target: "_blank", rel: "noopener noreferrer" }));
8
+ });
9
+ export default ExternalLink;
@@ -1,5 +1,5 @@
1
1
  /** Circular icon-only button (the portfolio's source/copy button), with a required accessible label. */
2
- import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import { type ButtonHTMLAttributes, type ReactNode } from "react";
3
3
  export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
4
4
  label: string;
5
5
  children: ReactNode;
@@ -8,4 +8,12 @@ export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
8
8
  /** Rotates the icon 45deg on hover, as on the project source links. */
9
9
  spin?: boolean;
10
10
  };
11
- export default function IconButton({ label, children, variant, size, spin, className, type, ...rest }: IconButtonProps): import("react").JSX.Element;
11
+ declare const IconButton: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
12
+ label: string;
13
+ children: ReactNode;
14
+ variant?: "outline" | "solid" | "accent";
15
+ size?: "sm" | "md" | "lg";
16
+ /** Rotates the icon 45deg on hover, as on the project source links. */
17
+ spin?: boolean;
18
+ } & import("react").RefAttributes<HTMLButtonElement>>;
19
+ export default IconButton;
@@ -1,5 +1,9 @@
1
+ "use client";
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ /** Circular icon-only button (the portfolio's source/copy button), with a required accessible label. */
4
+ import { forwardRef } from "react";
2
5
  import { cx } from "../../lib/classNames.js";
3
- export default function IconButton({ label, children, variant = "outline", size = "md", spin = false, className, type = "button", ...rest }) {
4
- return (_jsx("button", { ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: cx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
5
- }
6
+ const IconButton = forwardRef(function IconButton({ label, children, variant = "outline", size = "md", spin = false, className, type = "button", ...rest }, ref) {
7
+ return (_jsx("button", { ref: ref, ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: cx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
8
+ });
9
+ export default IconButton;
@@ -1,7 +1,8 @@
1
- /** Non-modal floating panel anchored to a trigger; closes on Escape or an outside press. */
1
+ /** Nonmodal local panel; root and existing trigger/panel props preserve owned state wiring. */
2
2
  import { type ReactNode } from "react";
3
- export type PopoverProps = {
4
- /** Text for the trigger button. */
3
+ import type { NativePartProps } from "../../lib/componentProps.js";
4
+ export type PopoverProps = Omit<NativePartProps<"div">, "ref" | "title"> & {
5
+ /** Content inside the owned trigger button, not a replacement interactive element. */
5
6
  trigger: ReactNode;
6
7
  title: string;
7
8
  children: ReactNode;
@@ -9,5 +10,19 @@ export type PopoverProps = {
9
10
  open?: boolean;
10
11
  defaultOpen?: boolean;
11
12
  onOpenChange?: (open: boolean) => void;
13
+ triggerProps?: NativePartProps<"button">;
14
+ panelProps?: NativePartProps<"div">;
12
15
  };
13
- export default function Popover({ trigger, title, children, align, open: controlledOpen, defaultOpen, onOpenChange, }: PopoverProps): import("react").JSX.Element;
16
+ declare const Popover: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"div">, "title" | "ref"> & {
17
+ /** Content inside the owned trigger button, not a replacement interactive element. */
18
+ trigger: ReactNode;
19
+ title: string;
20
+ children: ReactNode;
21
+ align?: "start" | "end";
22
+ open?: boolean;
23
+ defaultOpen?: boolean;
24
+ onOpenChange?: (open: boolean) => void;
25
+ triggerProps?: NativePartProps<"button">;
26
+ panelProps?: NativePartProps<"div">;
27
+ } & import("react").RefAttributes<HTMLDivElement>>;
28
+ export default Popover;
@@ -1,20 +1,27 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Non-modal floating panel anchored to a trigger; closes on Escape or an outside press. */
4
- import { useCallback, useId, useRef } from "react";
3
+ /** Nonmodal local panel; root and existing trigger/panel props preserve owned state wiring. */
4
+ import { forwardRef, useCallback, useId } from "react";
5
5
  import useOpenState from "../../hooks/useOpenState.js";
6
6
  import useDismiss from "../../hooks/useDismiss.js";
7
+ import useControlRef from "../../hooks/useControlRef.js";
7
8
  import { cx } from "../../lib/classNames.js";
8
- export default function Popover({ trigger, title, children, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, }) {
9
- const panelId = useId();
9
+ import { composeEventHandlers } from "../../lib/composeEventHandlers.js";
10
+ const Popover = forwardRef(function Popover({ trigger, title, children, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, className, triggerProps, panelProps, ...rest }, forwardedRef) {
11
+ const generatedPanelId = useId();
12
+ const panelId = panelProps?.id ?? generatedPanelId;
10
13
  const titleId = useId();
11
14
  const [open, setOpen] = useOpenState(controlledOpen, defaultOpen, onOpenChange);
12
- const container = useRef(null);
13
- const button = useRef(null);
15
+ const { ref: container, attach: attachContainer } = useControlRef(forwardedRef);
16
+ const { ref: button, attach: attachButton } = useControlRef(triggerProps?.ref ?? null);
14
17
  useDismiss(container, open, useCallback((reason) => {
15
18
  setOpen(false);
16
19
  if (reason === "escape")
17
20
  button.current?.focus();
18
- }, [setOpen]));
19
- return (_jsxs("div", { ref: container, className: cx("popover", `popover-${align}`), children: [_jsx("button", { ref: button, type: "button", className: "menu-trigger", "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen(!open), children: trigger }), _jsxs("div", { id: panelId, role: "dialog", "aria-labelledby": titleId, className: "popover-panel", hidden: !open, children: [_jsx("strong", { id: titleId, className: "popover-title", children: title }), children] })] }));
20
- }
21
+ }, [setOpen, button]));
22
+ return (_jsxs("div", { ...rest, ref: attachContainer, className: cx("popover", `popover-${align}`, className), children: [_jsx("button", { ...triggerProps, ref: attachButton, type: "button", className: cx("menu-trigger", triggerProps?.className), "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, onClick: composeEventHandlers(triggerProps?.onClick, () => {
23
+ if (!triggerProps?.disabled)
24
+ setOpen(!open);
25
+ }), children: trigger }), _jsxs("div", { ...panelProps, id: panelId, role: "dialog", "aria-labelledby": cx(titleId, panelProps?.["aria-labelledby"]), className: cx("popover-panel", panelProps?.className), hidden: !open, children: [_jsx("strong", { id: titleId, className: "popover-title", children: title }), children] })] }));
26
+ });
27
+ export default Popover;
@@ -1,14 +1,22 @@
1
- export type RadioGroupProps<Value extends string> = {
1
+ /** Native radios with caller form/name, controlled/default selection and option input refs. */
2
+ import { type FieldsetHTMLAttributes } from "react";
3
+ import type { NativePartProps } from "../../lib/componentProps.js";
4
+ export type RadioGroupProps<Value extends string> = Omit<FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "children" | "dangerouslySetInnerHTML"> & {
2
5
  legend: string;
3
6
  options: {
4
7
  value: Value;
5
8
  label: string;
6
9
  description?: string;
7
10
  disabled?: boolean;
11
+ /** Native focus/blur/ref/ID access for form controllers; selection stays group-owned. */
12
+ inputProps?: Omit<NativePartProps<"input">, "type" | "name" | "form" | "value" | "defaultValue" | "checked" | "defaultChecked" | "disabled" | "required" | "onChange">;
8
13
  }[];
9
- value: Value;
10
- onChange: (value: Value) => void;
11
- /** Lays options out in a row on wider screens. */
14
+ value?: Value;
15
+ defaultValue?: Value;
16
+ onChange?: (value: Value) => void;
12
17
  inline?: boolean;
18
+ required?: boolean;
19
+ description?: string;
20
+ error?: string;
13
21
  };
14
- export default function RadioGroup<Value extends string>({ legend, options, value, onChange, inline, }: RadioGroupProps<Value>): import("react").JSX.Element;
22
+ export default function RadioGroup<Value extends string>({ legend, options, value, defaultValue, onChange, inline, name: providedName, form, id: providedId, disabled, required, description, error, className, "aria-describedby": describedBy, ...rest }: RadioGroupProps<Value>): import("react").JSX.Element;
@@ -1,12 +1,39 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Fieldset of round radio options sharing one name, with a mono legend. */
4
- import { useId } from "react";
3
+ /** Native radios with caller form/name, controlled/default selection and option input refs. */
4
+ import { useId, useLayoutEffect, useRef, useState, } from "react";
5
5
  import { cx } from "../../lib/classNames.js";
6
- export default function RadioGroup({ legend, options, value, onChange, inline = false, }) {
7
- const name = useId();
8
- return (_jsxs("fieldset", { className: cx("radio-group", inline && "radio-group-inline"), children: [_jsx("legend", { className: "field-label mono", children: legend }), options.map((option) => {
9
- const id = `${name}-${option.value}`;
10
- return (_jsxs("div", { className: "choice choice-radio", children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { id: id, type: "radio", name: name, value: option.value, checked: option.value === value, disabled: option.disabled, className: "choice-input", onChange: () => onChange(option.value) }), _jsx("span", { className: "choice-box", "aria-hidden": "true" })] }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: option.label }), option.description && (_jsx("span", { className: "choice-description", children: option.description }))] })] }, option.value));
11
- })] }));
6
+ import useFormReset from "../../hooks/useFormReset.js";
7
+ export default function RadioGroup({ legend, options, value, defaultValue, onChange, inline = false, name: providedName, form, id: providedId, disabled, required, description, error, className, "aria-describedby": describedBy, ...rest }) {
8
+ const generatedId = useId();
9
+ const id = providedId ?? generatedId;
10
+ const name = providedName ?? generatedId;
11
+ const [initial] = useState(defaultValue ?? value);
12
+ const ref = useRef(null);
13
+ const inputs = () => ref.current?.querySelectorAll('input[type="radio"]');
14
+ useLayoutEffect(() => {
15
+ inputs()?.forEach((input) => {
16
+ input.defaultChecked = input.value === initial;
17
+ if (value !== undefined)
18
+ input.checked = input.value === value;
19
+ });
20
+ });
21
+ useFormReset(ref, () => {
22
+ if (value !== undefined)
23
+ inputs()?.forEach((input) => {
24
+ input.checked = input.value === value;
25
+ });
26
+ });
27
+ const context = cx(describedBy, description && `${id}-description`, error && `${id}-error`) || undefined;
28
+ return (_jsxs("fieldset", { ...rest, ref: ref, id: id, name: name, form: form, disabled: disabled, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": context, className: cx("radio-group", inline && "radio-group-inline", className), children: [_jsx("legend", { className: "field-label mono", children: legend }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description })), options.map((option) => {
29
+ const optionId = option.inputProps?.id ?? `${id}-${option.value}`;
30
+ return (_jsxs("div", { className: "choice choice-radio", children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { ...option.inputProps, id: optionId, type: "radio", name: name, form: form, value: option.value, checked: value === undefined ? undefined : option.value === value, defaultChecked: value === undefined ? option.value === initial : undefined, required: required, disabled: disabled || option.disabled, className: cx("choice-input", option.inputProps?.className), "aria-invalid": error
31
+ ? true
32
+ : (option.inputProps?.["aria-invalid"] ??
33
+ rest["aria-invalid"]), "aria-describedby": cx(option.inputProps?.["aria-describedby"], context, option.description && `${optionId}-description`) || undefined, onChange: (event) => {
34
+ if (!event.currentTarget.checked)
35
+ return;
36
+ onChange?.(option.value);
37
+ } }), _jsx("span", { className: "choice-box", "aria-hidden": "true" })] }), _jsxs("label", { htmlFor: optionId, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: option.label }), option.description && (_jsx("span", { id: `${optionId}-description`, className: "choice-description", children: option.description }))] })] }, option.value));
38
+ }), error && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: error }))] }));
12
39
  }
@@ -5,10 +5,24 @@ import { forwardRef, useId } from "react";
5
5
  import { ChevronDown } from "lucide-react";
6
6
  import { fieldDescribedBy } from "../../lib/a11y.js";
7
7
  import Field from "./Field.js";
8
- const SelectField = forwardRef(function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }, ref) {
8
+ import useControlRef from "../../hooks/useControlRef.js";
9
+ import useFormReset from "../../hooks/useFormReset.js";
10
+ /** Native reset does not preserve React's controlled selection on its own. */
11
+ function restoreSelection(control, value) {
12
+ const values = new Set(Array.isArray(value) ? value.map(String) : [String(value)]);
13
+ for (const option of control.options)
14
+ option.selected = values.has(option.value);
15
+ }
16
+ const SelectField = forwardRef(function SelectField({ id: providedId, "aria-describedby": describedBy, label, options, hint, error, placeholder, ...rest }, forwardedRef) {
9
17
  const generatedId = useId();
10
18
  const id = providedId ?? generatedId;
11
- return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, children: _jsxs("div", { className: "field-control field-select", children: [_jsxs("select", { ...rest, ref: ref, id: id, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
19
+ const { ref, attach } = useControlRef(forwardedRef);
20
+ useFormReset(ref, () => {
21
+ if (!ref.current || rest.value === undefined)
22
+ return;
23
+ restoreSelection(ref.current, rest.value);
24
+ });
25
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, children: _jsxs("div", { className: "field-control field-select", children: [_jsxs("select", { ...rest, ref: attach, id: id, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
12
26
  .filter(Boolean)
13
27
  .join(" ") || undefined, children: [placeholder && (_jsx("option", { value: "", disabled: true, children: placeholder })), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] }), _jsx(ChevronDown, { className: "field-chevron", size: 16, "aria-hidden": "true" })] }) }));
14
28
  });
@@ -1,12 +1,29 @@
1
- export type SliderProps = {
1
+ /** Native range submission with controlled/default state, reset and a formatted readout. */
2
+ import { type InputHTMLAttributes } from "react";
3
+ export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange" | "required" | "dangerouslySetInnerHTML" | "children" | "min" | "max" | "step"> & {
2
4
  label: string;
3
- value: number;
4
- onChange: (value: number) => void;
5
+ value?: number;
6
+ defaultValue?: number;
7
+ onChange?: (value: number) => void;
5
8
  min?: number;
6
9
  max?: number;
7
- step?: number;
8
- /** Formats the readout, e.g. (value) => `${value}%`. */
10
+ step?: number | "any";
11
+ /** Formats the readout and accessible value text. */
9
12
  format?: (value: number) => string;
10
- disabled?: boolean;
13
+ hint?: string;
14
+ error?: string;
11
15
  };
12
- export default function Slider({ label, value, onChange, min, max, step, format, disabled, }: SliderProps): import("react").JSX.Element;
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>>;
29
+ export default Slider;