@mlola-ui/registry 1.0.13 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/accordion.json +4 -4
- package/components/activity-heatmap.json +2 -2
- package/components/alert.json +9 -3
- package/components/app-shell.json +9 -3
- package/components/avatar.json +2 -2
- package/components/badge.json +2 -2
- package/components/breadcrumb.json +2 -2
- package/components/button.json +12 -3
- package/components/calendar.json +2 -2
- package/components/card.json +2 -2
- package/components/carousel.json +10 -4
- package/components/checkbox.json +2 -2
- package/components/color-picker.json +3 -3
- package/components/combobox.json +3 -3
- package/components/command-menu.json +3 -3
- package/components/context-menu.json +5 -5
- package/components/copy-button.json +10 -2
- package/components/date-picker.json +3 -3
- package/components/dropdown-menu.json +3 -3
- package/components/dropzone.json +1 -1
- package/components/empty-state.json +2 -2
- package/components/hover-card.json +4 -4
- package/components/input.json +2 -2
- package/components/kbd.json +1 -1
- package/components/modal.json +17 -5
- package/components/number-input.json +3 -3
- package/components/otp-input.json +2 -2
- package/components/pagination.json +2 -2
- package/components/password-input.json +2 -2
- package/components/popover.json +4 -4
- package/components/priority-icon.json +1 -1
- package/components/progress.json +8 -2
- package/components/radio-group.json +1 -1
- package/components/rating.json +1 -1
- package/components/resizable.json +1 -1
- package/components/segmented-control.json +2 -2
- package/components/select.json +8 -6
- package/components/sheet.json +17 -5
- package/components/skeleton.json +1 -1
- package/components/slider.json +6 -4
- package/components/spinner.json +1 -1
- package/components/status-icon.json +1 -1
- package/components/stepper.json +2 -2
- package/components/table.json +1 -1
- package/components/tabs.json +3 -3
- package/components/tag-input.json +3 -3
- package/components/textarea.json +8 -2
- package/components/time-picker.json +3 -3
- package/components/timeline.json +2 -2
- package/components/toast.json +2 -2
- package/components/toggle.json +1 -1
- package/components/tooltip.json +3 -3
- package/components/tour.json +11 -5
- package/components/virtual-list.json +2 -2
- package/generated/catalog.js +117 -46
- package/generated/examples.json +29 -17
- package/index.json +1 -1
- package/package.json +1 -1
- package/snapshot/index.json +218 -137
- package/snapshot/items/components/accordion.json +4 -4
- package/snapshot/items/components/activity-heatmap.json +2 -2
- package/snapshot/items/components/alert.json +9 -3
- package/snapshot/items/components/app-shell.json +9 -3
- package/snapshot/items/components/avatar.json +2 -2
- package/snapshot/items/components/badge.json +2 -2
- package/snapshot/items/components/breadcrumb.json +2 -2
- package/snapshot/items/components/button.json +12 -3
- package/snapshot/items/components/calendar.json +2 -2
- package/snapshot/items/components/card.json +2 -2
- package/snapshot/items/components/carousel.json +10 -4
- package/snapshot/items/components/checkbox.json +2 -2
- package/snapshot/items/components/color-picker.json +3 -3
- package/snapshot/items/components/combobox.json +3 -3
- package/snapshot/items/components/command-menu.json +3 -3
- package/snapshot/items/components/context-menu.json +5 -5
- package/snapshot/items/components/copy-button.json +10 -2
- package/snapshot/items/components/date-picker.json +3 -3
- package/snapshot/items/components/dropdown-menu.json +3 -3
- package/snapshot/items/components/dropzone.json +1 -1
- package/snapshot/items/components/empty-state.json +2 -2
- package/snapshot/items/components/hover-card.json +4 -4
- package/snapshot/items/components/input.json +2 -2
- package/snapshot/items/components/kbd.json +1 -1
- package/snapshot/items/components/modal.json +17 -5
- package/snapshot/items/components/number-input.json +3 -3
- package/snapshot/items/components/otp-input.json +2 -2
- package/snapshot/items/components/pagination.json +2 -2
- package/snapshot/items/components/password-input.json +2 -2
- package/snapshot/items/components/popover.json +4 -4
- package/snapshot/items/components/priority-icon.json +1 -1
- package/snapshot/items/components/progress.json +8 -2
- package/snapshot/items/components/radio-group.json +1 -1
- package/snapshot/items/components/rating.json +1 -1
- package/snapshot/items/components/resizable.json +1 -1
- package/snapshot/items/components/segmented-control.json +2 -2
- package/snapshot/items/components/select.json +8 -6
- package/snapshot/items/components/sheet.json +17 -5
- package/snapshot/items/components/skeleton.json +1 -1
- package/snapshot/items/components/slider.json +6 -4
- package/snapshot/items/components/spinner.json +1 -1
- package/snapshot/items/components/status-icon.json +1 -1
- package/snapshot/items/components/stepper.json +2 -2
- package/snapshot/items/components/table.json +1 -1
- package/snapshot/items/components/tabs.json +3 -3
- package/snapshot/items/components/tag-input.json +3 -3
- package/snapshot/items/components/textarea.json +8 -2
- package/snapshot/items/components/time-picker.json +3 -3
- package/snapshot/items/components/timeline.json +2 -2
- package/snapshot/items/components/toast.json +2 -2
- package/snapshot/items/components/toggle.json +1 -1
- package/snapshot/items/components/tooltip.json +3 -3
- package/snapshot/items/components/tour.json +11 -5
- package/snapshot/items/components/virtual-list.json +2 -2
- package/snapshot/source/packages/components/_internal/dialog.ts +30 -35
- package/snapshot/source/packages/components/_internal/dom.ts +14 -0
- package/snapshot/source/packages/components/_internal/floating.ts +13 -16
- package/snapshot/source/packages/components/_internal/media.ts +34 -0
- package/snapshot/source/packages/components/_internal/react.ts +43 -0
- package/snapshot/source/packages/components/accordion/accordion.tsx +11 -7
- package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +2 -1
- package/snapshot/source/packages/components/alert/alert.tsx +2 -1
- package/snapshot/source/packages/components/app-shell/app-shell.tsx +8 -16
- package/snapshot/source/packages/components/avatar/avatar.tsx +4 -3
- package/snapshot/source/packages/components/button/button.tsx +4 -1
- package/snapshot/source/packages/components/card/card.tsx +8 -4
- package/snapshot/source/packages/components/carousel/carousel.tsx +2 -9
- package/snapshot/source/packages/components/color-picker/color-picker.tsx +15 -12
- package/snapshot/source/packages/components/combobox/combobox.tsx +18 -13
- package/snapshot/source/packages/components/command-menu/command-menu.tsx +5 -5
- package/snapshot/source/packages/components/context-menu/context-menu.tsx +1 -1
- package/snapshot/source/packages/components/date-picker/date-picker.tsx +12 -9
- package/snapshot/source/packages/components/empty-state/empty-state.tsx +5 -3
- package/snapshot/source/packages/components/hover-card/hover-card.tsx +3 -7
- package/snapshot/source/packages/components/input/input.tsx +43 -5
- package/snapshot/source/packages/components/modal/modal.tsx +21 -9
- package/snapshot/source/packages/components/number-input/number-input.tsx +11 -7
- package/snapshot/source/packages/components/otp-input/otp-input.tsx +19 -10
- package/snapshot/source/packages/components/popover/popover.tsx +2 -1
- package/snapshot/source/packages/components/progress/progress.tsx +14 -11
- package/snapshot/source/packages/components/select/select.tsx +34 -11
- package/snapshot/source/packages/components/sheet/sheet.tsx +22 -11
- package/snapshot/source/packages/components/slider/slider.tsx +8 -8
- package/snapshot/source/packages/components/tabs/tabs.tsx +8 -7
- package/snapshot/source/packages/components/tag-input/tag-input.tsx +11 -7
- package/snapshot/source/packages/components/textarea/textarea.tsx +5 -4
- package/snapshot/source/packages/components/time-picker/time-picker.tsx +12 -9
- package/snapshot/source/packages/components/tooltip/tooltip.tsx +4 -3
- package/snapshot/source/packages/components/tour/tour.tsx +6 -8
- package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +12 -15
- 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(
|
|
17
|
+
function hideBackground(layer: HTMLElement) {
|
|
16
18
|
const children = Array.from(document.body.children).filter(
|
|
17
|
-
(child): child is HTMLElement => child instanceof HTMLElement && child !==
|
|
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
|
|
77
|
-
const
|
|
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.
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
if (
|
|
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
|
-
|
|
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.
|
|
28
|
-
if (moded?.dataset.mode) layer.
|
|
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
|
|
40
|
-
layer.style.top
|
|
41
|
-
layer.
|
|
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
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
140
|
+
</Heading>
|
|
137
141
|
);
|
|
138
142
|
}
|
|
139
143
|
);
|
|
140
144
|
AccordionTrigger.displayName = "AccordionTrigger";
|
|
141
145
|
|
|
142
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
22
|
-
React.
|
|
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={() =>
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
aria-describedby={fieldDescription(fieldId, { hint, error })}
|
|
137
|
-
aria-
|
|
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) => {
|