@abuzareal/contour-ui 0.4.0 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/COMBOBOX.md +87 -0
  3. package/CONTRACTS.md +23 -17
  4. package/DOCS.md +80 -0
  5. package/FOUNDATION.md +35 -0
  6. package/GETTING-STARTED.md +63 -0
  7. package/NEXTJS.md +5 -1
  8. package/OVERLAYS.md +52 -0
  9. package/PUBLIC-API.md +189 -162
  10. package/README.md +44 -28
  11. package/RECIPES.md +43 -0
  12. package/RELEASING.md +2 -2
  13. package/SIZE.md +51 -0
  14. package/STYLING.md +68 -0
  15. package/dist/actions.css +7 -0
  16. package/dist/components/hero/Sculpture.js +2 -1
  17. package/dist/components/three/ObjectStudy.js +2 -1
  18. package/dist/components/ui/Alert.d.ts +2 -1
  19. package/dist/components/ui/Alert.js +3 -3
  20. package/dist/components/ui/Avatar.d.ts +3 -1
  21. package/dist/components/ui/Avatar.js +3 -3
  22. package/dist/components/ui/Badge.js +2 -2
  23. package/dist/components/ui/BrandMark.js +2 -1
  24. package/dist/components/ui/Breadcrumbs.d.ts +2 -1
  25. package/dist/components/ui/Breadcrumbs.js +3 -2
  26. package/dist/components/ui/Button.d.ts +2 -0
  27. package/dist/components/ui/Button.js +3 -2
  28. package/dist/components/ui/Card.js +2 -2
  29. package/dist/components/ui/Checkbox.js +3 -3
  30. package/dist/components/ui/Combobox.d.ts +325 -0
  31. package/dist/components/ui/Combobox.js +120 -0
  32. package/dist/components/ui/Combobox.types.d.ts +46 -0
  33. package/dist/components/ui/Combobox.types.js +1 -0
  34. package/dist/components/ui/CountUp.d.ts +2 -1
  35. package/dist/components/ui/CountUp.js +6 -3
  36. package/dist/components/ui/DataTable.d.ts +1 -0
  37. package/dist/components/ui/DataTable.js +15 -10
  38. package/dist/components/ui/DepthButton.js +2 -2
  39. package/dist/components/ui/Dialog.d.ts +5 -1
  40. package/dist/components/ui/Dialog.js +49 -18
  41. package/dist/components/ui/Disclosure.js +2 -1
  42. package/dist/components/ui/Divider.js +3 -2
  43. package/dist/components/ui/DropdownMenu.d.ts +11 -4
  44. package/dist/components/ui/DropdownMenu.js +109 -66
  45. package/dist/components/ui/EmptyState.js +2 -1
  46. package/dist/components/ui/Field.js +2 -2
  47. package/dist/components/ui/FlipCard.js +2 -2
  48. package/dist/components/ui/FloatingPortal.d.ts +10 -0
  49. package/dist/components/ui/FloatingPortal.js +13 -0
  50. package/dist/components/ui/FloatingScope.d.ts +9 -0
  51. package/dist/components/ui/FloatingScope.js +43 -0
  52. package/dist/components/ui/IconButton.js +2 -2
  53. package/dist/components/ui/Kbd.js +2 -1
  54. package/dist/components/ui/Magnetic.js +2 -1
  55. package/dist/components/ui/Marquee.js +3 -2
  56. package/dist/components/ui/OverlayTrigger.d.ts +11 -0
  57. package/dist/components/ui/OverlayTrigger.js +5 -0
  58. package/dist/components/ui/Pagination.d.ts +5 -1
  59. package/dist/components/ui/Pagination.js +3 -2
  60. package/dist/components/ui/Popover.d.ts +18 -8
  61. package/dist/components/ui/Popover.js +58 -17
  62. package/dist/components/ui/ProgressBar.js +2 -2
  63. package/dist/components/ui/RadioGroup.js +5 -5
  64. package/dist/components/ui/Reveal.d.ts +0 -1
  65. package/dist/components/ui/Reveal.js +4 -3
  66. package/dist/components/ui/RevealHeading.js +2 -1
  67. package/dist/components/ui/ScrollProgress.js +2 -1
  68. package/dist/components/ui/SectionLabel.js +2 -1
  69. package/dist/components/ui/SegmentedControl.js +6 -3
  70. package/dist/components/ui/SelectField.js +3 -2
  71. package/dist/components/ui/Skeleton.js +2 -2
  72. package/dist/components/ui/Slider.js +3 -3
  73. package/dist/components/ui/Spinner.js +2 -2
  74. package/dist/components/ui/Stat.js +2 -1
  75. package/dist/components/ui/Stepper.js +3 -3
  76. package/dist/components/ui/Switch.d.ts +2 -0
  77. package/dist/components/ui/Switch.js +6 -7
  78. package/dist/components/ui/Table.d.ts +20 -0
  79. package/dist/components/ui/Table.js +9 -0
  80. package/dist/components/ui/Tabs.js +7 -5
  81. package/dist/components/ui/Tag.d.ts +2 -1
  82. package/dist/components/ui/Tag.js +4 -3
  83. package/dist/components/ui/TextArea.js +2 -1
  84. package/dist/components/ui/TextField.js +3 -2
  85. package/dist/components/ui/ThemeScope.d.ts +13 -0
  86. package/dist/components/ui/ThemeScope.js +10 -0
  87. package/dist/components/ui/ThemeToggle.d.ts +3 -0
  88. package/dist/components/ui/ThemeToggle.js +4 -3
  89. package/dist/components/ui/TiltCard.js +3 -2
  90. package/dist/components/ui/Timeline.js +2 -2
  91. package/dist/components/ui/ToastProvider.d.ts +5 -2
  92. package/dist/components/ui/ToastProvider.js +5 -5
  93. package/dist/components/ui/Tooltip.d.ts +4 -4
  94. package/dist/components/ui/Tooltip.js +70 -10
  95. package/dist/data-display.css +5 -0
  96. package/dist/effects.css +6 -0
  97. package/dist/feedback.css +5 -0
  98. package/dist/fonts-latin.css +10 -4
  99. package/dist/fonts.css +8 -0
  100. package/dist/forms.css +5 -0
  101. package/dist/hooks/useComboboxAnchor.d.ts +17 -0
  102. package/dist/hooks/useComboboxAnchor.js +61 -0
  103. package/dist/hooks/useDismiss.d.ts +12 -3
  104. package/dist/hooks/useDismiss.js +5 -21
  105. package/dist/hooks/useFloatingPanel.d.ts +14 -0
  106. package/dist/hooks/useFloatingPanel.js +157 -0
  107. package/dist/hooks/useLayerDismiss.d.ts +4 -0
  108. package/dist/hooks/useLayerDismiss.js +48 -0
  109. package/dist/hooks/useMobileMenu.d.ts +0 -1
  110. package/dist/hooks/useMobileMenu.js +11 -16
  111. package/dist/hooks/usePanelFocus.d.ts +10 -0
  112. package/dist/hooks/usePanelFocus.js +60 -0
  113. package/dist/hooks/useTheme.d.ts +1 -0
  114. package/dist/hooks/useTheme.js +18 -5
  115. package/dist/index.d.ts +7 -0
  116. package/dist/index.js +3 -0
  117. package/dist/kit/data-display.css +6 -0
  118. package/dist/kit/forms.css +45 -2
  119. package/dist/kit/navigation.css +4 -0
  120. package/dist/kit/overlays.css +8 -4
  121. package/dist/lib/classNames.d.ts +2 -0
  122. package/dist/lib/classNames.js +16 -1
  123. package/dist/lib/componentClasses.d.ts +1 -0
  124. package/dist/lib/componentClasses.js +2 -0
  125. package/dist/lib/keyboard.d.ts +1 -1
  126. package/dist/lib/keyboard.js +5 -2
  127. package/dist/lib/layers.d.ts +18 -0
  128. package/dist/lib/layers.js +128 -0
  129. package/dist/lib/themeScope.d.ts +5 -0
  130. package/dist/lib/themeScope.js +3 -0
  131. package/dist/minimal-reset.css +6 -0
  132. package/dist/navigation.css +5 -0
  133. package/dist/overlays.css +5 -0
  134. package/dist/scoped/base.css +183 -0
  135. package/dist/scoped/kit/actions.css +209 -0
  136. package/dist/scoped/kit/data-display.css +498 -0
  137. package/dist/scoped/kit/effects.css +140 -0
  138. package/dist/scoped/kit/feedback.css +309 -0
  139. package/dist/scoped/kit/forms.css +404 -0
  140. package/dist/scoped/kit/navigation.css +266 -0
  141. package/dist/scoped/kit/overlays.css +299 -0
  142. package/dist/scoped/marquee.css +58 -0
  143. package/dist/scoped/spinner.css +20 -0
  144. package/dist/scoped/theme-toggle.css +38 -0
  145. package/dist/scoped/tokens.css +119 -0
  146. package/dist/scoped/ui.css +127 -0
  147. package/dist/scoped/utilities.css +17 -0
  148. package/dist/scoped-tokens.css +1 -0
  149. package/dist/scoped.css +12 -0
  150. package/dist/theme-toggle.css +2 -2
  151. package/dist/tokens.json +710 -0
  152. package/examples/optional/LazyScene.tsx +78 -0
  153. package/examples/optional/OptionalSmoothScroll.tsx +25 -0
  154. package/examples/optional/SmoothScrollOwner.tsx +7 -0
  155. package/examples/optional/optional.css +25 -0
  156. package/examples/recipes/AsyncProjectPicker.tsx +82 -0
  157. package/examples/recipes/DeleteProjectConfirmation.tsx +97 -0
  158. package/examples/recipes/InputCompositions.tsx +49 -0
  159. package/examples/recipes/RemoteRecordsTable.tsx +256 -0
  160. package/examples/recipes/useRemoteResource.ts +81 -0
  161. package/examples/reproduction/README.md +9 -0
  162. package/examples/reproduction/index.html +14 -0
  163. package/examples/reproduction/package.json +27 -0
  164. package/examples/reproduction/src/main.tsx +33 -0
  165. package/examples/reproduction/src/style.css +19 -0
  166. package/examples/reproduction/tsconfig.json +18 -0
  167. package/examples/reproduction/vite.config.ts +19 -0
  168. package/examples/starters/next/README.md +18 -0
  169. package/examples/starters/next/app/Demo.tsx +65 -0
  170. package/examples/starters/next/app/layout.tsx +25 -0
  171. package/examples/starters/next/app/next-page/page.tsx +9 -0
  172. package/examples/starters/next/app/page.tsx +11 -0
  173. package/examples/starters/next/app/style.css +19 -0
  174. package/examples/starters/next/next-env.d.ts +2 -0
  175. package/examples/starters/next/next.config.mjs +1 -0
  176. package/examples/starters/next/package.json +27 -0
  177. package/examples/starters/next/proxy.ts +16 -0
  178. package/examples/starters/next/tsconfig.json +30 -0
  179. package/examples/starters/vite/README.md +16 -0
  180. package/examples/starters/vite/index.html +14 -0
  181. package/examples/starters/vite/package.json +27 -0
  182. package/examples/starters/vite/src/main.tsx +81 -0
  183. package/examples/starters/vite/src/style.css +19 -0
  184. package/examples/starters/vite/tsconfig.json +18 -0
  185. package/examples/starters/vite/vite.config.ts +19 -0
  186. package/package.json +41 -5
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { useContext, useState, useLayoutEffect, useRef, } from "react";
3
+ import { LayerParent, registerLayer, refreshLayerStack, } from "../lib/layers.js";
4
+ export default function useLayerDismiss(container, active, onDismiss, options = {}) {
5
+ const [id] = useState(() => Symbol("contour-layer"));
6
+ const parent = useContext(LayerParent);
7
+ const latest = useRef({ onDismiss, options });
8
+ const registration = useRef(undefined);
9
+ // Reconcile each commit so owner-document moves rebind listeners, without reordering on updates.
10
+ useLayoutEffect(() => {
11
+ latest.current = { onDismiss, options };
12
+ const doc = active ? container.current?.ownerDocument : undefined;
13
+ if (registration.current &&
14
+ (registration.current.doc !== doc ||
15
+ registration.current.parent !== parent)) {
16
+ registration.current.dispose();
17
+ registration.current = undefined;
18
+ }
19
+ if (!doc)
20
+ return;
21
+ if (registration.current) {
22
+ refreshLayerStack(doc);
23
+ return;
24
+ }
25
+ registration.current = {
26
+ doc,
27
+ parent,
28
+ dispose: registerLayer(doc, {
29
+ id,
30
+ parent,
31
+ elements: () => [
32
+ container.current,
33
+ ...(latest.current.options.branches ?? []).map((ref) => ref.current),
34
+ ],
35
+ floating: () => latest.current.options.layerElement?.current ?? null,
36
+ dismiss: (reason, event) => latest.current.onDismiss(reason, event),
37
+ get outside() {
38
+ return latest.current.options.outside !== false;
39
+ },
40
+ }),
41
+ };
42
+ });
43
+ useLayoutEffect(() => () => {
44
+ registration.current?.dispose();
45
+ registration.current = undefined;
46
+ }, []);
47
+ return id;
48
+ }
@@ -1,3 +1,2 @@
1
- /** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
2
1
  import { type RefObject } from "react";
3
2
  export default function useMobileMenu(container: RefObject<HTMLElement | null>, toggleButton: RefObject<HTMLButtonElement | null>): readonly [boolean, import("react").Dispatch<import("react").SetStateAction<boolean>>];
@@ -1,35 +1,30 @@
1
1
  "use client";
2
2
  /** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
3
+ import useDismiss from "./useDismiss.js";
3
4
  import { useEffect, useState } from "react";
4
5
  const desktopQuery = "(min-width: 768px)";
5
6
  export default function useMobileMenu(container, toggleButton) {
6
7
  const [open, setOpen] = useState(false);
8
+ useDismiss(container, open, (reason) => {
9
+ setOpen(false);
10
+ if (reason === "escape" && toggleButton.current?.isConnected)
11
+ toggleButton.current.focus();
12
+ }, { branches: [toggleButton] });
7
13
  useEffect(() => {
8
14
  if (!open)
9
15
  return;
10
- const closeOnEscape = (event) => {
11
- if (event.key !== "Escape")
12
- return;
13
- setOpen(false);
14
- toggleButton.current?.focus();
15
- };
16
- const closeOnOutsidePointer = (event) => {
17
- if (!container.current?.contains(event.target))
18
- setOpen(false);
19
- };
20
- const desktop = window.matchMedia(desktopQuery);
16
+ const view = container.current?.ownerDocument.defaultView;
17
+ if (!view)
18
+ return;
19
+ const desktop = view.matchMedia(desktopQuery);
21
20
  const closeOnDesktop = () => {
22
21
  if (desktop.matches)
23
22
  setOpen(false);
24
23
  };
25
- document.addEventListener("keydown", closeOnEscape);
26
- document.addEventListener("pointerdown", closeOnOutsidePointer);
27
24
  desktop.addEventListener("change", closeOnDesktop);
28
25
  return () => {
29
- document.removeEventListener("keydown", closeOnEscape);
30
- document.removeEventListener("pointerdown", closeOnOutsidePointer);
31
26
  desktop.removeEventListener("change", closeOnDesktop);
32
27
  };
33
- }, [open, container, toggleButton]);
28
+ });
34
29
  return [open, setOpen];
35
30
  }
@@ -0,0 +1,10 @@
1
+ import { type RefObject } from "react";
2
+ export type PanelFocusOptions = {
3
+ initialFocus?: RefObject<HTMLElement | null> | false;
4
+ onOpenAutoFocus?: (event: Event) => void;
5
+ onCloseAutoFocus?: (event: Event) => void;
6
+ };
7
+ /** Reconcile actual node/document moves without treating new callback props as a close. */
8
+ export default function usePanelFocus(open: boolean, trigger: RefObject<HTMLElement | null>, panel: RefObject<HTMLElement | null>, reason: RefObject<{
9
+ value: string | undefined;
10
+ }>, options: PanelFocusOptions, anchorElement?: HTMLElement | null, layer?: symbol): void;
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { layerOwnsFocus, isTopLayer } from "../lib/layers.js";
3
+ import { useLayoutEffect, useRef } from "react";
4
+ /** Reconcile actual node/document moves without treating new callback props as a close. */
5
+ export default function usePanelFocus(open, trigger, panel, reason, options, anchorElement, layer) {
6
+ const policy = useRef(options);
7
+ const registration = useRef(undefined);
8
+ useLayoutEffect(() => {
9
+ policy.current = options;
10
+ const node = open && anchorElement ? panel.current : null;
11
+ const old = registration.current;
12
+ if (old && (!node || old.node !== node || old.doc !== node.ownerDocument)) {
13
+ old.dispose(!open);
14
+ registration.current = undefined;
15
+ }
16
+ if (!node || registration.current)
17
+ return;
18
+ const doc = node.ownerDocument, returnTarget = trigger.current, returnReason = reason.current;
19
+ const owns = () => node.contains(doc.activeElement) ||
20
+ (layer !== undefined && layerOwnsFocus(doc, layer));
21
+ let ownedFocus = owns();
22
+ const observeFocus = () => {
23
+ ownedFocus = owns();
24
+ };
25
+ doc.addEventListener("focusin", observeFocus);
26
+ const EventType = doc.defaultView?.Event ?? Event;
27
+ const event = new EventType("contour-open-auto-focus", {
28
+ cancelable: true,
29
+ });
30
+ policy.current.onOpenAutoFocus?.(event);
31
+ if (!event.defaultPrevented &&
32
+ (layer === undefined || isTopLayer(doc, layer)) &&
33
+ policy.current.initialFocus !== false)
34
+ (policy.current.initialFocus?.current ?? node).focus();
35
+ ownedFocus = owns();
36
+ const currentPolicy = policy;
37
+ registration.current = {
38
+ node,
39
+ doc,
40
+ dispose(returnFocus) {
41
+ doc.removeEventListener("focusin", observeFocus);
42
+ if (!returnFocus)
43
+ return;
44
+ const closeEvent = new EventType("contour-close-auto-focus", {
45
+ cancelable: true,
46
+ });
47
+ currentPolicy.current.onCloseAutoFocus?.(closeEvent);
48
+ if (returnReason?.value !== "outside" &&
49
+ !closeEvent.defaultPrevented &&
50
+ returnTarget?.isConnected &&
51
+ (returnReason?.value === "escape" || ownedFocus))
52
+ returnTarget.focus();
53
+ },
54
+ };
55
+ });
56
+ useLayoutEffect(() => () => {
57
+ registration.current?.dispose(true);
58
+ registration.current = undefined;
59
+ }, []);
60
+ }
@@ -2,4 +2,5 @@ export type Theme = "light" | "dark";
2
2
  export default function useTheme(animate?: boolean): {
3
3
  theme: Theme;
4
4
  toggle: (origin: HTMLElement) => void;
5
+ canToggle: boolean;
5
6
  };
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  /** Light/dark theme state: persisted choice, system fallback, and the animated switch. */
3
- import { useCallback, useEffect, useSyncExternalStore } from "react";
3
+ import { useCallback, useContext, useEffect, useSyncExternalStore, } from "react";
4
+ import { ThemeScopeContext } from "../lib/themeScope.js";
4
5
  import { flushSync } from "react-dom";
