@abuzareal/contour-ui 0.3.0 → 0.5.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 (147) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/COMPOSITION.md +28 -28
  3. package/CONTRACTS.md +9 -3
  4. package/FORMS.md +94 -0
  5. package/NEXTJS.md +6 -2
  6. package/OVERLAYS.md +52 -0
  7. package/PUBLIC-API.md +181 -162
  8. package/README.md +44 -28
  9. package/RELEASING.md +1 -1
  10. package/SIZE.md +45 -0
  11. package/STYLING.md +68 -0
  12. package/dist/actions.css +7 -0
  13. package/dist/components/hero/Sculpture.js +2 -1
  14. package/dist/components/three/ObjectStudy.js +2 -1
  15. package/dist/components/ui/Alert.d.ts +2 -1
  16. package/dist/components/ui/Alert.js +3 -3
  17. package/dist/components/ui/Avatar.d.ts +3 -1
  18. package/dist/components/ui/Avatar.js +3 -3
  19. package/dist/components/ui/Badge.js +2 -2
  20. package/dist/components/ui/BrandMark.js +2 -1
  21. package/dist/components/ui/Breadcrumbs.d.ts +2 -1
  22. package/dist/components/ui/Breadcrumbs.js +3 -2
  23. package/dist/components/ui/Button.d.ts +2 -0
  24. package/dist/components/ui/Button.js +3 -2
  25. package/dist/components/ui/Card.js +2 -2
  26. package/dist/components/ui/Checkbox.d.ts +2 -2
  27. package/dist/components/ui/Checkbox.js +14 -5
  28. package/dist/components/ui/CountUp.d.ts +2 -1
  29. package/dist/components/ui/CountUp.js +6 -3
  30. package/dist/components/ui/DataTable.d.ts +1 -0
  31. package/dist/components/ui/DataTable.js +12 -8
  32. package/dist/components/ui/DepthButton.js +2 -2
  33. package/dist/components/ui/Dialog.d.ts +5 -1
  34. package/dist/components/ui/Dialog.js +49 -18
  35. package/dist/components/ui/Disclosure.js +2 -1
  36. package/dist/components/ui/Divider.js +3 -2
  37. package/dist/components/ui/DropdownMenu.d.ts +11 -4
  38. package/dist/components/ui/DropdownMenu.js +109 -66
  39. package/dist/components/ui/EmptyState.js +2 -1
  40. package/dist/components/ui/Field.js +2 -2
  41. package/dist/components/ui/FlipCard.js +2 -2
  42. package/dist/components/ui/FloatingPortal.d.ts +10 -0
  43. package/dist/components/ui/FloatingPortal.js +51 -0
  44. package/dist/components/ui/IconButton.js +2 -2
  45. package/dist/components/ui/Kbd.js +2 -1
  46. package/dist/components/ui/Magnetic.js +2 -1
  47. package/dist/components/ui/Marquee.js +3 -2
  48. package/dist/components/ui/OverlayTrigger.d.ts +11 -0
  49. package/dist/components/ui/OverlayTrigger.js +5 -0
  50. package/dist/components/ui/Pagination.d.ts +5 -1
  51. package/dist/components/ui/Pagination.js +3 -2
  52. package/dist/components/ui/Popover.d.ts +18 -8
  53. package/dist/components/ui/Popover.js +58 -17
  54. package/dist/components/ui/ProgressBar.js +2 -2
  55. package/dist/components/ui/RadioGroup.d.ts +13 -5
  56. package/dist/components/ui/RadioGroup.js +36 -9
  57. package/dist/components/ui/Reveal.d.ts +0 -1
  58. package/dist/components/ui/Reveal.js +4 -3
  59. package/dist/components/ui/RevealHeading.js +2 -1
  60. package/dist/components/ui/ScrollProgress.js +2 -1
  61. package/dist/components/ui/SectionLabel.js +2 -1
  62. package/dist/components/ui/SegmentedControl.js +6 -3
  63. package/dist/components/ui/SelectField.js +18 -3
  64. package/dist/components/ui/Skeleton.js +2 -2
  65. package/dist/components/ui/Slider.d.ts +23 -7
  66. package/dist/components/ui/Slider.js +43 -6
  67. package/dist/components/ui/Spinner.js +2 -2
  68. package/dist/components/ui/Stat.js +2 -1
  69. package/dist/components/ui/Stepper.js +3 -3
  70. package/dist/components/ui/Switch.d.ts +27 -5
  71. package/dist/components/ui/Switch.js +51 -5
  72. package/dist/components/ui/Tabs.js +7 -5
  73. package/dist/components/ui/Tag.d.ts +2 -1
  74. package/dist/components/ui/Tag.js +4 -3
  75. package/dist/components/ui/TextArea.js +4 -1
  76. package/dist/components/ui/TextField.js +8 -3
  77. package/dist/components/ui/ThemeScope.d.ts +13 -0
  78. package/dist/components/ui/ThemeScope.js +10 -0
  79. package/dist/components/ui/ThemeToggle.d.ts +3 -0
  80. package/dist/components/ui/ThemeToggle.js +4 -3
  81. package/dist/components/ui/TiltCard.js +3 -2
  82. package/dist/components/ui/Timeline.js +2 -2
  83. package/dist/components/ui/ToastProvider.d.ts +5 -2
  84. package/dist/components/ui/ToastProvider.js +5 -5
  85. package/dist/components/ui/Tooltip.d.ts +4 -4
  86. package/dist/components/ui/Tooltip.js +70 -10
  87. package/dist/data-display.css +5 -0
  88. package/dist/effects.css +6 -0
  89. package/dist/feedback.css +5 -0
  90. package/dist/fonts-latin.css +10 -4
  91. package/dist/fonts.css +8 -0
  92. package/dist/forms.css +5 -0
  93. package/dist/hooks/useDismiss.d.ts +12 -3
  94. package/dist/hooks/useDismiss.js +5 -21
  95. package/dist/hooks/useFloatingPanel.d.ts +14 -0
  96. package/dist/hooks/useFloatingPanel.js +157 -0
  97. package/dist/hooks/useFormReset.d.ts +1 -1
  98. package/dist/hooks/useLayerDismiss.d.ts +4 -0
  99. package/dist/hooks/useLayerDismiss.js +48 -0
  100. package/dist/hooks/useMobileMenu.d.ts +0 -1
  101. package/dist/hooks/useMobileMenu.js +11 -16
  102. package/dist/hooks/usePanelFocus.d.ts +10 -0
  103. package/dist/hooks/usePanelFocus.js +60 -0
  104. package/dist/hooks/useTheme.d.ts +1 -0
  105. package/dist/hooks/useTheme.js +18 -5
  106. package/dist/index.d.ts +5 -0
  107. package/dist/index.js +1 -0
  108. package/dist/kit/forms.css +11 -2
  109. package/dist/kit/navigation.css +4 -0
  110. package/dist/kit/overlays.css +8 -4
  111. package/dist/lib/classNames.d.ts +2 -0
  112. package/dist/lib/classNames.js +16 -1
  113. package/dist/lib/componentClasses.d.ts +1 -0
  114. package/dist/lib/componentClasses.js +2 -0
  115. package/dist/lib/keyboard.d.ts +1 -1
  116. package/dist/lib/keyboard.js +5 -2
  117. package/dist/lib/layers.d.ts +18 -0
  118. package/dist/lib/layers.js +128 -0
  119. package/dist/lib/themeScope.d.ts +5 -0
  120. package/dist/lib/themeScope.js +3 -0
  121. package/dist/minimal-reset.css +6 -0
  122. package/dist/navigation.css +5 -0
  123. package/dist/overlays.css +5 -0
  124. package/dist/scoped/base.css +183 -0
  125. package/dist/scoped/kit/actions.css +209 -0
  126. package/dist/scoped/kit/data-display.css +492 -0
  127. package/dist/scoped/kit/effects.css +140 -0
  128. package/dist/scoped/kit/feedback.css +309 -0
  129. package/dist/scoped/kit/forms.css +370 -0
  130. package/dist/scoped/kit/navigation.css +266 -0
  131. package/dist/scoped/kit/overlays.css +299 -0
  132. package/dist/scoped/marquee.css +58 -0
  133. package/dist/scoped/spinner.css +20 -0
  134. package/dist/scoped/theme-toggle.css +38 -0
  135. package/dist/scoped/tokens.css +119 -0
  136. package/dist/scoped/ui.css +127 -0
  137. package/dist/scoped/utilities.css +17 -0
  138. package/dist/scoped-tokens.css +1 -0
  139. package/dist/scoped.css +12 -0
  140. package/dist/theme-toggle.css +2 -2
  141. package/dist/tokens.json +710 -0
  142. package/examples/forms/HookFormSettingsForm.tsx +176 -0
  143. package/examples/forms/ManualSettingsForm-0.3.0.tsx +102 -0
  144. package/examples/forms/NativeSettingsForm.tsx +119 -0
  145. package/examples/forms/settings.ts +61 -0
  146. package/examples/forms/useSubmission.ts +77 -0
  147. package/package.json +30 -6
