@mlola-ui/registry 1.0.13 → 1.1.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 (150) hide show
  1. package/components/accordion.json +4 -4
  2. package/components/activity-heatmap.json +2 -2
  3. package/components/alert.json +9 -3
  4. package/components/app-shell.json +9 -3
  5. package/components/avatar.json +2 -2
  6. package/components/badge.json +2 -2
  7. package/components/breadcrumb.json +2 -2
  8. package/components/button.json +12 -3
  9. package/components/calendar.json +2 -2
  10. package/components/card.json +2 -2
  11. package/components/carousel.json +10 -4
  12. package/components/checkbox.json +2 -2
  13. package/components/color-picker.json +3 -3
  14. package/components/combobox.json +3 -3
  15. package/components/command-menu.json +3 -3
  16. package/components/context-menu.json +5 -5
  17. package/components/copy-button.json +10 -2
  18. package/components/date-picker.json +3 -3
  19. package/components/dropdown-menu.json +3 -3
  20. package/components/dropzone.json +1 -1
  21. package/components/empty-state.json +2 -2
  22. package/components/hover-card.json +4 -4
  23. package/components/input.json +2 -2
  24. package/components/kbd.json +1 -1
  25. package/components/modal.json +17 -5
  26. package/components/number-input.json +3 -3
  27. package/components/otp-input.json +2 -2
  28. package/components/pagination.json +2 -2
  29. package/components/password-input.json +2 -2
  30. package/components/popover.json +4 -4
  31. package/components/priority-icon.json +1 -1
  32. package/components/progress.json +8 -2
  33. package/components/radio-group.json +1 -1
  34. package/components/rating.json +1 -1
  35. package/components/resizable.json +1 -1
  36. package/components/segmented-control.json +2 -2
  37. package/components/select.json +8 -6
  38. package/components/sheet.json +17 -5
  39. package/components/skeleton.json +1 -1
  40. package/components/slider.json +6 -4
  41. package/components/spinner.json +1 -1
  42. package/components/status-icon.json +1 -1
  43. package/components/stepper.json +2 -2
  44. package/components/table.json +1 -1
  45. package/components/tabs.json +3 -3
  46. package/components/tag-input.json +3 -3
  47. package/components/textarea.json +8 -2
  48. package/components/time-picker.json +3 -3
  49. package/components/timeline.json +2 -2
  50. package/components/toast.json +2 -2
  51. package/components/toggle.json +1 -1
  52. package/components/tooltip.json +3 -3
  53. package/components/tour.json +11 -5
  54. package/components/virtual-list.json +2 -2
  55. package/generated/catalog.js +117 -46
  56. package/generated/examples.json +29 -17
  57. package/index.json +1 -1
  58. package/package.json +1 -1
  59. package/snapshot/index.json +218 -137
  60. package/snapshot/items/components/accordion.json +4 -4
  61. package/snapshot/items/components/activity-heatmap.json +2 -2
  62. package/snapshot/items/components/alert.json +9 -3
  63. package/snapshot/items/components/app-shell.json +9 -3
  64. package/snapshot/items/components/avatar.json +2 -2
  65. package/snapshot/items/components/badge.json +2 -2
  66. package/snapshot/items/components/breadcrumb.json +2 -2
  67. package/snapshot/items/components/button.json +12 -3
  68. package/snapshot/items/components/calendar.json +2 -2
  69. package/snapshot/items/components/card.json +2 -2
  70. package/snapshot/items/components/carousel.json +10 -4
  71. package/snapshot/items/components/checkbox.json +2 -2
  72. package/snapshot/items/components/color-picker.json +3 -3
  73. package/snapshot/items/components/combobox.json +3 -3
  74. package/snapshot/items/components/command-menu.json +3 -3
  75. package/snapshot/items/components/context-menu.json +5 -5
  76. package/snapshot/items/components/copy-button.json +10 -2
  77. package/snapshot/items/components/date-picker.json +3 -3
  78. package/snapshot/items/components/dropdown-menu.json +3 -3
  79. package/snapshot/items/components/dropzone.json +1 -1
  80. package/snapshot/items/components/empty-state.json +2 -2
  81. package/snapshot/items/components/hover-card.json +4 -4
  82. package/snapshot/items/components/input.json +2 -2
  83. package/snapshot/items/components/kbd.json +1 -1
  84. package/snapshot/items/components/modal.json +17 -5
  85. package/snapshot/items/components/number-input.json +3 -3
  86. package/snapshot/items/components/otp-input.json +2 -2
  87. package/snapshot/items/components/pagination.json +2 -2
  88. package/snapshot/items/components/password-input.json +2 -2
  89. package/snapshot/items/components/popover.json +4 -4
  90. package/snapshot/items/components/priority-icon.json +1 -1
  91. package/snapshot/items/components/progress.json +8 -2
  92. package/snapshot/items/components/radio-group.json +1 -1
  93. package/snapshot/items/components/rating.json +1 -1
  94. package/snapshot/items/components/resizable.json +1 -1
  95. package/snapshot/items/components/segmented-control.json +2 -2
  96. package/snapshot/items/components/select.json +8 -6
  97. package/snapshot/items/components/sheet.json +17 -5
  98. package/snapshot/items/components/skeleton.json +1 -1
  99. package/snapshot/items/components/slider.json +6 -4
  100. package/snapshot/items/components/spinner.json +1 -1
  101. package/snapshot/items/components/status-icon.json +1 -1
  102. package/snapshot/items/components/stepper.json +2 -2
  103. package/snapshot/items/components/table.json +1 -1
  104. package/snapshot/items/components/tabs.json +3 -3
  105. package/snapshot/items/components/tag-input.json +3 -3
  106. package/snapshot/items/components/textarea.json +8 -2
  107. package/snapshot/items/components/time-picker.json +3 -3
  108. package/snapshot/items/components/timeline.json +2 -2
  109. package/snapshot/items/components/toast.json +2 -2
  110. package/snapshot/items/components/toggle.json +1 -1
  111. package/snapshot/items/components/tooltip.json +3 -3
  112. package/snapshot/items/components/tour.json +11 -5
  113. package/snapshot/items/components/virtual-list.json +2 -2
  114. package/snapshot/source/packages/components/_internal/dialog.ts +30 -35
  115. package/snapshot/source/packages/components/_internal/dom.ts +14 -0
  116. package/snapshot/source/packages/components/_internal/floating.ts +13 -16
  117. package/snapshot/source/packages/components/_internal/media.ts +34 -0
  118. package/snapshot/source/packages/components/_internal/react.ts +43 -0
  119. package/snapshot/source/packages/components/accordion/accordion.tsx +11 -7
  120. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +2 -1
  121. package/snapshot/source/packages/components/alert/alert.tsx +2 -1
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +8 -16
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +4 -3
  124. package/snapshot/source/packages/components/button/button.tsx +4 -1
  125. package/snapshot/source/packages/components/card/card.tsx +8 -4
  126. package/snapshot/source/packages/components/carousel/carousel.tsx +2 -9
  127. package/snapshot/source/packages/components/color-picker/color-picker.tsx +15 -12
  128. package/snapshot/source/packages/components/combobox/combobox.tsx +18 -13
  129. package/snapshot/source/packages/components/command-menu/command-menu.tsx +5 -5
  130. package/snapshot/source/packages/components/context-menu/context-menu.tsx +1 -1
  131. package/snapshot/source/packages/components/date-picker/date-picker.tsx +12 -9
  132. package/snapshot/source/packages/components/empty-state/empty-state.tsx +5 -3
  133. package/snapshot/source/packages/components/hover-card/hover-card.tsx +3 -7
  134. package/snapshot/source/packages/components/input/input.tsx +43 -5
  135. package/snapshot/source/packages/components/modal/modal.tsx +21 -9
  136. package/snapshot/source/packages/components/number-input/number-input.tsx +11 -7
  137. package/snapshot/source/packages/components/otp-input/otp-input.tsx +19 -10
  138. package/snapshot/source/packages/components/popover/popover.tsx +2 -1
  139. package/snapshot/source/packages/components/progress/progress.tsx +14 -11
  140. package/snapshot/source/packages/components/select/select.tsx +34 -11
  141. package/snapshot/source/packages/components/sheet/sheet.tsx +22 -11
  142. package/snapshot/source/packages/components/slider/slider.tsx +8 -8
  143. package/snapshot/source/packages/components/tabs/tabs.tsx +8 -7
  144. package/snapshot/source/packages/components/tag-input/tag-input.tsx +11 -7
  145. package/snapshot/source/packages/components/textarea/textarea.tsx +5 -4
  146. package/snapshot/source/packages/components/time-picker/time-picker.tsx +12 -9
  147. package/snapshot/source/packages/components/tooltip/tooltip.tsx +4 -3
  148. package/snapshot/source/packages/components/tour/tour.tsx +6 -8
  149. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +12 -15
  150. package/snapshot/source-manifest.json +36 -34