5
6
  import { readStorage, storageKeys, writeStorage } from "../lib/storage.js";
6
7
  import { themeColors } from "../lib/themeBootstrap.js";
@@ -63,8 +64,12 @@ function subscribe(onChange) {
63
64
  };
64
65
  }
65
66
  export default function useTheme(animate = true) {
66
- const theme = useSyncExternalStore(subscribe, readTheme, () => "light");
67
+ const scope = useContext(ThemeScopeContext);
68
+ const scopedSubscribe = useCallback((listener) => (scope ? () => { } : subscribe(listener)), [scope]);
69
+ const theme = useSyncExternalStore(scopedSubscribe, readTheme, () => "light");
67
70
  useEffect(() => {
71
+ if (scope)
72
+ return;
68
73
  const media = window.matchMedia("(prefers-color-scheme: dark)");
69
74
  const followSystem = () => {
70
75
  if (readStorage("local", storageKeys.theme) !== null)
@@ -74,8 +79,12 @@ export default function useTheme(animate = true) {
74
79
  };
75
80
  media.addEventListener("change", followSystem);
76
81
  return () => media.removeEventListener("change", followSystem);
77
- }, []);
82
+ }, [scope]);
78
83
  const toggle = useCallback((origin) => {
84
+ if (scope) {
85
+ scope.onThemeChange?.(scope.theme === "dark" ? "light" : "dark");
86
+ return;
87
+ }
79
88
  const next = readTheme() === "dark" ? "light" : "dark";
80
89
  writeStorage("local", storageKeys.theme, next);
81
90
  const commit = () => {
@@ -94,6 +103,10 @@ export default function useTheme(animate = true) {
94
103
  return;
95
104
  }
96
105
  animateReveal(document.startViewTransition(commit), origin);
97
- }, [animate]);
98
- return { theme, toggle };
106
+ }, [animate, scope]);
107
+ return {
108
+ theme: scope?.theme ?? theme,
109
+ toggle,
110
+ canToggle: scope ? !!scope.onThemeChange : true,
111
+ };
99
112
  }