@@ -1,9 +1,55 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** On/off switch (role="switch") with a pill track that fills acid when on. */
4
- import { forwardRef, useId } from "react";
5
- const Switch = forwardRef(function Switch({ label, checked, onChange, description, disabled }, ref) {
6
- const id = useId();
7
- return (_jsxs("div", { className: "switch", children: [_jsx("button", { ref: ref, id: id, type: "button", role: "switch", "aria-checked": checked, "aria-describedby": description ? `${id}-description` : undefined, disabled: disabled, className: "switch-track", onClick: () => onChange(!checked), children: _jsx("span", { className: "switch-thumb" }) }), _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 }))] })] }));
3
+ /** Button switch with a native checkbox backing submission, validation and reset. */
4
+ import { forwardRef, useId, useLayoutEffect, useRef, useState, } from "react";
5
+ import { cx, ccx } 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, requiredMessage = "Turn on this option to continue.", 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 ?? (invalid && !current ? requiredMessage : undefined);
18
+ useLayoutEffect(() => {
19
+ if (input.current) {
20
+ input.current.defaultChecked = initial;
21
+ input.current.checked = current;
22
+ }
23
+ });
24
+ useFormReset(input, () => {
25
+ if (checked === undefined)
26
+ setLocalChecked(input.current?.checked ?? initial);
27
+ else if (input.current)
28
+ input.current.checked = checked;
29
+ setInvalid(false);
30
+ });
31
+ return (_jsxs("div", { className: ccx("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: ccx("switch-track", className), onClick: (event) => {
32
+ onClick?.(event);
33
+ if (event.defaultPrevented)
34
+ return;
35
+ if (event.currentTarget.matches(":disabled"))
36
+ return;
37
+ if (checked === undefined) {
38
+ if (input.current)
39
+ input.current.checked = !current;
40
+ setLocalChecked(!current);
41
+ }
42
+ setInvalid(false);
43
+ onChange?.(!current);
44
+ }, children: _jsx("span", { className: ccx("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) => {
45
+ onInvalid?.(event);
46
+ const canceled = event.defaultPrevented;
47
+ // Native validation must never try to focus the noninteractive backing control.
48
+ event.preventDefault();
49
+ if (!canceled) {
50
+ setInvalid(true);
51
+ button.current?.focus();
52
+ }
53
+ } }), _jsxs("label", { htmlFor: id, className: ccx("choice-text"), children: [_jsx("span", { id: `${id}-label`, className: ccx("choice-label"), children: label }), description && (_jsx("span", { id: `${id}-description`, className: ccx("choice-description"), children: description })), message && (_jsx("span", { id: `${id}-error`, className: ccx("choice-error mono"), children: message }))] })] }));
8
54
  });
