@mlola-ui/registry 1.0.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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/components/accordion.json +54 -0
  4. package/components/activity-heatmap.json +54 -0
  5. package/components/alert.json +56 -0
  6. package/components/app-shell.json +53 -0
  7. package/components/avatar.json +54 -0
  8. package/components/badge.json +62 -0
  9. package/components/breadcrumb.json +49 -0
  10. package/components/button.json +62 -0
  11. package/components/calendar.json +55 -0
  12. package/components/card.json +53 -0
  13. package/components/carousel.json +52 -0
  14. package/components/checkbox.json +50 -0
  15. package/components/color-picker.json +60 -0
  16. package/components/combobox.json +64 -0
  17. package/components/command-menu.json +64 -0
  18. package/components/context-menu.json +61 -0
  19. package/components/date-picker.json +59 -0
  20. package/components/dropdown-menu.json +51 -0
  21. package/components/dropzone.json +45 -0
  22. package/components/empty-state.json +45 -0
  23. package/components/hover-card.json +59 -0
  24. package/components/input.json +56 -0
  25. package/components/kbd.json +45 -0
  26. package/components/modal.json +63 -0
  27. package/components/number-input.json +58 -0
  28. package/components/otp-input.json +49 -0
  29. package/components/pagination.json +50 -0
  30. package/components/popover.json +58 -0
  31. package/components/priority-icon.json +53 -0
  32. package/components/progress.json +57 -0
  33. package/components/radio-group.json +48 -0
  34. package/components/rating.json +48 -0
  35. package/components/resizable.json +46 -0
  36. package/components/segmented-control.json +51 -0
  37. package/components/select.json +67 -0
  38. package/components/sheet.json +66 -0
  39. package/components/skeleton.json +48 -0
  40. package/components/slider.json +53 -0
  41. package/components/status-icon.json +53 -0
  42. package/components/stepper.json +50 -0
  43. package/components/table.json +53 -0
  44. package/components/tabs.json +54 -0
  45. package/components/tag-input.json +53 -0
  46. package/components/textarea.json +48 -0
  47. package/components/time-picker.json +58 -0
  48. package/components/timeline.json +54 -0
  49. package/components/toast.json +56 -0
  50. package/components/toggle.json +52 -0
  51. package/components/tooltip.json +54 -0
  52. package/components/tour.json +62 -0
  53. package/components/virtual-list.json +54 -0
  54. package/generated/catalog.ts +2037 -0
  55. package/index.json +413 -0
  56. package/package.json +47 -0
  57. package/schema/mlola.config.schema.json +51 -0
  58. package/schema/registry-index.schema.json +116 -0
  59. package/schema/registry-item.schema.json +80 -0
  60. package/snapshot/index.json +2938 -0
  61. package/snapshot/items/components/accordion.json +54 -0
  62. package/snapshot/items/components/activity-heatmap.json +54 -0
  63. package/snapshot/items/components/alert.json +56 -0
  64. package/snapshot/items/components/app-shell.json +53 -0
  65. package/snapshot/items/components/avatar.json +54 -0
  66. package/snapshot/items/components/badge.json +62 -0
  67. package/snapshot/items/components/breadcrumb.json +49 -0
  68. package/snapshot/items/components/button.json +62 -0
  69. package/snapshot/items/components/calendar.json +55 -0
  70. package/snapshot/items/components/card.json +53 -0
  71. package/snapshot/items/components/carousel.json +52 -0
  72. package/snapshot/items/components/checkbox.json +50 -0
  73. package/snapshot/items/components/color-picker.json +60 -0
  74. package/snapshot/items/components/combobox.json +64 -0
  75. package/snapshot/items/components/command-menu.json +64 -0
  76. package/snapshot/items/components/context-menu.json +61 -0
  77. package/snapshot/items/components/date-picker.json +59 -0
  78. package/snapshot/items/components/dropdown-menu.json +51 -0
  79. package/snapshot/items/components/dropzone.json +45 -0
  80. package/snapshot/items/components/empty-state.json +45 -0
  81. package/snapshot/items/components/hover-card.json +59 -0
  82. package/snapshot/items/components/input.json +56 -0
  83. package/snapshot/items/components/kbd.json +45 -0
  84. package/snapshot/items/components/modal.json +63 -0
  85. package/snapshot/items/components/number-input.json +58 -0
  86. package/snapshot/items/components/otp-input.json +49 -0
  87. package/snapshot/items/components/pagination.json +50 -0
  88. package/snapshot/items/components/popover.json +58 -0
  89. package/snapshot/items/components/priority-icon.json +53 -0
  90. package/snapshot/items/components/progress.json +57 -0
  91. package/snapshot/items/components/radio-group.json +48 -0
  92. package/snapshot/items/components/rating.json +48 -0
  93. package/snapshot/items/components/resizable.json +46 -0
  94. package/snapshot/items/components/segmented-control.json +51 -0
  95. package/snapshot/items/components/select.json +67 -0
  96. package/snapshot/items/components/sheet.json +66 -0
  97. package/snapshot/items/components/skeleton.json +48 -0
  98. package/snapshot/items/components/slider.json +53 -0
  99. package/snapshot/items/components/status-icon.json +53 -0
  100. package/snapshot/items/components/stepper.json +50 -0
  101. package/snapshot/items/components/table.json +53 -0
  102. package/snapshot/items/components/tabs.json +54 -0
  103. package/snapshot/items/components/tag-input.json +53 -0
  104. package/snapshot/items/components/textarea.json +48 -0
  105. package/snapshot/items/components/time-picker.json +58 -0
  106. package/snapshot/items/components/timeline.json +54 -0
  107. package/snapshot/items/components/toast.json +56 -0
  108. package/snapshot/items/components/toggle.json +52 -0
  109. package/snapshot/items/components/tooltip.json +54 -0
  110. package/snapshot/items/components/tour.json +62 -0
  111. package/snapshot/items/components/virtual-list.json +54 -0
  112. package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
  113. package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
  114. package/snapshot/source/packages/components/_internal/floating.ts +73 -0
  115. package/snapshot/source/packages/components/_internal/match.ts +39 -0
  116. package/snapshot/source/packages/components/_internal/react.ts +40 -0
  117. package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
  118. package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
  119. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  120. package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
  121. package/snapshot/source/packages/components/alert/alert.tsx +83 -0
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
  124. package/snapshot/source/packages/components/badge/badge.tsx +39 -0
  125. package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  126. package/snapshot/source/packages/components/button/button.tsx +42 -0
  127. package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
  128. package/snapshot/source/packages/components/calendar/dates.ts +163 -0
  129. package/snapshot/source/packages/components/card/card.tsx +39 -0
  130. package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
  131. package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
  132. package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
  133. package/snapshot/source/packages/components/color-picker/color.ts +124 -0
  134. package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
  135. package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
  136. package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
  137. package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
  138. package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  139. package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
  140. package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
  141. package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
  142. package/snapshot/source/packages/components/input/input.tsx +101 -0
  143. package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
  144. package/snapshot/source/packages/components/modal/modal.tsx +100 -0
  145. package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
  146. package/snapshot/source/packages/components/number-input/number.ts +50 -0
  147. package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
  148. package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
  149. package/snapshot/source/packages/components/popover/popover.tsx +107 -0
  150. package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
  151. package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  152. package/snapshot/source/packages/components/progress/progress.tsx +110 -0
  153. package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
  154. package/snapshot/source/packages/components/rating/rating.tsx +94 -0
  155. package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
  156. package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  157. package/snapshot/source/packages/components/select/select.tsx +332 -0
  158. package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
  159. package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
  160. package/snapshot/source/packages/components/slider/slider.tsx +99 -0
  161. package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
  162. package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
  163. package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
  164. package/snapshot/source/packages/components/table/table.tsx +66 -0
  165. package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
  166. package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
  167. package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
  168. package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
  169. package/snapshot/source/packages/components/time-picker/times.ts +76 -0
  170. package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
  171. package/snapshot/source/packages/components/toast/toast.tsx +317 -0
  172. package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
  173. package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
  174. package/snapshot/source/packages/components/tour/tour.tsx +193 -0
  175. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  176. package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
  177. package/snapshot/source-manifest.json +67 -0
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "../schema/registry-item.schema.json",
3
+ "schemaVersion": 2,
4
+ "name": "tooltip",
5
+ "type": "registry:ui",
6
+ "title": "Tooltip",
7
+ "description": "Hover and focus tooltip with four placements, delay, and arrow",
8
+ "category": "overlays",
9
+ "variants": [],
10
+ "sizes": [],
11
+ "placements": [
12
+ "top",
13
+ "bottom",
14
+ "left",
15
+ "right"
16
+ ],
17
+ "tags": [
18
+ "overlay",
19
+ "hint",
20
+ "interactive"
21
+ ],
22
+ "usage": {
23
+ "importPath": "{{aliases.components}}/tooltip",
24
+ "exportName": "Tooltip",
25
+ "isDefault": false
26
+ },
27
+ "registryDependencies": [],
28
+ "engineDependencies": {},
29
+ "files": [
30
+ {
31
+ "path": "packages/components/_internal/react.ts",
32
+ "target": "{{aliases.components}}/_internal/react.ts",
33
+ "type": "registry:ui",
34
+ "integrity": "sha256-SH63/haiB4Q93uFjMIMe5xZbaxENu3VI+GcidV8HELM="
35
+ },
36
+ {
37
+ "path": "packages/components/tooltip/tooltip.tsx",
38
+ "target": "{{aliases.components}}/tooltip.tsx",
39
+ "type": "registry:ui",
40
+ "integrity": "sha256-NbaOUmlUbBlUIdovchrWwH2QqzuvXB97DjnJnrHf4Gc="
41
+ }
42
+ ],
43
+ "themeContract": {
44
+ "attribute": "data-theme",
45
+ "modeAttribute": "data-mode",
46
+ "compatibleThemes": [
47
+ "graphite",
48
+ "atelier",
49
+ "machined",
50
+ "aerogel",
51
+ "nordic"
52
+ ]
53
+ }
54
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "../schema/registry-item.schema.json",
3
+ "schemaVersion": 2,
4
+ "name": "tour",
5
+ "type": "registry:ui",
6
+ "title": "Tour",
7
+ "description": "A guided product tour that spotlights parts of the page step by step, with keyboard control and a focus trap",
8
+ "category": "overlays",
9
+ "tags": [
10
+ "tour",
11
+ "onboarding",
12
+ "walkthrough",
13
+ "spotlight",
14
+ "coach marks"
15
+ ],
16
+ "usage": {
17
+ "importPath": "{{aliases.components}}/tour",
18
+ "exportName": "Tour",
19
+ "isDefault": false
20
+ },
21
+ "registryDependencies": [],
22
+ "engineDependencies": {
23
+ "@mlola-ui/icons": "^1.0.0"
24
+ },
25
+ "files": [
26
+ {
27
+ "path": "packages/components/_internal/anchor.ts",
28
+ "target": "{{aliases.components}}/_internal/anchor.ts",
29
+ "type": "registry:ui",
30
+ "integrity": "sha256-Jo9N3iIaWnS1CJzYhgTv7vnW+8MLyzL8X4GRNjeEp+g="
31
+ },
32
+ {
33
+ "path": "packages/components/_internal/floating.ts",
34
+ "target": "{{aliases.components}}/_internal/floating.ts",
35
+ "type": "registry:ui",
36
+ "integrity": "sha256-+WxgC4CtAlSr2qA1EkoDTgto535XLIPsvtOfdOK6CHk="
37
+ },
38
+ {
39
+ "path": "packages/components/_internal/react.ts",
40
+ "target": "{{aliases.components}}/_internal/react.ts",
41
+ "type": "registry:ui",
42
+ "integrity": "sha256-SH63/haiB4Q93uFjMIMe5xZbaxENu3VI+GcidV8HELM="
43
+ },
44
+ {
45
+ "path": "packages/components/tour/tour.tsx",
46
+ "target": "{{aliases.components}}/tour.tsx",
47
+ "type": "registry:ui",
48
+ "integrity": "sha256-1aSh7hMVt/DM/kH3WGJzJMGNsNqYEOmnwAZwc8dwRv8="
49
+ }
50
+ ],
51
+ "themeContract": {
52
+ "attribute": "data-theme",
53
+ "modeAttribute": "data-mode",
54
+ "compatibleThemes": [
55
+ "graphite",
56
+ "atelier",
57
+ "machined",
58
+ "aerogel",
59
+ "nordic"
60
+ ]
61
+ }
62
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "../schema/registry-item.schema.json",
3
+ "schemaVersion": 2,
4
+ "name": "virtual-list",
5
+ "type": "registry:ui",
6
+ "title": "Virtual List",
7
+ "description": "A long list that renders only what is on screen, measures rows of any height and loads more near the end",
8
+ "category": "data",
9
+ "tags": [
10
+ "virtual",
11
+ "list",
12
+ "infinite",
13
+ "scroll",
14
+ "performance"
15
+ ],
16
+ "usage": {
17
+ "importPath": "{{aliases.components}}/virtual-list",
18
+ "exportName": "VirtualList",
19
+ "isDefault": false
20
+ },
21
+ "registryDependencies": [],
22
+ "engineDependencies": {},
23
+ "files": [
24
+ {
25
+ "path": "packages/components/_internal/react.ts",
26
+ "target": "{{aliases.components}}/_internal/react.ts",
27
+ "type": "registry:ui",
28
+ "integrity": "sha256-SH63/haiB4Q93uFjMIMe5xZbaxENu3VI+GcidV8HELM="
29
+ },
30
+ {
31
+ "path": "packages/components/virtual-list/virtual-list.tsx",
32
+ "target": "{{aliases.components}}/virtual-list.tsx",
33
+ "type": "registry:ui",
34
+ "integrity": "sha256-raGEFn4HlAOIohqZcy0DgptsnUIPS/6bWLYeTe+M9E8="
35
+ },
36
+ {
37
+ "path": "packages/components/virtual-list/virtual.ts",
38
+ "target": "{{aliases.components}}/virtual-list/virtual.ts",
39
+ "type": "registry:ui",
40
+ "integrity": "sha256-PyDIj3C2Vwb1i4ZssxSlYjgKmZPBIFLZFNq8YCW2Z78="
41
+ }
42
+ ],
43
+ "themeContract": {
44
+ "attribute": "data-theme",
45
+ "modeAttribute": "data-mode",
46
+ "compatibleThemes": [
47
+ "graphite",
48
+ "atelier",
49
+ "machined",
50
+ "aerogel",
51
+ "nordic"
52
+ ]
53
+ }
54
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where a floating layer goes next to its anchor, as a pure function of
3
+ * rectangles: prefer the requested side, flip when it does not fit, and shift
4
+ * along the edge to stay inside the viewport. No DOM, so it is testable.
5
+ */
6
+ export type Side = "top" | "bottom" | "left" | "right";
7
+ export type Align = "start" | "center" | "end";
8
+
9
+ export interface Rect {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ }
15
+
16
+ export interface Placement {
17
+ x: number;
18
+ y: number;
19
+ side: Side;
20
+ }
21
+
22
+ const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
23
+
24
+ export function placeFloating(
25
+ anchor: Rect,
26
+ floating: { width: number; height: number },
27
+ viewport: { width: number; height: number },
28
+ { side = "bottom", align = "center", offset = 8, padding = 8 }: { side?: Side; align?: Align; offset?: number; padding?: number } = {},
29
+ ): Placement {
30
+ const room: Record<Side, number> = {
31
+ top: anchor.y - padding,
32
+ bottom: viewport.height - (anchor.y + anchor.height) - padding,
33
+ left: anchor.x - padding,
34
+ right: viewport.width - (anchor.x + anchor.width) - padding,
35
+ };
36
+ const needs = (candidate: Side) => (candidate === "top" || candidate === "bottom" ? floating.height : floating.width) + offset;
37
+ let chosen = side;
38
+ if (room[side] < needs(side) && room[OPPOSITE[side]] > room[side]) chosen = OPPOSITE[side];
39
+
40
+ const vertical = chosen === "top" || chosen === "bottom";
41
+ let x: number;
42
+ let y: number;
43
+ if (vertical) {
44
+ y = chosen === "bottom" ? anchor.y + anchor.height + offset : anchor.y - floating.height - offset;
45
+ x = align === "start" ? anchor.x : align === "end" ? anchor.x + anchor.width - floating.width : anchor.x + anchor.width / 2 - floating.width / 2;
46
+ } else {
47
+ x = chosen === "right" ? anchor.x + anchor.width + offset : anchor.x - floating.width - offset;
48
+ y = align === "start" ? anchor.y : align === "end" ? anchor.y + anchor.height - floating.height : anchor.y + anchor.height / 2 - floating.height / 2;
49
+ }
50
+ // Shift along the edge to stay on screen.
51
+ x = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - floating.width - padding));
52
+ y = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - floating.height - padding));
53
+ return { x: Math.round(x), y: Math.round(y), side: chosen };
54
+ }
@@ -0,0 +1,142 @@
1
+ import * as React from "react";
2
+ import { lockScroll } from "@mlola-ui/behavior/document";
3
+
4
+ const FOCUSABLE =
5
+ '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"])';
6
+
7
+ type HiddenSnapshot = {
8
+ count: number;
9
+ inert: boolean;
10
+ ariaHidden: string | null;
11
+ };
12
+
13
+ const hiddenElements = new Map<HTMLElement, HiddenSnapshot>();
14
+
15
+ function hideBackground(portal: HTMLElement) {
16
+ const children = Array.from(document.body.children).filter(
17
+ (child): child is HTMLElement => child instanceof HTMLElement && child !== portal
18
+ );
19
+ for (const element of children) {
20
+ const existing = hiddenElements.get(element);
21
+ if (existing) {
22
+ existing.count += 1;
23
+ continue;
24
+ }
25
+ hiddenElements.set(element, {
26
+ count: 1,
27
+ inert: element.inert,
28
+ ariaHidden: element.getAttribute("aria-hidden"),
29
+ });
30
+ element.inert = true;
31
+ element.setAttribute("aria-hidden", "true");
32
+ }
33
+ return () => {
34
+ for (const element of children) {
35
+ const snapshot = hiddenElements.get(element);
36
+ if (!snapshot) continue;
37
+ snapshot.count -= 1;
38
+ if (snapshot.count > 0) continue;
39
+ element.inert = snapshot.inert;
40
+ if (snapshot.ariaHidden === null) element.removeAttribute("aria-hidden");
41
+ else element.setAttribute("aria-hidden", snapshot.ariaHidden);
42
+ hiddenElements.delete(element);
43
+ }
44
+ };
45
+ }
46
+
47
+ export function useDialogLayer({
48
+ open,
49
+ onClose,
50
+ closeOnEscape,
51
+ panelRef,
52
+ }: {
53
+ open: boolean;
54
+ onClose: () => void;
55
+ closeOnEscape: boolean;
56
+ panelRef: React.RefObject<HTMLElement | null>;
57
+ }) {
58
+ const [portal, setPortal] = React.useState<HTMLDivElement | null>(null);
59
+ const restoreFocusRef = React.useRef<HTMLElement | null>(null);
60
+ const closeRef = React.useRef(onClose);
61
+ closeRef.current = onClose;
62
+
63
+ React.useEffect(() => {
64
+ const node = document.createElement("div");
65
+ node.setAttribute("data-ml-portal", "");
66
+ document.body.appendChild(node);
67
+ setPortal(node);
68
+ return () => {
69
+ node.remove();
70
+ setPortal(null);
71
+ };
72
+ }, []);
73
+
74
+ React.useEffect(() => {
75
+ if (!open || !portal) return;
76
+ restoreFocusRef.current =
77
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
78
+ const releaseScroll = lockScroll();
79
+ const restoreBackground = hideBackground(portal);
80
+ const focusPanel = () => {
81
+ const panel = panelRef.current;
82
+ if (!panel) return;
83
+ const first = panel.querySelector<HTMLElement>(FOCUSABLE);
84
+ (first ?? panel).focus();
85
+ };
86
+ const frame = requestAnimationFrame(focusPanel);
87
+
88
+ const onKeyDown = (event: KeyboardEvent) => {
89
+ const panel = panelRef.current;
90
+ if (!panel) return;
91
+ if (event.key === "Escape" && closeOnEscape) {
92
+ event.preventDefault();
93
+ closeRef.current();
94
+ return;
95
+ }
96
+ if (event.key !== "Tab") return;
97
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
98
+ (element) =>
99
+ !element.hidden &&
100
+ element.getAttribute("aria-hidden") !== "true" &&
101
+ element.tabIndex >= 0
102
+ );
103
+ if (focusables.length === 0) {
104
+ event.preventDefault();
105
+ panel.focus();
106
+ return;
107
+ }
108
+ const first = focusables[0];
109
+ const last = focusables[focusables.length - 1];
110
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
111
+ event.preventDefault();
112
+ last.focus();
113
+ } else if (!event.shiftKey && document.activeElement === last) {
114
+ event.preventDefault();
115
+ first.focus();
116
+ }
117
+ };
118
+
119
+ const onFocusIn = (event: FocusEvent) => {
120
+ const panel = panelRef.current;
121
+ if (!panel || !(event.target instanceof Element) || panel.contains(event.target)) return;
122
+ // Menus and lists opened from inside the dialog live in their own layer on <body>; focus may go there.
123
+ const layer = event.target.closest("[data-ml-portal]");
124
+ if (layer && layer !== portal) return;
125
+ focusPanel();
126
+ };
127
+
128
+ document.addEventListener("keydown", onKeyDown);
129
+ document.addEventListener("focusin", onFocusIn);
130
+ return () => {
131
+ cancelAnimationFrame(frame);
132
+ document.removeEventListener("keydown", onKeyDown);
133
+ document.removeEventListener("focusin", onFocusIn);
134
+ releaseScroll();
135
+ restoreBackground();
136
+ const target = restoreFocusRef.current;
137
+ if (target?.isConnected) requestAnimationFrame(() => target.focus());
138
+ };
139
+ }, [open, portal, closeOnEscape, panelRef]);
140
+
141
+ return portal;
142
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { placeFloating, type Align, type Side } from "./anchor";
5
+
6
+ export type { Align, Side };
7
+
8
+ /**
9
+ * Keep a fixed-position layer next to its anchor while open: on open, on
10
+ * scroll anywhere, on resize, and whenever either element changes size.
11
+ * Writes styles directly, so following a scroll never re-renders React.
12
+ */
13
+ export function useFloating(
14
+ anchor: React.RefObject<Element | null>,
15
+ floating: React.RefObject<HTMLElement | null>,
16
+ open: boolean,
17
+ options: { side?: Side; align?: Align; offset?: number },
18
+ ) {
19
+ const { side, align, offset } = options;
20
+ React.useLayoutEffect(() => {
21
+ const layer = floating.current;
22
+ if (!open || !layer) return;
23
+ let frame = 0;
24
+ // A layer on <body> takes the theme of the place it opened from, which may be a themed canvas rather than the page.
25
+ const themed = anchor.current?.closest<HTMLElement>("[data-theme]");
26
+ 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;
29
+ const update = () => {
30
+ const target = anchor.current;
31
+ if (!target || !layer.isConnected) return;
32
+ const rect = target.getBoundingClientRect();
33
+ const place = placeFloating(
34
+ { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
35
+ { width: layer.offsetWidth, height: layer.offsetHeight },
36
+ { width: window.innerWidth, height: window.innerHeight },
37
+ { side, align, offset },
38
+ );
39
+ layer.style.left = `${place.x}px`;
40
+ layer.style.top = `${place.y}px`;
41
+ layer.dataset.side = place.side;
42
+ };
43
+ const schedule = () => {
44
+ cancelAnimationFrame(frame);
45
+ frame = requestAnimationFrame(update);
46
+ };
47
+ update();
48
+ window.addEventListener("scroll", schedule, true);
49
+ window.addEventListener("resize", schedule);
50
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
51
+ observer?.observe(layer);
52
+ if (anchor.current) observer?.observe(anchor.current);
53
+ return () => {
54
+ cancelAnimationFrame(frame);
55
+ window.removeEventListener("scroll", schedule, true);
56
+ window.removeEventListener("resize", schedule);
57
+ observer?.disconnect();
58
+ };
59
+ }, [anchor, floating, open, side, align, offset]);
60
+ }
61
+
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;
73
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Rank a command against a query the way people type: a prefix beats the
3
+ * start of a later word, which beats a substring, which beats letters in
4
+ * order ("dkm" → "Dark mode"). Returns null for no match, and the matched
5
+ * character positions so the view can bold them.
6
+ */
7
+ export interface CommandMatch {
8
+ score: number;
9
+ positions: number[];
10
+ }
11
+
12
+ export function matchCommand(label: string, query: string, keywords: string[] = []): CommandMatch | null {
13
+ const needle = query.trim().toLowerCase();
14
+ if (!needle) return { score: 0, positions: [] };
15
+ const text = label.toLowerCase();
16
+ const range = (start: number) => Array.from({ length: needle.length }, (_, index) => start + index);
17
+
18
+ if (text.startsWith(needle)) return { score: 100 - text.length * 0.1, positions: range(0) };
19
+ const wordStart = text.search(new RegExp(`\\b${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
20
+ if (wordStart > 0) return { score: 80 - wordStart * 0.5, positions: range(wordStart) };
21
+ const inside = text.indexOf(needle);
22
+ if (inside >= 0) return { score: 60 - inside * 0.5, positions: range(inside) };
23
+ if (keywords.some((keyword) => keyword.toLowerCase().includes(needle))) return { score: 45, positions: [] };
24
+
25
+ // Letters in order, preferring word starts and runs.
26
+ const positions: number[] = [];
27
+ let from = 0;
28
+ let bonus = 0;
29
+ for (const char of needle) {
30
+ const found = text.indexOf(char, from);
31
+ if (found < 0) return null;
32
+ if (found === 0 || text[found - 1] === " ") bonus += 3;
33
+ if (positions.length && found === positions[positions.length - 1] + 1) bonus += 2;
34
+ positions.push(found);
35
+ from = found + 1;
36
+ }
37
+ const spread = positions[positions.length - 1] - positions[0];
38
+ return { score: 20 + bonus - spread * 0.2, positions };
39
+ }
@@ -0,0 +1,40 @@
1
+ import * as React from "react";
2
+
3
+ export type ClassNameValue = string | false | null | undefined;
4
+
5
+ export function cx(...values: ClassNameValue[]): string {
6
+ return values.filter(Boolean).join(" ");
7
+ }
8
+
9
+ export function composeRefs<T>(
10
+ ...refs: Array<React.ForwardedRef<T> | undefined>
11
+ ): (node: T | null) => void {
12
+ return (node) => {
13
+ for (const ref of refs) {
14
+ if (typeof ref === "function") ref(node);
15
+ else if (ref) ref.current = node;
16
+ }
17
+ };
18
+ }
19
+
20
+ export function useControllableState<T>({
21
+ value,
22
+ defaultValue,
23
+ onChange,
24
+ }: {
25
+ value: T | undefined;
26
+ defaultValue: T;
27
+ onChange?: (value: T) => void;
28
+ }): [T, (value: T) => void] {
29
+ const [internal, setInternal] = React.useState(defaultValue);
30
+ const controlled = value !== undefined;
31
+ const current = controlled ? value : internal;
32
+ const setValue = React.useCallback(
33
+ (next: T) => {
34
+ if (!controlled) setInternal(next);
35
+ onChange?.(next);
36
+ },
37
+ [controlled, onChange]
38
+ );
39
+ return [current, setValue];
40
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Bring `element` into view inside `scroller` only. Unlike scrollIntoView, it
3
+ * never scrolls the page or any other ancestor, so a component that follows
4
+ * its active item cannot move the reader's place on first render.
5
+ */
6
+ export function revealIn(scroller: HTMLElement | null, element: HTMLElement | null, axis: "x" | "y" = "y") {
7
+ if (!scroller || !element) return;
8
+ const box = scroller.getBoundingClientRect();
9
+ const rect = element.getBoundingClientRect();
10
+ if (axis === "x") {
11
+ if (rect.left < box.left) scroller.scrollLeft -= box.left - rect.left;
12
+ else if (rect.right > box.right) scroller.scrollLeft += rect.right - box.right;
13
+ } else if (rect.top < box.top) scroller.scrollTop -= box.top - rect.top;
14
+ else if (rect.bottom > box.bottom) scroller.scrollTop += rect.bottom - box.bottom;
15
+ }