package/dist/index.d.ts CHANGED
@@ -127,3 +127,10 @@ export * from "./lib/motion.js";
127
127
  export type { Section } from "./types.js";
128
128
  export { default as SceneBoundary } from "./components/hero/SceneBoundary.js";
129
129
  export { safeHref } from "./lib/safeHref.js";
130
+ export { default as ThemeScope } from "./components/ui/ThemeScope.js";
131
+ export type * from "./components/ui/ThemeScope.js";
132
+ export type { FloatingOptions } from "./hooks/useFloatingPanel.js";
133
+ export type { PanelFocusOptions } from "./hooks/usePanelFocus.js";
134
+ export type { DismissReason } from "./lib/layers.js";
135
+ export { default as Table, type TableProps } from "./components/ui/Table.js";
136
+ export { default as Combobox, type ComboboxProps, type ComboboxOption, } from "./components/ui/Combobox.js";
package/dist/index.js CHANGED
@@ -66,3 +66,6 @@ export * from "./lib/a11y.js";
66
66
  export * from "./lib/motion.js";
67
67
  export { default as SceneBoundary } from "./components/hero/SceneBoundary.js";
68
68
  export { safeHref } from "./lib/safeHref.js";
69
+ export { default as ThemeScope } from "./components/ui/ThemeScope.js";
70
+ export { default as Table } from "./components/ui/Table.js";
71
+ export { default as Combobox, } from "./components/ui/Combobox.js";
@@ -277,12 +277,18 @@
277
277
  }