9
55
  export default Switch;
@@ -4,7 +4,7 @@ import { createElement as _createElement } from "react";
4
4
  /** Controlled/default tabs, with props and native refs for root/list/buttons/panels. */
5
5
  import { forwardRef, useId, useLayoutEffect, useRef, useState, } from "react";
6
6
  import { nextIndexForKey } from "../../lib/keyboard.js";
7
- import { cx } from "../../lib/classNames.js";
7
+ import { cx, ccx } from "../../lib/classNames.js";
8
8
  import useTabRefs from "../../hooks/useTabRefs.js";
9
9
  const Tabs = forwardRef(function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, listProps, className, id, ...rest }, ref) {
10
10
  const generatedId = useId();
@@ -45,13 +45,13 @@ const Tabs = forwardRef(function Tabs({ label, tabs, defaultTabId, activeTabId,
45
45
  };
46
46
  const buttonId = (tab) => tab.buttonProps?.id ?? `${baseId}-tab-${tab.id}`;
47
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: {
48
+ return (_jsxs("div", { ...rest, ref: ref, id: id, className: ccx("tabs", className), children: [_jsx("div", { ...listProps, className: ccx("tabs-list", listProps?.className), role: "tablist", "aria-label": label, style: {
49
49
  ...listProps?.style,
50
50
  "--indicator-left": `${indicator.left}px`,
51
51
  "--indicator-width": `${indicator.width}px`,
52
52
  }, children: tabs.map((tab, index) => {
53
53
  const selected = tab.id === selectedId;
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) => {
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: ccx("tabs-tab", tab.buttonProps?.className), onClick: (event) => {
55
55
  tab.buttonProps?.onClick?.(event);
56
56
  if (!event.defaultPrevented)
57
57
  select(tab);
@@ -59,12 +59,14 @@ const Tabs = forwardRef(function Tabs({ label, tabs, defaultTabId, activeTabId,
59
59
  tab.buttonProps?.onKeyDown?.(event);
60
60
  if (event.defaultPrevented)
61
61
  return;
62
- const next = nextIndexForKey(event.key, index, tabs.length);
62
+ const next = nextIndexForKey(event.key, index, tabs.length, "horizontal", event.currentTarget.ownerDocument.defaultView?.getComputedStyle(event.currentTarget).direction === "rtl"
63
+ ? "rtl"
64
+ : "ltr");
63
65
  if (next === null)
64
66
  return;
65
67
  event.preventDefault();
66
68
  select(tabs[next], true);
67
69
  } }, 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)))] }));
70
+ }) }), 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: ccx("tabs-panel", tab.panelProps?.className) }, tab.content)))] }));
69
71
  });
70
72
  export default Tabs;
@@ -1,5 +1,6 @@
1
1
  export type TagProps = {
2
2
  children: string;
3
+ removeLabel?: string;
3
4
  onRemove?: () => void;
4
5
  };
5
- export default function Tag({ children, onRemove }: TagProps): import("react").JSX.Element;
6
+ export default function Tag({ children, onRemove, removeLabel }: TagProps): import("react").JSX.Element;
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Mono "+ tag" label (as in project notes); becomes a removable chip when given onRemove. */
4
5
  import { X } from "lucide-react";