@@ -1,5 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { lockScroll } from "@mlola-ui/behavior/document";
3
+ import { usePortalNode } from "./floating";
4
+ import { useLatest } from "./react";
3
5
 
4
6
  const FOCUSABLE =
5
7
  'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),iframe,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
@@ -12,9 +14,9 @@ type HiddenSnapshot = {
12
14
 
13
15
  const hiddenElements = new Map<HTMLElement, HiddenSnapshot>();
14
16
 
15
- function hideBackground(portal: HTMLElement) {
17
+ function hideBackground(layer: HTMLElement) {
16
18
  const children = Array.from(document.body.children).filter(
17
- (child): child is HTMLElement => child instanceof HTMLElement && child !== portal
19
+ (child): child is HTMLElement => child instanceof HTMLElement && child !== layer
18
20
  );
19
21
  for (const element of children) {
20
22
  const existing = hiddenElements.get(element);
@@ -62,6 +64,15 @@ function trackPresses() {
62
64
  );
63
65
  }
64
66
 
67
+ /**
68
+ * A modal layer: renders into <body>, hides and freezes everything else,
69
+ * traps focus, closes on Escape, and returns focus on close. Focus moves in
70
+ * the same commit that opens the layer, so it is already inside when the
71
+ * render returns (in a browser and in a test alike).
72
+ *
73
+ * The component renders its layer root with `data-ml-portal` and the panel
74
+ * inside it.
75
+ */
65
76
  export function useDialogLayer({
66
77
  open,
67
78
  onClose,
@@ -73,45 +84,32 @@ export function useDialogLayer({
73
84
  closeOnEscape: boolean;
74
85
  panelRef: React.RefObject<HTMLElement | null>;
75
86
  }) {
76
- const [portal, setPortal] = React.useState<HTMLDivElement | null>(null);
77
- const restoreFocusRef = React.useRef<HTMLElement | null>(null);
78
- const closeRef = React.useRef(onClose);
79
- closeRef.current = onClose;
87
+ const portal = usePortalNode();
88
+ const closeRef = useLatest(onClose);
80
89
 
81
- React.useEffect(() => {
82
- trackPresses();
83
- const node = document.createElement("div");
84
- node.setAttribute("data-ml-portal", "");
85
- document.body.appendChild(node);
86
- setPortal(node);
87
- return () => {
88
- node.remove();
89
- setPortal(null);
90
- };
91
- }, []);
90
+ React.useEffect(trackPresses, []);
92
91
 
93
- React.useEffect(() => {
94
- if (!open || !portal) return;
92
+ React.useLayoutEffect(() => {
93
+ const panel = panelRef.current;
94
+ const layer = panel?.closest<HTMLElement>("[data-ml-portal]");
95
+ if (!open || !panel || !layer) return;
95
96
  const active = document.activeElement;
96
- restoreFocusRef.current =
97
- active instanceof HTMLElement && active !== document.body
97
+ // Something inside that focused itself (autoFocus) is not where focus came from.
98
+ const restoreTarget =
99
+ active instanceof HTMLElement && active !== document.body && !panel.contains(active)
98
100
  ? active
99
101
  : lastPressed?.isConnected && performance.now() - pressedAt < 2000
100
102
  ? lastPressed
101
103
  : null;
102
104
  const releaseScroll = lockScroll();
103
- const restoreBackground = hideBackground(portal);
105
+ const restoreBackground = hideBackground(layer);
104
106
  const focusPanel = () => {
105
- const panel = panelRef.current;
106
- if (!panel) return;
107
107
  const first = panel.querySelector<HTMLElement>(FOCUSABLE);
108
108
  (first ?? panel).focus();
109
109
  };
110
- const frame = requestAnimationFrame(focusPanel);
110
+ if (!panel.contains(document.activeElement)) focusPanel();
111
111
 
112
112
  const onKeyDown = (event: KeyboardEvent) => {
113
- const panel = panelRef.current;
114
- if (!panel) return;
115
113
  if (event.key === "Escape" && closeOnEscape) {
116
114
  event.preventDefault();
117
115
  closeRef.current();
@@ -141,26 +139,23 @@ export function useDialogLayer({
141
139
  };
142
140
 
143
141
  const onFocusIn = (event: FocusEvent) => {
144
- const panel = panelRef.current;
145
- if (!panel || !(event.target instanceof Element) || panel.contains(event.target)) return;
142
+ if (!(event.target instanceof Element) || panel.contains(event.target)) return;
146
143
  // Menus and lists opened from inside the dialog live in their own layer on <body>; focus may go there.
147
- const layer = event.target.closest("[data-ml-portal]");
148
- if (layer && layer !== portal) return;
144
+ const other = event.target.closest("[data-ml-portal]");
145
+ if (other && other !== layer) return;
149
146
  focusPanel();
150
147
  };
151
148
 
152
149
  document.addEventListener("keydown", onKeyDown);
153
150
  document.addEventListener("focusin", onFocusIn);
154
151
  return () => {
155
- cancelAnimationFrame(frame);
156
152
  document.removeEventListener("keydown", onKeyDown);
157
153
  document.removeEventListener("focusin", onFocusIn);
158
154
  releaseScroll();
159
155
  restoreBackground();
160
- const target = restoreFocusRef.current;
161
- if (target?.isConnected) requestAnimationFrame(() => target.focus());
156
+ if (restoreTarget?.isConnected) restoreTarget.focus();
162
157
  };
163
- }, [open, portal, closeOnEscape, panelRef]);
158
+ }, [open, portal, closeOnEscape, panelRef, closeRef]);
164
159
 
165
160
  return portal;
166
161
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * DOM reads that work wherever the component runs, including test
3
+ * environments such as jsdom, which have no `CSS` object.
4
+ */
5
+
6
+ /** Whether the browser supports a CSS declaration; false where it cannot say. */
7
+ export function supportsCss(property: string, value: string) {
8
+ return typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports(property, value);
9
+ }
10
+
11
+ /** A selector for an element whose attribute holds exactly `value`, whatever characters it contains. */
12
+ export function byAttribute(name: string, value: string) {
13
+ return `[${name}="${value.replace(/["\\]/g, "\\$&").replace(/\n/g, "\\a ")}"]`;
14
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { placeFloating, type Align, type Side } from "./anchor";
5
+ import { useIsClient } from "./react";
5
6
 
6
7
  export type { Align, Side };
7
8
 
@@ -24,8 +25,8 @@ export function useFloating(
24
25
  // A layer on <body> takes the theme of the place it opened from, which may be a themed canvas rather than the page.
25
26
  const themed = anchor.current?.closest<HTMLElement>("[data-theme]");
26
27
  const moded = anchor.current?.closest<HTMLElement>("[data-mode]");
27
- if (themed?.dataset.theme) layer.dataset.theme = themed.dataset.theme;
28
- if (moded?.dataset.mode) layer.dataset.mode = moded.dataset.mode;
28
+ if (themed?.dataset.theme) layer.setAttribute("data-theme", themed.dataset.theme);
29
+ if (moded?.dataset.mode) layer.setAttribute("data-mode", moded.dataset.mode);
29
30
  const update = () => {
30
31
  const target = anchor.current;
31
32
  if (!target || !layer.isConnected) return;
@@ -36,9 +37,9 @@ export function useFloating(
36
37
  { width: window.innerWidth, height: window.innerHeight },
37
38
  { side, align, offset },
38
39
  );
39
- layer.style.left = `${place.x}px`;
40
- layer.style.top = `${place.y}px`;
41
- layer.dataset.side = place.side;
40
+ layer.style.setProperty("left", `${place.x}px`);
41
+ layer.style.setProperty("top", `${place.y}px`);
42
+ layer.setAttribute("data-side", place.side);
42
43
  };
43
44
  const schedule = () => {
44
45
  cancelAnimationFrame(frame);
@@ -59,15 +60,11 @@ export function useFloating(
59
60
  }, [anchor, floating, open, side, align, offset]);
60
61
  }
61
62
 
62
- /** A node on <body> for layers that must escape overflow and stacking. */
63
- export function usePortalNode() {
64
- const [node, setNode] = React.useState<HTMLElement | null>(null);
65
- React.useEffect(() => {
66
- const element = document.createElement("div");
67
- element.setAttribute("data-ml-portal", "");
68
- document.body.appendChild(element);
69
- setNode(element);
70
- return () => element.remove();
71
- }, []);
72
- return node;
63
+ /**
64
+ * Where a layer renders to escape overflow and stacking: <body>, from the
65
+ * first client render (null on the server and while hydrating). The layer's
66
+ * root carries `data-ml-portal`, so a dialog lets focus into it.
67
+ */
68
+ export function usePortalNode(): HTMLElement | null {
69
+ return useIsClient() ? document.body : null;
73
70
  }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
6
+
7
+ /**
8
+ * Whether a media query matches now. False wherever there is no
9
+ * `matchMedia` (on the server, and in jsdom and other test environments),
10
+ * so no component crashes where media queries do not exist.
11
+ */
12
+ export function matchesMedia(query: string) {
13
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(query).matches;
14
+ }
15
+
16
+ /** A media query as React state, following changes. False on the server and without `matchMedia`. */
17
+ export function useMediaQuery(query: string) {
18
+ return React.useSyncExternalStore(
19
+ (onChange) => {
20
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => undefined;
21
+ const list = window.matchMedia(query);
22
+ list.addEventListener("change", onChange);
23
+ return () => list.removeEventListener("change", onChange);
24
+ },
25
+ () => matchesMedia(query),
26
+ () => false,
27
+ );
28
+ }
29
+
30
+ /** Whether the visitor asked for less motion, read once (inside an effect or a handler). */
31
+ export const prefersReducedMotion = () => matchesMedia(REDUCED_MOTION);
32
+
33
+ /** Whether the visitor asked for less motion, following changes. */
34
+ export const usePrefersReducedMotion = () => useMediaQuery(REDUCED_MOTION);
@@ -38,3 +38,46 @@ export function useControllableState<T>({
38
38
  );
39
39
  return [current, setValue];
40
40
  }
41
+
42
+ /**
43
+ * The latest value in a ref, for handlers and effects that must not re-run
44
+ * when it changes. Written after render, never during it.
45
+ */
46
+ export function useLatest<T>(value: T) {
47
+ const ref = React.useRef(value);
48
+ React.useLayoutEffect(() => {
49
+ ref.current = value;
50
+ });
51
+ return ref;
52
+ }
53
+
54
+ const noSubscription = () => () => undefined;
55
+
56
+ /** False on the server and while hydrating, true in the browser after that: when it is safe to render into <body>. */
57
+ export function useIsClient() {
58
+ return React.useSyncExternalStore(noSubscription, () => true, () => false);
59
+ }
60
+
61
+ type AriaAttributes = { [key: `aria-${string}`]: unknown };
62
+
63
+ /**
64
+ * Split `aria-*` attributes from the rest, for a component whose root is not
65
+ * the element that carries its role: ARIA goes to the element with the role,
66
+ * everything else (id, data-*, style, handlers) to the root.
67
+ */
68
+ export function splitAria<T extends object>(props: T): [AriaAttributes, Omit<T, `aria-${string}`>] {
69
+ const aria: Record<string, unknown> = {};
70
+ const rest: Record<string, unknown> = {};
71
+ for (const [key, value] of Object.entries(props)) (key.startsWith("aria-") ? aria : rest)[key] = value;
72
+ return [aria as AriaAttributes, rest as Omit<T, `aria-${string}`>];
73
+ }
74
+
75
+ /** The level of a heading a component renders, so it fits the outline of the page around it. */
76
+ export type HeadingLevel = 2 | 3 | 4 | 5 | 6;
77
+
78
+ /** A heading at the level the caller asks for; `h3` unless told otherwise. */
79
+ export const Heading = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement> & { level?: HeadingLevel }>(
80
+ function Heading({ level = 3, ...props }, ref) {
81
+ return React.createElement(`h${level}`, { ...props, ref });
82
+ },
83
+ );
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cx } from "../_internal/react";
4
+ import { cx, Heading, type HeadingLevel } from "../_internal/react";
5
5
  import { rovingIndex } from "@mlola-ui/behavior/logic";
6
6
  import { IconChevronDown } from "@mlola-ui/icons";
7
7
 
@@ -12,10 +12,13 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
12
12
  value?: string | string[];
13
13
  onValueChange?: (value: string | string[] | undefined) => void;
14
14
  collapsible?: boolean;
15
+ /** The level of each item's heading, so the accordion fits the page's outline. */
16
+ headingLevel?: HeadingLevel;
15
17
  }
16
18
  interface AccordionContextValue {
17
19
  openValues: string[];
18
20
  toggle: (value: string) => void;
21
+ headingLevel?: HeadingLevel;
19
22
  }
20
23
  interface ItemContextValue {
21
24
  open: boolean;
@@ -30,7 +33,7 @@ const normalize = (value: string | string[] | undefined) =>
30
33
  value === undefined || value === "" ? [] : Array.isArray(value) ? value : [value];
31
34
 
32
35
  const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
33
- ({ type = "single", defaultValue, value, onValueChange, collapsible = true, className, children, ...props }, ref) => {
36
+ ({ type = "single", defaultValue, value, onValueChange, collapsible = true, headingLevel, className, children, ...props }, ref) => {
34
37
  const controlled = value !== undefined;
35
38
  const [internal, setInternal] = React.useState(() => normalize(defaultValue));
36
39
  const openValues = controlled ? normalize(value) : internal;
@@ -75,7 +78,7 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
75
78
  };
76
79
 
77
80
  return (
78
- <AccordionContext.Provider value={{ openValues, toggle }}>
81
+ <AccordionContext.Provider value={{ openValues, toggle, headingLevel }}>
79
82
  <div ref={setRef} className={cx("ml-accordion", className)} onKeyDown={onKeyDown} {...props}>{children}</div>
80
83
  </AccordionContext.Provider>
81
84
  );
@@ -111,13 +114,14 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
111
114
  );
112
115
  AccordionItem.displayName = "AccordionItem";
113
116
 
114
- interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
117
+ type AccordionTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
115
118
  const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
116
119
  ({ className, children, ...props }, ref) => {
117
120
  const item = React.useContext(ItemContext);
121
+ const headingLevel = React.useContext(AccordionContext)?.headingLevel;
118
122
  if (!item) throw new Error("AccordionTrigger must be used inside AccordionItem");
119
123
  return (
120
- <h3 className="ml-accordion-heading">
124
+ <Heading level={headingLevel} className="ml-accordion-heading">
121
125
  <button
122
126
  ref={ref}
123
127
  id={item.triggerId}
@@ -133,13 +137,13 @@ const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerPro
133
137
  <span className="ml-accordion-trigger-label">{children}</span>
134
138
  <IconChevronDown aria-hidden="true" className="ml-accordion-chevron" />
135
139
  </button>
136
- </h3>
140
+ </Heading>
137
141
  );
138
142
  }
139
143
  );
140
144
  AccordionTrigger.displayName = "AccordionTrigger";
141
145
 
142
- interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
146
+ type AccordionContentProps = React.HTMLAttributes<HTMLDivElement>;
143
147
  const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
144
148
  ({ className, children, ...props }, ref) => {
145
149
  const item = React.useContext(ItemContext);
@@ -100,7 +100,8 @@ export function ActivityHeatmap({
100
100
  };
101
101
  if (moves[event.key]) {
102
102
  event.preventDefault();
103
- let [nextWeek, nextDay] = moves[event.key];
103
+ const [week, nextDay] = moves[event.key];
104
+ let nextWeek = week;
104
105
  // Home and End land on the nearest day inside the range.
105
106
  if (event.key === "Home") while (calendar.weeks[nextWeek]?.[nextDay]?.outside && nextWeek < lastWeek) nextWeek += 1;
106
107
  if (event.key === "End") while (calendar.weeks[nextWeek]?.[nextDay]?.outside && nextWeek > 0) nextWeek -= 1;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import { prefersReducedMotion } from "../_internal/media";
4
5
  import { cx } from "../_internal/react";
5
6
  import { IconCircleCheck, IconCircleX, IconInfo, IconTriangleAlert, IconX } from "@mlola-ui/icons";
6
7
 
@@ -38,7 +39,7 @@ const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
38
39
  React.useEffect(() => () => window.clearTimeout(timer.current), []);
39
40
  const dismiss = () => {
40
41
  if (!onDismiss || leaving) return;
41
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return onDismiss();
42
+ if (prefersReducedMotion()) return onDismiss();
42
43
  setLeaving(true);
43
44
  timer.current = window.setTimeout(onDismiss, 200);
44
45
  };
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { IconChevronDown, IconMenu } from "@mlola-ui/icons";
5
5
  import { Resizable } from "../resizable/resizable";
6
6
  import { Sheet } from "../sheet/sheet";
7
+ import { useMediaQuery } from "../_internal/media";
7
8
  import { cx } from "../_internal/react";
8
9
 
9
10
  interface ShellContext {
@@ -20,18 +21,6 @@ const Context = React.createContext<ShellContext>({ toggle: () => undefined, fol
20
21
  /** The shell's controls, for a menu button in a page header or a link that should close the drawer. */
21
22
  export const useAppShell = () => React.useContext(Context);
22
23
 
23
- function useNarrow(query: string) {
24
- return React.useSyncExternalStore(
25
- (notify) => {
26
- const list = window.matchMedia(query);
27
- list.addEventListener("change", notify);
28
- return () => list.removeEventListener("change", notify);
29
- },
30
- () => window.matchMedia(query).matches,
31
- () => false,
32
- );
33
- }
34
-
35
24
  export interface AppShellProps {
36
25
  sidebar: React.ReactNode;
37
26
  children: React.ReactNode;
@@ -54,16 +43,19 @@ export interface AppShellProps {
54
43
  * when the sidebar is out of view.
55
44
  */
56
45
  export function AppShell({ sidebar, children, label = "Navigation", defaultWidth = 248, minWidth = 200, maxWidth = 360, storageKey, drawerQuery = "(max-width: 48rem)", className }: AppShellProps) {
57
- const narrow = useNarrow(drawerQuery);
46
+ const narrow = useMediaQuery(drawerQuery);
58
47
  const [folded, setFolded] = React.useState(false);
59
48
  const [drawer, setDrawer] = React.useState(false);
49
+ // Widening the window past the drawer closes it, so it is not open again on the way back.
50
+ const [wasNarrow, setWasNarrow] = React.useState(narrow);
51
+ if (narrow !== wasNarrow) {
52
+ setWasNarrow(narrow);
53
+ if (!narrow) setDrawer(false);
54
+ }
60
55
  const context = React.useMemo<ShellContext>(
61
56
  () => ({ toggle: () => (narrow ? setDrawer(true) : setFolded((value) => !value)), folded, narrow, closeDrawer: () => setDrawer(false) }),
62
57
  [folded, narrow],
63
58
  );
64
- React.useEffect(() => {
65
- if (!narrow) setDrawer(false);
66
- }, [narrow]);
67
59
  return (
68
60
  <Context.Provider value={context}>
69
61
  <div className={cx("ml-app-shell", className)} data-folded={folded || undefined} data-narrow={narrow || undefined}>
@@ -18,8 +18,9 @@ interface AvatarProps extends React.HTMLAttributes<HTMLSpanElement> {
18
18
  }
19
19
  const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
20
20
  ({ src, alt, name, size = "md", status, className, ...props }, ref) => {
21
- const [failed, setFailed] = React.useState(false);
22
- React.useEffect(() => setFailed(false), [src]);
21
+ // The source that failed to load; a new source gets its own chance.
22
+ const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
23
+ const failed = failedSrc === src;
23
24
  const label = alt ?? name ?? "Avatar";
24
25
  // alt="" marks the avatar as decorative, as it is for images: the name is
25
26
  // already on screen beside it and must not be read twice.
@@ -28,7 +29,7 @@ const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
28
29
  <span ref={ref} data-size={size} className={cx("ml-avatar-root", className)} {...props}>
29
30
  <span className="ml-avatar">
30
31
  {src && !failed
31
- ? <img src={src} alt={label} loading="lazy" className="ml-avatar-image" onError={() => setFailed(true)} />
32
+ ? <img src={src} alt={label} loading="lazy" className="ml-avatar-image" onError={() => setFailedSrc(src)} />
32
33
  : <span role={decorative ? undefined : "img"} aria-label={decorative ? undefined : label} aria-hidden={decorative || undefined} className="ml-avatar-fallback">{initials(name)}</span>}
33
34
  </span>
34
35
  {status ? <span role="status" aria-label={`${label} is ${status}`} data-status={status} className="ml-avatar-status" /> : null}
@@ -13,10 +13,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
13
13
  size?: ButtonSize;
14
14
  loading?: boolean;
15
15
  magnetic?: boolean;
16
+ /** "full" fills the container's width, as on a phone form; "auto" (the default) fits the label. */
17
+ width?: "auto" | "full";
16
18
  }
17
19
 
18
20
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
19
- ({ className, variant = "primary", size = "md", loading = false, magnetic = false, disabled, children, type = "button", ...props }, ref) => {
21
+ ({ className, variant = "primary", size = "md", loading = false, magnetic = false, width = "auto", disabled, children, type = "button", ...props }, ref) => {
20
22
  const isDisabled = disabled || loading;
21
23
  const button = (
22
24
  <button
@@ -27,6 +29,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
27
29
  aria-busy={loading || undefined}
28
30
  data-variant={variant}
29
31
  data-size={size}
32
+ data-width={width === "full" ? "full" : undefined}
30
33
  data-loading={loading ? "" : undefined}
31
34
  className={cx("ml-button", className)}
32
35
  {...props}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cx } from "../_internal/react";
4
+ import { cx, Heading, type HeadingLevel } from "../_internal/react";
5
5
 
6
6
  export type CardVariant = "default" | "elevated" | "glass" | "specular";
7
7
  type CardProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -19,9 +19,13 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
19
19
  ({ className, ...props }, ref) => <div ref={ref} className={cx("ml-card-header", className)} {...props} />
20
20
  );
21
21
  CardHeader.displayName = "CardHeader";
22
- const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
23
- ({ className, ...props }, ref) => <h3 ref={ref} className={cx("ml-card-title", className)} {...props} />
24
- );
22
+ interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
23
+ /** The level of the heading, so the card fits the page's outline. */
24
+ headingLevel?: HeadingLevel;
25
+ }
26
+ const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(({ className, headingLevel, ...props }, ref) => {
27
+ return <Heading ref={ref} level={headingLevel} className={cx("ml-card-title", className)} {...props} />;
28
+ });
25
29
  CardTitle.displayName = "CardTitle";
26
30
  const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
27
31
  ({ className, ...props }, ref) => <p ref={ref} className={cx("ml-card-description", className)} {...props} />
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { rovingIndex } from "@mlola-ui/behavior/logic";
5
5
  import { IconChevronLeft, IconChevronRight } from "@mlola-ui/icons";
6
+ import { usePrefersReducedMotion } from "../_internal/media";
6
7
  import { cx, useControllableState } from "../_internal/react";
7
8
 
8
9
  export interface CarouselProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
@@ -72,7 +73,7 @@ export function Carousel({
72
73
  /** While an arrow or dot scrolls the track, passing positions are not reported. */
73
74
  const heading = React.useRef<{ target: number; until: number } | null>(null);
74
75
  const [paused, setPaused] = React.useState(false);
75
- const [reduced, setReduced] = React.useState(false);
76
+ const reduced = usePrefersReducedMotion();
76
77
 
77
78
  const labelFor = (position: number) => itemLabels?.[position] ?? `Slide ${position + 1}`;
78
79
 
@@ -132,14 +133,6 @@ export function Carousel({
132
133
  return () => observer.disconnect();
133
134
  }, [count, view, minSlideWidth]);
134
135
 
135
- React.useEffect(() => {
136
- const query = window.matchMedia("(prefers-reduced-motion: reduce)");
137
- const update = () => setReduced(query.matches);
138
- update();
139
- query.addEventListener("change", update);
140
- return () => query.removeEventListener("change", update);
141
- }, []);
142
-
143
136
  // Autoplay: one timer per slide, stopped while paused or hidden.
144
137
  React.useEffect(() => {
145
138
  if (!autoplay || paused || reduced || positions < 2) return;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { IconPen } from "@mlola-ui/icons";
5
- import { Field, fieldDescription } from "../input/input";
5
+ import { Field, fieldDescription, type FormControlProps } from "../input/input";
6
6
  import { Popover } from "../popover/popover";
7
7
  import { cx } from "../_internal/react";
8
8
  import { contrastRatio, formatColor, hsvToRgb, parseColor, rgbToHsv, type ColorFormat, type HSVA, type RGBA } from "./color";
@@ -10,7 +10,7 @@ import { contrastRatio, formatColor, hsvToRgb, parseColor, rgbToHsv, type ColorF
10
10
  export type { ColorFormat, RGBA };
11
11
  export { contrastRatio, formatColor, parseColor } from "./color";
12
12
 
13
- export interface ColorPickerProps {
13
+ export interface ColorPickerProps extends Omit<FormControlProps, "required"> {
14
14
  /** Any color the picker reads: hex, rgb() or oklch(). */
15
15
  value?: string;
16
16
  defaultValue?: string;
@@ -25,8 +25,6 @@ export interface ColorPickerProps {
25
25
  label?: React.ReactNode;
26
26
  hint?: React.ReactNode;
27
27
  error?: React.ReactNode;
28
- disabled?: boolean;
29
- id?: string;
30
28
  className?: string;
31
29
  }
32
30
 
@@ -88,7 +86,7 @@ function Track({ label, value, max, onChange, background, valueText, className }
88
86
  * hex, rgb or oklch, the system eyedropper where the browser has one, preset
89
87
  * swatches, and the color's contrast against white and black.
90
88
  */
91
- export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>(function ColorPicker({ value, defaultValue = "#0a84ff", onValueChange, format = "hex", alpha = false, swatches, label, hint, error, disabled, id, className }: ColorPickerProps, ref) {
89
+ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>(function ColorPicker({ value, defaultValue = "#0a84ff", onValueChange, format = "hex", alpha = false, swatches, label, hint, error, disabled, id, name, form, "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": describedBy, className }: ColorPickerProps, ref) {
92
90
  const autoId = React.useId();
93
91
  const fieldId = id ?? autoId;
94
92
  const [inner, setInner] = React.useState(defaultValue);
@@ -102,10 +100,11 @@ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>
102
100
  const shownHex = formatColor(rgba, "hex");
103
101
 
104
102
  // Follow a value set from outside, unless it is the color already shown.
105
- React.useEffect(() => {
103
+ const [followed, setFollowed] = React.useState(shownHex);
104
+ if (followed !== shownHex) {
105
+ setFollowed(shownHex);
106
106
  if (formatColor(hsvToRgb(hsv), "hex") !== shownHex) setHsv(rgbToHsv(rgba));
107
- // eslint-disable-next-line react-hooks/exhaustive-deps
108
- }, [shownHex]);
107
+ }
109
108
 
110
109
  const emit = (next: HSVA) => {
111
110
  setHsv(next);
@@ -132,9 +131,10 @@ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>
132
131
  type="button"
133
132
  className="ml-input ml-color-picker-trigger"
134
133
  disabled={disabled}
135
- aria-invalid={error ? true : undefined}
136
- aria-describedby={fieldDescription(fieldId, { hint, error })}
137
- aria-label={`${typeof label === "string" ? label : "Color"}: ${formatColor(rgba, format)}`}
134
+ data-invalid={error ? "" : undefined}
135
+ aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
136
+ aria-labelledby={labelledBy}
137
+ aria-label={labelledBy ? undefined : `${ariaLabel ?? (typeof label === "string" ? label : "Color")}: ${formatColor(rgba, format)}`}
138
138
  >
139
139
  <span className="ml-color-picker-swatch" style={{ "--ml-color": formatColor(rgba, "rgb") } as React.CSSProperties} aria-hidden="true" />
140
140
  <span className="ml-color-picker-value">{formatColor(rgba, format)}</span>
@@ -142,7 +142,7 @@ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>
142
142
  );
143
143
 
144
144
  return (
145
- <Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} className={cx("ml-color-picker", className)}>
145
+ <Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} name={name} form={form} value={formatColor(rgba, format)} className={cx("ml-color-picker", className)}>
146
146
  <Popover trigger={trigger} label={typeof label === "string" ? `Choose ${label.toLowerCase()}` : "Choose a color"} open={open} onOpenChange={setOpen} className="ml-color-picker-popover">
147
147
  <div className="ml-color-picker-panel">
148
148
  <div
@@ -150,6 +150,9 @@ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>
150
150
  role="slider"
151
151
  tabIndex={0}
152
152
  aria-label="Saturation and brightness"
153
+ aria-valuemin={0}
154
+ aria-valuemax={100}
155
+ aria-valuenow={Math.round(hsv.s * 100)}
153
156
  aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
154
157
  style={{ "--ml-color-hue": `hsl(${hsv.h} 100% 50%)`, "--ml-color-x": `${hsv.s * 100}%`, "--ml-color-y": `${(1 - hsv.v) * 100}%` } as React.CSSProperties}
155
158
  onKeyDown={(event) => {