278
278
 
279
279
  .data-table {
280
+ color: var(--fg);
280
281
  width: 100%;
281
282
  min-width: 560px;
282
283
  border-collapse: collapse;
283
284
  font-size: 14px;
284
285
  }
285
286
 
287
+ .data-table caption {
288
+ color: inherit;
289
+ text-align: left;
290
+ }
291
+
286
292
  .data-table th {
287
293
  padding: 14px 16px 14px 0;
288
294
  border-bottom: 1px solid var(--line);
@@ -272,7 +272,7 @@ textarea {
272
272
  .switch-thumb {
273
273
  position: absolute;
274
274
  top: 3px;
275
- left: 3px;
275
+ inset-inline-start: 3px;
276
276
  width: 16px;
277
277
  height: 16px;
278
278
  border-radius: 50%;
@@ -287,7 +287,7 @@ textarea {
287
287
 
288
288
  .switch-track[aria-checked="true"] .switch-thumb {
289
289
  background: var(--ink);
290
- transform: translateX(20px);
290
+ inset-inline-start: 23px;
291
291
  }
292
292
 
293
293
  .switch-track:disabled {
@@ -361,3 +361,46 @@ textarea {
361
361
  outline: 2px solid var(--fg);
362
362
  outline-offset: 6px;
363
363
  }
364
+
365
+ :dir(rtl) .slider input[type="range"]::-webkit-slider-runnable-track,
366
+ :dir(rtl) .slider input[type="range"]::-moz-range-track {
367
+ background: linear-gradient(
368
+ to left,
369
+ var(--fg) var(--progress),
370
+ var(--line-strong) var(--progress)
371
+ );
372
+ }
373
+
374
+ /* Combobox uses the same native field frame; the popup is an allowed-value list. */
375
+ .combobox { min-width: 0; }
376
+ .combobox-control { display: flex; align-items: center; gap: 8px; }
377
+ .combobox-control input { min-width: 0; flex: 1; }
378
+ .combobox-trigger, .combobox-retry {
379
+ border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer;
380
+ }
381
+ .combobox-trigger { display: grid; place-items: center; min-width: 36px; min-height: 36px; flex: none; }
382
+ .combobox-trigger:disabled { cursor: default; opacity: .45; }
383
+ .combobox-positioner { z-index: 30; }
384
+ .combobox-popup {
385
+ width: var(--anchor-width, 16rem);
386
+ max-width: var(--available-width, calc(100vw - 16px));
387
+ max-height: min(320px, var(--available-height, 320px));
388
+ overflow: auto; overscroll-behavior: contain;
389
+ border: 1px solid var(--line); border-radius: var(--radius-sm);
390
+ background: var(--bg); color: var(--fg); padding: 4px;
391
+ box-shadow: 0 8px 28px rgb(0 0 0 / .12);
392
+ }
393
+ .combobox-list { outline: none; }
394
+ .combobox-option { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; overflow-wrap: anywhere; cursor: default; }
395
+ .combobox-option[data-highlighted] { background: var(--invert-bg); color: var(--invert-fg); outline: 2px solid currentColor; outline-offset: -2px; }
396
+ .combobox-option[data-disabled] { opacity: .45; }
397
+ .combobox-status { padding: 12px; font-size: 14px; }
398
+ .combobox-retry { padding: 10px 12px; text-decoration: underline; }
399
+ .combobox-trigger:focus-visible, .combobox-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
400
+ @media (forced-colors: active) {
401
+ .combobox-popup { border-color: CanvasText; background: Canvas; color: CanvasText; }
402
+ .combobox-option[data-highlighted] { background: Highlight; color: HighlightText; }
403
+ .combobox-option[data-disabled] { color: GrayText; opacity: 1; }
404
+ }
405
+ /* Keep the live region mounted without a blank row when there is no message. */
406
+ .combobox-status:empty { padding: 0; }
@@ -260,3 +260,7 @@
260
260
  min-width: 14px;
261
261
  }
262
262
  }
263
+
264
+ :dir(rtl) .pagination-step svg {
265
+ transform: scaleX(-1);
266
+ }
@@ -10,10 +10,12 @@
10
10
  z-index: 40;
11
11
  width: max-content;
12
12
  max-width: 220px;
13
+ overflow: auto;
14
+ overflow-wrap: anywhere;
13
15
  padding: 6px 10px;
14
- background: var(--ink);
15
- color: var(--paper);
16
- pointer-events: none;
16
+ background: var(--invert-bg);
17
+ color: var(--invert-fg);
18
+ pointer-events: auto;
17
19
  opacity: 0;
18
20
  visibility: hidden;
19
21
  transition:
@@ -230,7 +232,9 @@
230
232
  position: absolute;
231
233
  top: calc(100% + 8px);
232
234
  z-index: 30;
233
- min-width: 220px;
235
+ min-width: min(220px, calc(100vw - 16px));
236
+ overflow: auto;
237
+ overflow-wrap: anywhere;
234
238
  padding: 6px;
235
239
  border: 1px solid var(--line);
236
240
  background: var(--surface);
@@ -1,5 +1,7 @@
1
1
  /** Builds a className string from optional parts, skipping falsy values. */
2
2
  export declare const cx: (...parts: (string | false | null | undefined)[]) => string;
3
+ /** Prefixed hooks alongside compatibility classes; never use this for IDs or ARIA references. */
4
+ export declare const ccx: (...parts: (string | false | null | undefined)[]) => string;
3
5
  export type ButtonVariant = "primary" | "accent" | "secondary" | "ghost" | "danger";
4
6
  export type ButtonSize = "sm" | "md" | "lg";
5
7
  /** Button classes, shared so anchors can look like buttons without duplicating styles. */
@@ -1,4 +1,19 @@
1
+ import { componentClasses } from "./componentClasses.js";
1
2
  /** Builds a className string from optional parts, skipping falsy values. */
2
3
  export const cx = (...parts) => parts.filter(Boolean).join(" ");
4
+ /** Prefixed hooks alongside compatibility classes; never use this for IDs or ARIA references. */
5
+ export const ccx = (...parts) => {
6
+ const names = cx(...parts)
7
+ .split(/\s+/)
8
+ .filter(Boolean);
9
+ return [
10
+ ...new Set([
11
+ ...names,
12
+ ...names
13
+ .filter((name) => componentClasses.has(name))
14
+ .map((name) => `contour-${name}`),
15
+ ]),
16
+ ].join(" ");
17
+ };
3
18
  /** Button classes, shared so anchors can look like buttons without duplicating styles. */
4
- export const buttonClassName = (variant = "primary", size = "md", className) => cx("button", `button-${variant}`, `button-${size}`, className);
19
+ export const buttonClassName = (variant = "primary", size = "md", className) => ccx("button", `button-${variant}`, `button-${size}`, className);
@@ -0,0 +1 @@
1
+ export declare const componentClasses: Set<string>;
@@ -0,0 +1,2 @@
1
+ // Generated compatibility aliases; unknown consumer classes pass through unchanged.
2
+ export const componentClasses = new Set(["alert", "alert-accent", "alert-action", "alert-banner", "alert-body", "alert-content", "alert-danger", "alert-dismiss", "alert-icon", "alert-info", "alert-success", "alert-title", "alert-warning", "avatar", "avatar-group", "avatar-initials", "avatar-lg", "avatar-sm", "avatar-status", "avatar-status-away", "avatar-status-online", "avatar-xl", "badge", "badge-danger", "badge-dot", "badge-info", "badge-invert", "badge-neutral", "badge-success", "badge-warning", "brand", "breadcrumbs", "button", "button-accent", "button-danger", "button-ghost", "button-icon", "button-lg", "button-secondary", "button-sm", "card", "card-acid", "card-body", "card-footer", "card-icon", "card-ink", "card-interactive", "card-title", "card-top", "choice", "choice-box", "choice-control", "choice-description", "choice-error", "choice-input", "choice-invalid", "choice-label", "choice-radio", "choice-text", "combobox", "combobox-control", "combobox-list", "combobox-option", "combobox-popup", "combobox-positioner", "combobox-retry", "combobox-status", "combobox-trigger", "count-up", "css", "data-table", "data-table-idle", "depth-button", "depth-button-face", "dialog", "dialog-body", "dialog-close", "dialog-description", "dialog-footer", "dialog-header", "dialog-left", "dialog-panel", "dialog-right", "disclosure", "disclosure-icon", "divider", "divider-labelled", "empty-state", "empty-state-icon", "field", "field-chevron", "field-control", "field-counter", "field-disabled", "field-error", "field-footer", "field-icon", "field-invalid", "field-label", "field-required", "field-select", "field-trailing", "flip-back", "flip-card", "flip-face", "flip-front", "flip-stage", "flip-toggle", "hero-letter", "icon-button", "icon-button-accent", "icon-button-lg", "icon-button-sm", "icon-button-solid", "icon-button-spin", "inline-link", "is-flipped", "is-open", "is-visible", "kbd", "magnetic", "marquee", "marquee-inner", "marquee-track", "menu", "menu-chevron", "menu-end", "menu-item", "menu-item-danger", "menu-list", "menu-start", "menu-trigger", "mono", "opening-sequence", "page-width", "pagination", "pagination-gap", "pagination-page", "pagination-step", "popover", "popover-end", "popover-panel", "popover-start", "popover-title", "progress", "progress-accent", "progress-danger", "progress-fill", "progress-header", "progress-indeterminate", "progress-success", "progress-track", "radio-group", "radio-group-inline", "reveal", "scroll-progress", "sculpture-canvas", "section-label", "segmented", "segmented-option", "site-shell", "skeleton", "skeleton-block", "skeleton-circle", "skeleton-line", "skip-link", "slider", "slider-header", "spinner", "stage-canvas", "stat", "stat-suffix", "stepper", "stepper-complete", "stepper-current", "stepper-description", "stepper-label", "stepper-marker", "stepper-step", "stepper-text", "stepper-upcoming", "switch", "switch-thumb", "switch-track", "table-scroll", "tabs", "tabs-list", "tabs-panel", "tabs-tab", "tag", "tag-removable", "tag-remove", "text-link", "theme-fading", "theme-icon-moon", "theme-icon-sun", "theme-toggle", "tilt-card", "tilt-glare", "tilt-stage", "timeline", "timeline-body", "timeline-current", "timeline-date", "timeline-entry", "timeline-title", "toast", "toast-bar", "toast-close", "toast-content", "toast-danger", "toast-info", "toast-success", "toast-viewport", "toast-warning", "tooltip", "tooltip-anchor", "tooltip-bottom", "tooltip-left", "tooltip-right", "tooltip-top", "ts", "type-line", "type-run", "visually-hidden"]);
@@ -4,5 +4,5 @@ type Orientation = "horizontal" | "vertical";
4
4
  * Returns the index to move to for an arrow, Home, or End key press,
5
5
  * wrapping at both ends, or null when the key is not a navigation key.
6
6
  */
7
- export declare function nextIndexForKey(key: string, current: number, count: number, orientation?: Orientation): number | null;
7
+ export declare function nextIndexForKey(key: string, current: number, count: number, orientation?: Orientation, direction?: "ltr" | "rtl"): number | null;
8
8
  export {};
@@ -6,10 +6,13 @@ const keysFor = (orientation) => orientation === "horizontal"
6
6
  * Returns the index to move to for an arrow, Home, or End key press,
7
7
  * wrapping at both ends, or null when the key is not a navigation key.
8
8
  */
9
- export function nextIndexForKey(key, current, count, orientation = "horizontal") {
9
+ export function nextIndexForKey(key, current, count, orientation = "horizontal", direction = "ltr") {
10
10
  if (count <= 0)
11
11
  return null;
12
- const { previous, next } = keysFor(orientation);
12
+ const keys = keysFor(orientation);
13
+ const { previous, next } = orientation === "horizontal" && direction === "rtl"
14
+ ? { previous: keys.next, next: keys.previous }
15
+ : keys;
13
16
  if (key === next)
14
17
  return (current + 1) % count;
15
18
  if (key === previous)
@@ -0,0 +1,18 @@
1
+ export type DismissReason = "escape" | "outside";
2
+ export declare const LayerVisibility: import("react").Context<boolean>;
3
+ export declare const LayerParent: import("react").Context<symbol | undefined>;
4
+ type Layer = {
5
+ id: symbol;
6
+ parent?: symbol;
7
+ elements: () => (HTMLElement | null)[];
8
+ dismiss: (reason: DismissReason, event: KeyboardEvent | PointerEvent) => void;
9
+ outside: boolean;
10
+ floating?: () => HTMLElement | null;
11
+ };
12
+ export declare function layerOwnsFocus(doc: Document, id: symbol): boolean;
13
+ /** Paint order follows dismissal order, including later-opened siblings and portaled children. */
14
+ export declare function refreshLayerStack(doc: Document): void;
15
+ export declare function isTopLayer(doc: Document, id: symbol): boolean;
16
+ /** One bubble listener per owner document. Never stop propagation or cascade a dismissal. */
17
+ export declare function registerLayer(doc: Document, layer: Layer): () => void;
18
+ export {};
@@ -0,0 +1,128 @@
1
+ import { createContext } from "react";
2
+ export const LayerVisibility = createContext(true);
3
+ export const LayerParent = createContext(undefined);
4
+ const documents = new WeakMap();
5
+ function descendant(layer, ancestor, layers) {
6
+ let parent = layer.parent;
7
+ const visited = new Set();
8
+ while (parent && !visited.has(parent)) {
9
+ if (parent === ancestor.id)
10
+ return true;
11
+ visited.add(parent);
12
+ parent = layers.find((entry) => entry.id === parent)?.parent;
13
+ }
14
+ return false;
15
+ }
16
+ function topLayer(layers) {
17
+ return [...layers]
18
+ .reverse()
19
+ .find((layer) => !layers.some((entry) => entry !== layer &&
20
+ (descendant(entry, layer, layers) ||
21
+ layer
22
+ .elements()
23
+ .some((root) => entry
24
+ .elements()
25
+ .some((child) => root && child && root !== child && root.contains(child))))));
26
+ }
27
+ export function layerOwnsFocus(doc, id) {
28
+ const layers = documents.get(doc)?.layers ?? [];
29
+ const owner = layers.find((layer) => layer.id === id);
30
+ return (!!owner &&
31
+ layers.some((layer) => (layer === owner || descendant(layer, owner, layers)) &&
32
+ layer
33
+ .elements()
34
+ .some((element) => element?.contains(doc.activeElement))));
35
+ }
36
+ /** Paint order follows dismissal order, including later-opened siblings and portaled children. */
37
+ export function refreshLayerStack(doc) {
38
+ const registry = documents.get(doc);
39
+ if (!registry)
40
+ return;
41
+ const remaining = [...registry.layers];
42
+ const ordered = [];
43
+ while (remaining.length) {
44
+ const top = topLayer(remaining);
45
+ if (!top)
46
+ break;
47
+ ordered.unshift(top);
48
+ remaining.splice(remaining.indexOf(top), 1);
49
+ }
50
+ const active = new Set();
51
+ ordered.forEach((layer, index) => {
52
+ const panel = layer.floating?.();
53
+ if (panel) {
54
+ active.add(panel);
55
+ if (!registry.zStyles.has(panel))
56
+ registry.zStyles.set(panel, {
57
+ value: panel.style.getPropertyValue("z-index"),
58
+ priority: panel.style.getPropertyPriority("z-index"),
59
+ });
60
+ const value = `calc(var(--contour-z-overlay, 30) + ${index})`;
61
+ if (panel.style.zIndex !== value)
62
+ panel.style.zIndex = value;
63
+ }
64
+ });
65
+ for (const [element, old] of registry.zStyles)
66
+ if (!active.has(element)) {
67
+ if (old.value)
68
+ element.style.setProperty("z-index", old.value, old.priority);
69
+ else
70
+ element.style.removeProperty("z-index");
71
+ registry.zStyles.delete(element);
72
+ }
73
+ }
74
+ export function isTopLayer(doc, id) {
75
+ return topLayer(documents.get(doc)?.layers ?? [])?.id === id;
76
+ }
77
+ /** One bubble listener per owner document. Never stop propagation or cascade a dismissal. */
78
+ export function registerLayer(doc, layer) {
79
+ let registry = documents.get(doc);
80
+ if (!registry) {
81
+ const layers = [];
82
+ const keydown = (event) => {
83
+ if (event.key !== "Escape" || event.defaultPrevented)
84
+ return;
85
+ const top = topLayer(layers);
86
+ if (!top)
87
+ return;
88
+ top.dismiss("escape", event);
89
+ // Suppress native modal cancellation after dispatching the chosen layer.
90
+ event.preventDefault();
91
+ };
92
+ const pointerdown = (event) => {
93
+ if (event.defaultPrevented || event.button > 0)
94
+ return;
95
+ const top = topLayer(layers);
96
+ if (!top?.outside)
97
+ return;
98
+ const path = event.composedPath();
99
+ if (top.elements().some((element) => element && path.includes(element)))
100
+ return;
101
+ top.dismiss("outside", event);
102
+ };
103
+ doc.addEventListener("keydown", keydown);
104
+ doc.addEventListener("pointerdown", pointerdown);
105
+ registry = {
106
+ layers,
107
+ zStyles: new Map(),
108
+ dispose: () => {
109
+ doc.removeEventListener("keydown", keydown);
110
+ doc.removeEventListener("pointerdown", pointerdown);
111
+ },
112
+ };
113
+ documents.set(doc, registry);
114
+ }
115
+ registry.layers.push(layer);
116
+ refreshLayerStack(doc);
117
+ const owned = registry;
118
+ return () => {
119
+ const index = owned.layers.indexOf(layer);
120
+ if (index !== -1)
121
+ owned.layers.splice(index, 1);
122
+ refreshLayerStack(doc);
123
+ if (owned.layers.length === 0) {
124
+ owned.dispose();
125
+ documents.delete(doc);
126
+ }
127
+ };
128
+ }
@@ -0,0 +1,5 @@
1
+ import type { Theme } from "../hooks/useTheme.js";
2
+ export declare const ThemeScopeContext: import("react").Context<{
3
+ theme: Theme;
4
+ onThemeChange?: (theme: Theme) => void;
5
+ } | null>;
@@ -0,0 +1,3 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ export const ThemeScopeContext = createContext(null);
@@ -0,0 +1,6 @@
1
+ /* Optional global normalization. Page layout, colors and typography belong to the application. */
2
+ *, ::before, ::after { box-sizing: border-box; }
3
+ html { -webkit-text-size-adjust: 100%; }
4
+ body { margin: 0; }
5
+ button, input, select, textarea { font: inherit; }
6
+ [hidden]:where(:not([hidden="until-found"])) { display: none; }
@@ -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/navigation.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/overlays.css";