5
- export default function Tag({ children, onRemove }) {
6
+ export default function Tag({ children, onRemove, removeLabel }) {
6
7
  if (!onRemove)
7
- return _jsx("span", { className: "tag mono", children: children });
8
- return (_jsxs("span", { className: "tag tag-removable mono", children: [children, _jsx("button", { type: "button", className: "tag-remove", "aria-label": `Remove ${children}`, onClick: onRemove, children: _jsx(X, { size: 12 }) })] }));
8
+ return _jsx("span", { className: ccx("tag mono"), children: children });
9
+ return (_jsxs("span", { className: ccx("tag tag-removable mono"), children: [children, _jsx("button", { type: "button", className: ccx("tag-remove"), "aria-label": removeLabel ?? `Remove ${children}`, onClick: onRemove, children: _jsx(X, { size: 12 }) })] }));
9
10
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Multi-line text input with optional auto-resize and character counter. */
4
5
  import { forwardRef, useId, useLayoutEffect, useState, } from "react";
5
6
  import { fieldDescribedBy } from "../../lib/a11y.js";
@@ -20,12 +21,14 @@ const TextArea = forwardRef(function TextArea({ id: providedId, "aria-describedb
20
21
  };
21
22
  useLayoutEffect(fitContent);
22
23
  useFormReset(ref, () => {
24
+ if (ref.current && rest.value !== undefined)
25
+ ref.current.value = String(rest.value);
23
26
  setLength(ref.current?.value.length ?? 0);
24
27
  fitContent();
25
28
  });
26
29
  return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, counter: maxLength !== undefined
27
30
  ? `${rest.value !== undefined ? String(rest.value).length : length}/${maxLength}`
28
- : undefined, children: _jsx("div", { className: "field-control", children: _jsx("textarea", { ...rest, ref: attach, id: id, rows: rows, maxLength: maxLength, "data-auto-resize": autoResize || undefined, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
31
+ : undefined, children: _jsx("div", { className: ccx("field-control"), children: _jsx("textarea", { ...rest, ref: attach, id: id, rows: rows, maxLength: maxLength, "data-auto-resize": autoResize || undefined, "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [describedBy, fieldDescribedBy(id, hint, error)]
29
32
  .filter(Boolean)
30
33
  .join(" ") || undefined,
31
34
  // Native input also runs when React deduplicates a repeated value after form.reset().
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Single-line text input with underline styling, optional icon/adornment, and character counter. */
4
5
  import { forwardRef, useId, useState, } from "react";
5
6
  import { fieldDescribedBy } from "../../lib/a11y.js";
@@ -11,10 +12,14 @@ const TextField = forwardRef(function TextField({ id: providedId, "aria-describe
11
12
  const generatedId = useId();
12
13
  const id = providedId ?? generatedId;
13
14
  const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
14
- useFormReset(ref, () => setLength(ref.current?.value.length ?? 0));
15
+ useFormReset(ref, () => {
16
+ if (ref.current && rest.value !== undefined)
17
+ ref.current.value = String(rest.value);
18
+ setLength(ref.current?.value.length ?? 0);
19
+ });
15
20
  return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, hideLabel: hideLabel, disabled: rest.disabled, counter: maxLength !== undefined
16
21
  ? `${rest.value !== undefined ? String(rest.value).length : length}/${maxLength}`
17
- : 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)]
22
+ : undefined, children: _jsxs("div", { className: ccx("field-control"), children: [leadingIcon && (_jsx("span", { className: ccx("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)]
18
23
  .filter(Boolean)
19
24
  .join(" ") || undefined,
20
25
  // Native input also runs when React deduplicates a repeated value after form.reset().
@@ -24,6 +29,6 @@ const TextField = forwardRef(function TextField({ id: providedId, "aria-describe
24
29
  }, onChange: (event) => {
25
30
  setLength(event.target.value.length);
26
31
  onChange?.(event);
27
- } }), trailing && _jsx("span", { className: "field-trailing", children: trailing })] }) }));
32
+ } }), trailing && (_jsx("span", { className: ccx("field-trailing"), children: trailing }))] }) }));
28
33
  });
29
34
  export default TextField;
@@ -0,0 +1,13 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import type { Theme } from "../../hooks/useTheme.js";
3
+ export type ThemeScopeProps = HTMLAttributes<HTMLDivElement> & {
4
+ theme: Theme;
5
+ /** Caller owns persistence; omitting this makes embedded theme controls read-only. */
6
+ onThemeChange?: (theme: Theme) => void;
7
+ };
8
+ declare const ThemeScope: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & {
9
+ theme: Theme;
10
+ /** Caller owns persistence; omitting this makes embedded theme controls read-only. */
11
+ onThemeChange?: (theme: Theme) => void;
12
+ } & import("react").RefAttributes<HTMLDivElement>>;
13
+ export default ThemeScope;
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { forwardRef, useMemo } from "react";
4
+ import { ThemeScopeContext } from "../../lib/themeScope.js";
5
+ import { cx } from "../../lib/classNames.js";
6
+ const ThemeScope = forwardRef(function ThemeScope({ theme, onThemeChange, className, children, ...rest }, ref) {
7
+ const context = useMemo(() => ({ theme, onThemeChange }), [theme, onThemeChange]);
8
+ return (_jsx(ThemeScopeContext.Provider, { value: context, children: _jsx("div", { ...rest, ref: ref, "data-contour-theme": theme, className: cx("contour-root", className), children: children }) }));
9
+ });
10
+ export default ThemeScope;
@@ -1,6 +1,9 @@
1
1
  export type ThemeToggleProps = {
2
2
  /** When false the theme switches instantly instead of animating. */
3
3
  animate: boolean;
4
+ darkLabel?: string;
5
+ lightTitle?: string;
6
+ darkTitle?: string;
4
7
  };
5
8
  declare const ThemeToggle: import("react").ForwardRefExoticComponent<ThemeToggleProps & import("react").RefAttributes<HTMLButtonElement>>;
6
9
  export default ThemeToggle;
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Header button that switches between the light and dark themes. */
4
5
  import { forwardRef } from "react";
5
6
  import { Moon, Sun } from "lucide-react";
6
7
  import useTheme from "../../hooks/useTheme.js";
7
- const ThemeToggle = forwardRef(function ThemeToggle({ animate }, ref) {
8
- const { theme, toggle } = useTheme(animate);
8
+ const ThemeToggle = forwardRef(function ThemeToggle({ animate, darkLabel = "Dark theme", lightTitle = "Switch to light theme", darkTitle = "Switch to dark theme", }, ref) {
9
+ const { theme, toggle, canToggle } = useTheme(animate);
9
10
  const dark = theme === "dark";
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
+ return (_jsxs("button", { ref: ref, type: "button", className: ccx("theme-toggle"), "aria-label": darkLabel, "aria-pressed": dark, disabled: !canToggle, title: dark ? lightTitle : darkTitle, onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: ccx("theme-icon-moon"), size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: ccx("theme-icon-sun"), size: 17, "aria-hidden": "true" })] }));
11
12
  });
12
13
  export default ThemeToggle;
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Card that tilts in 3D toward a fine pointer with a moving glare highlight. */
4
5
  import { useRef } from "react";
5
6
  import { motionDisabledFor } from "../../lib/motion.js";
6
7
  export default function TiltCard({ children, maxTilt = 12 }) {
7
8
  const ref = useRef(null);
8
- return (_jsx("div", { className: "tilt-stage", children: _jsxs("div", { ref: ref, className: "tilt-card", onPointerMove: (event) => {
9
+ return (_jsx("div", { className: ccx("tilt-stage"), children: _jsxs("div", { ref: ref, className: ccx("tilt-card"), onPointerMove: (event) => {
9
10
  const element = ref.current;
10
11
  if (!element ||
11
12
  event.pointerType !== "mouse" ||
@@ -20,5 +21,5 @@ export default function TiltCard({ children, maxTilt = 12 }) {
20
21
  element.style.setProperty("--glare-y", `${y * 100}%`);
21
22
  }, onPointerLeave: () => {
22
23
  ["--tilt-x", "--tilt-y", "--glare-x", "--glare-y"].forEach((name) => ref.current?.style.removeProperty(name));
23
- }, children: [children, _jsx("span", { className: "tilt-glare", "aria-hidden": "true" })] }) }));
24
+ }, children: [children, _jsx("span", { className: ccx("tilt-glare"), "aria-hidden": "true" })] }) }));
24
25
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Vertical timeline of dated entries joined by a rule, with an acid marker for the current one. */
3
- import { cx } from "../../lib/classNames.js";
3
+ import { ccx } from "../../lib/classNames.js";
4
4
  export default function Timeline({ entries }) {
5
- return (_jsx("ol", { className: "timeline", children: entries.map((entry) => (_jsxs("li", { className: cx("timeline-entry", entry.current && "timeline-current"), children: [_jsx("span", { className: "timeline-date mono", children: entry.date }), _jsx("span", { className: "timeline-title", children: entry.title }), entry.body && _jsx("span", { className: "timeline-body", children: entry.body })] }, `${entry.date}-${entry.title}`))) }));
5
+ return (_jsx("ol", { className: ccx("timeline"), children: entries.map((entry) => (_jsxs("li", { className: ccx("timeline-entry", entry.current && "timeline-current"), children: [_jsx("span", { className: ccx("timeline-date mono"), children: entry.date }), _jsx("span", { className: ccx("timeline-title"), children: entry.title }), entry.body && (_jsx("span", { className: ccx("timeline-body"), children: entry.body }))] }, `${entry.date}-${entry.title}`))) }));
6
6
  }
@@ -1,5 +1,8 @@
1
1
  /** Renders queued toasts in a polite live region; each auto-dismisses unless hovered or focused. */
2
2
  import { type ReactNode } from "react";
3
- export default function ToastProvider({ children }: {
3
+ export type ToastProviderProps = {
4
4
  children: ReactNode;
5
- }): import("react").JSX.Element;
5
+ label?: string;
6
+ dismissLabel?: string;
7
+ };
8
+ export default function ToastProvider({ children, label, dismissLabel, }: ToastProviderProps): import("react").JSX.Element;
@@ -4,9 +4,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
5
5
  import { X } from "lucide-react";
6
6
  import { ToastContext, } from "../../hooks/useToast.js";
7
- import { cx } from "../../lib/classNames.js";
7
+ import { ccx } from "../../lib/classNames.js";
8
8
  const autoDismissAfter = 5000;
9
- function Toast({ toast, onDismiss, }) {
9
+ function Toast({ toast, onDismiss, dismissLabel, }) {
10
10
  const [paused, setPaused] = useState(false);
11
11
  useEffect(() => {
12
12
  if (paused)
@@ -14,9 +14,9 @@ function Toast({ toast, onDismiss, }) {
14
14
  const timer = window.setTimeout(() => onDismiss(toast.id), autoDismissAfter);
15
15
  return () => window.clearTimeout(timer);
16
16
  }, [paused, toast.id, onDismiss]);
17
- return (_jsxs("li", { className: cx("toast", `toast-${toast.tone ?? "neutral"}`), onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), children: [_jsx("span", { className: "toast-bar", "aria-hidden": "true" }), _jsxs("div", { className: "toast-content", children: [_jsx("strong", { children: toast.title }), toast.message && _jsx("p", { children: toast.message })] }), _jsx("button", { type: "button", className: "toast-close", "aria-label": "Dismiss notification", onClick: () => onDismiss(toast.id), children: _jsx(X, { size: 15 }) })] }));
17
+ return (_jsxs("li", { className: ccx("toast", `toast-${toast.tone ?? "neutral"}`), onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), children: [_jsx("span", { className: ccx("toast-bar"), "aria-hidden": "true" }), _jsxs("div", { className: ccx("toast-content"), children: [_jsx("strong", { children: toast.title }), toast.message && _jsx("p", { children: toast.message })] }), _jsx("button", { type: "button", className: ccx("toast-close"), "aria-label": dismissLabel, onClick: () => onDismiss(toast.id), children: _jsx(X, { size: 15 }) })] }));
18
18
  }
19
- export default function ToastProvider({ children }) {
19
+ export default function ToastProvider({ children, label = "Notifications", dismissLabel = "Dismiss notification", }) {
20
20
  const [toasts, setToasts] = useState([]);
21
21
  const nextId = useRef(0);
22
22
  const dismiss = useCallback((id) => setToasts((current) => current.filter((toast) => toast.id !== id)), []);
@@ -26,5 +26,5 @@ export default function ToastProvider({ children }) {
26
26
  { ...toast, id: (nextId.current += 1) },
27
27
  ]),
28
28
  }), []);
29
- return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx("ol", { className: "toast-viewport", "aria-live": "polite", "aria-label": "Notifications", children: toasts.map((toast) => (_jsx(Toast, { toast: toast, onDismiss: dismiss }, toast.id))) })] }));
29
+ return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx("ol", { className: ccx("toast-viewport"), "aria-live": "polite", "aria-label": label, children: toasts.map((toast) => (_jsx(Toast, { toast: toast, onDismiss: dismiss, dismissLabel: dismissLabel }, toast.id))) })] }));
30
30
  }
@@ -1,11 +1,11 @@
1
- /** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
2
1
  import { type ReactElement } from "react";
3
- export type TooltipProps = {
2
+ import type { FloatingOptions } from "../../hooks/useFloatingPanel.js";
3
+ export type TooltipProps = Omit<FloatingOptions, "side"> & {
4
4
  content: string;
5
- /** A single focusable element, e.g. a button. */
5
+ /** One focusable element. Supplemental noninteractive text only. */
6
6
  children: ReactElement<{
7
7
  "aria-describedby"?: string;
8
8
  }>;
9
9
  placement?: "top" | "bottom" | "left" | "right";
10
10
  };
11
- export default function Tooltip({ content, children, placement, }: TooltipProps): import("react").JSX.Element;
11
+ export default function Tooltip({ content, children, placement, align, sideOffset, collisionPadding, portal, container, }: TooltipProps): import("react").JSX.Element;
@@ -1,17 +1,77 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
4
- import { cloneElement, useId, useState } from "react";
5
- import { cx } from "../../lib/classNames.js";
6
- export default function Tooltip({ content, children, placement = "top", }) {
3
+ import { useContext, cloneElement, useMemo, useEffect, useId, useRef, useState, } from "react";
4
+ import { composeRefs } from "../../lib/composeRefs.js";
5
+ import { ccx } from "../../lib/classNames.js";
6
+ import { LayerVisibility } from "../../lib/layers.js";
7
+ import useDismiss from "../../hooks/useLayerDismiss.js";
8
+ import FloatingPortal from "./FloatingPortal.js";
9
+ export default function Tooltip({ content, children, placement = "top", align = "center", sideOffset = 10, collisionPadding = 8, portal = true, container, }) {
7
10
  const id = useId();
8
- const [open, setOpen] = useState(false);
9
- return (_jsxs("span", { className: "tooltip-anchor", onPointerEnter: () => setOpen(true), onPointerLeave: () => setOpen(false), onFocus: () => setOpen(true), onBlur: () => setOpen(false), onKeyDown: (event) => {
10
- if (event.key === "Escape")
11
- setOpen(false);
12
- }, children: [cloneElement(children, {
11
+ const anchor = useRef(null), panel = useRef(null);
12
+ const [anchorElement, setAnchorElement] = useState(null);
13
+ const attachAnchor = useMemo(() => composeRefs(anchor, setAnchorElement), []);
14
+ const [hovered, setHovered] = useState(false), [focused, setFocused] = useState(false);
15
+ const [dismissed, setDismissed] = useState(false);
16
+ const parentVisible = useContext(LayerVisibility);
17
+ const open = (hovered || focused) && !dismissed && parentVisible;
18
+ const timer = useRef(undefined);
19
+ const clear = () => {
20
+ clearTimeout(timer.current);
21
+ timer.current = undefined;
22
+ };
23
+ const enter = () => {
24
+ clear();
25
+ setHovered(true);
26
+ if (!focused && !hovered)
27
+ setDismissed(false);
28
+ };
29
+ const leave = () => {
30
+ clear();
31
+ timer.current = setTimeout(() => {
32
+ setHovered(false);
33
+ }, 160);
34
+ };
35
+ useEffect(() => () => clearTimeout(timer.current), []);
36
+ useEffect(() => {
37
+ if (!hovered)
38
+ return;
39
+ const doc = anchor.current?.ownerDocument;
40
+ const move = (event) => {
41
+ const a = anchor.current?.getBoundingClientRect(), p = panel.current?.getBoundingClientRect();
42
+ if (!a || !p)
43
+ return;
44
+ // Keep the pointer corridor between trigger and hint available, including slow travel.
45
+ const inside = event.clientX >= Math.min(a.left, p.left) - 4 &&
46
+ event.clientX <= Math.max(a.right, p.right) + 4 &&
47
+ event.clientY >= Math.min(a.top, p.top) - 4 &&
48
+ event.clientY <= Math.max(a.bottom, p.bottom) + 4;
49
+ if (inside)
50
+ clear();
51
+ else
52
+ leave();
53
+ };
54
+ doc?.addEventListener("pointermove", move);
55
+ return () => doc?.removeEventListener("pointermove", move);
56
+ }, [hovered]);
57
+ useDismiss(anchor, open, () => setDismissed(true), {
58
+ branches: [panel],
59
+ layerElement: panel,
60
+ });
61
+ return (_jsxs("span", { ref: attachAnchor, className: ccx("tooltip-anchor"), onPointerEnter: enter, onPointerLeave: leave, onFocus: () => {
62
+ setFocused(true);
63
+ if (!hovered)
64
+ setDismissed(false);
65
+ }, onBlur: () => setFocused(false), children: [cloneElement(children, {
13
66
  "aria-describedby": [children.props["aria-describedby"], id]
14
67
  .filter(Boolean)
15
68
  .join(" "),
16
- }), _jsx("span", { id: id, role: "tooltip", className: cx("tooltip mono", `tooltip-${placement}`, open && "is-open"), children: content })] }));
69
+ }), _jsx(FloatingPortal, { anchorElement: anchorElement, anchor: anchor, panel: panel, open: open, options: {
70
+ side: placement,
71
+ align,
72
+ sideOffset,
73
+ collisionPadding,
74
+ portal,
75
+ container,
76
+ }, children: _jsx("span", { ref: panel, id: id, role: "tooltip", className: ccx("tooltip mono", `tooltip-${placement}`, open && "is-open"), onPointerEnter: enter, onPointerLeave: leave, children: content }) })] }));
17
77
  }
@@ -0,0 +1,5 @@
1
+ @import "./scoped/tokens.css";
2
+ @import "./scoped/base.css";
3
+ @import "./scoped/utilities.css";
4
+ @import "./scoped/ui.css";
5
+ @import "./scoped/kit/data-display.css";
@@ -0,0 +1,6 @@
1
+ @import "./scoped/tokens.css";
2
+ @import "./scoped/base.css";
3
+ @import "./scoped/utilities.css";
4
+ @import "./scoped/ui.css";
5
+ @import "./scoped/kit/effects.css";
6
+ @import "./scoped/marquee.css";
@@ -0,0 +1,5 @@
1
+ @import "./scoped/tokens.css";
2
+ @import "./scoped/base.css";
3
+ @import "./scoped/utilities.css";
4
+ @import "./scoped/ui.css";
5
+ @import "./scoped/kit/feedback.css";
@@ -4,8 +4,7 @@
4
4
  font-style: normal;
5
5
  font-display: swap;
6
6
  font-weight: 100 1000;
7
- src: url("./fonts/dm-sans-latin-wght-normal.woff2")
8
- format("woff2-variations");
7
+ src: url("./fonts/dm-sans-latin-wght-normal.woff2") format("woff2-variations");
9
8
  unicode-range:
10
9
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
11
10
  U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
@@ -30,6 +29,13 @@
30
29
  font-style: normal;
31
30
  font-display: swap;
32
31
  font-weight: 400;
33
- src: url("./fonts/dm-mono-latin-400-normal.woff2")
34
- format("woff2");
32
+ src: url("./fonts/dm-mono-latin-400-normal.woff2") format("woff2");
33
+ }
34
+
35
+ /* Explicit branded typography for isolated components; import after scoped styles. */
36
+ :where(:root) {
37
+ --contour-font-sans: "DM Sans Variable", "DM Sans", system-ui, sans-serif;
38
+ --contour-font-display:
39
+ "Space Grotesk Variable", "Space Grotesk", system-ui, sans-serif;
40
+ --contour-font-mono: "DM Mono", ui-monospace, monospace;
35
41
  }
package/dist/fonts.css CHANGED
@@ -1,3 +1,11 @@
1
1
  @import "@fontsource-variable/dm-sans/wght.css";
2
2
  @import "@fontsource-variable/space-grotesk/wght.css";
3
3
  @import "@fontsource/dm-mono/400.css";
4
+
5
+ /* Explicit branded typography for isolated components; import after scoped styles. */
6
+ :where(:root) {
7
+ --contour-font-sans: "DM Sans Variable", "DM Sans", system-ui, sans-serif;
8
+ --contour-font-display:
9
+ "Space Grotesk Variable", "Space Grotesk", system-ui, sans-serif;
10
+ --contour-font-mono: "DM Mono", ui-monospace, monospace;
11
+ }
package/dist/forms.css ADDED
@@ -0,0 +1,5 @@
1
+ @import "./scoped/tokens.css";
2
+ @import "./scoped/base.css";
3
+ @import "./scoped/utilities.css";
4
+ @import "./scoped/ui.css";
5
+ @import "./scoped/kit/forms.css";
@@ -1,3 +1,12 @@
1
- /** Calls onDismiss on Escape or a pointer press outside the container while `active`. */
2
- import { type RefObject } from "react";
3
- export default function useDismiss(container: RefObject<HTMLElement | null>, active: boolean, onDismiss: (reason: "escape" | "outside") => void): void;
1
+ import type { RefObject } from "react";
2
+ import type { DismissReason } from "../lib/layers.js";
3
+ export type DismissOptions = {
4
+ /** Additional owned elements, including portaled content and the trigger. */
5
+ layerElement?: RefObject<HTMLElement | null>;
6
+ branches?: RefObject<HTMLElement | null>[];
7
+ /** Native modal backdrops own outside-click behavior. */
8
+ outside?: boolean;
9
+ };
10
+ /** Topmost layer dismissal in the container's document; caller cancellation is respected. */
11
+ /** Preserves the public void-returning dismissal hook; built-in layers use an internal handle. */
12
+ export default function useDismiss(container: RefObject<HTMLElement | null>, active: boolean, onDismiss: (reason: DismissReason, event: KeyboardEvent | PointerEvent) => void, options?: DismissOptions): void;
@@ -1,23 +1,7 @@
1
1
  "use client";
2
- /** Calls onDismiss on Escape or a pointer press outside the container while `active`. */
3
- import { useEffect } from "react";
4
- export default function useDismiss(container, active, onDismiss) {
5
- useEffect(() => {
6
- if (!active)
7
- return;
8
- const onKeyDown = (event) => {
9
- if (event.key === "Escape")
10
- onDismiss("escape");
11
- };
12
- const onPointerDown = (event) => {
13
- if (!container.current?.contains(event.target))
14
- onDismiss("outside");
15
- };
16
- document.addEventListener("keydown", onKeyDown);
17
- document.addEventListener("pointerdown", onPointerDown);
18
- return () => {
19
- document.removeEventListener("keydown", onKeyDown);
20
- document.removeEventListener("pointerdown", onPointerDown);
21
- };
22
- }, [container, active, onDismiss]);
2
+ import useLayerDismiss from "./useLayerDismiss.js";
3
+ /** Topmost layer dismissal in the container's document; caller cancellation is respected. */
4
+ /** Preserves the public void-returning dismissal hook; built-in layers use an internal handle. */
5
+ export default function useDismiss(container, active, onDismiss, options = {}) {
6
+ useLayerDismiss(container, active, onDismiss, options);
23
7
  }
@@ -0,0 +1,14 @@
1
+ import { type RefObject } from "react";
2
+ export type FloatingOptions = {
3
+ side?: "top" | "bottom" | "left" | "right";
4
+ align?: "start" | "center" | "end";
5
+ sideOffset?: number;
6
+ collisionPadding?: number;
7
+ /** false keeps content inline; default portals into its owner body or active modal with inherited theme variables. */
8
+ portal?: boolean;
9
+ /** Must belong to the trigger's document and be inside the active modal, when applicable. */
10
+ container?: HTMLElement | null;
11
+ };
12
+ export declare function floatingContainer(anchor: HTMLElement, options: FloatingOptions): HTMLElement;
13
+ /** DOM adapter: clean up observers and ignore obsolete asynchronous positioning results. */
14
+ export default function useFloatingPanel(anchor: RefObject<HTMLElement | null>, panel: RefObject<HTMLElement | null>, open: boolean, { side, align, sideOffset, collisionPadding, container, }: FloatingOptions): void;