@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/components/accordion.json +54 -0
- package/components/activity-heatmap.json +54 -0
- package/components/alert.json +56 -0
- package/components/app-shell.json +53 -0
- package/components/avatar.json +54 -0
- package/components/badge.json +62 -0
- package/components/breadcrumb.json +49 -0
- package/components/button.json +62 -0
- package/components/calendar.json +55 -0
- package/components/card.json +53 -0
- package/components/carousel.json +52 -0
- package/components/checkbox.json +50 -0
- package/components/color-picker.json +60 -0
- package/components/combobox.json +64 -0
- package/components/command-menu.json +64 -0
- package/components/context-menu.json +61 -0
- package/components/date-picker.json +59 -0
- package/components/dropdown-menu.json +51 -0
- package/components/dropzone.json +45 -0
- package/components/empty-state.json +45 -0
- package/components/hover-card.json +59 -0
- package/components/input.json +56 -0
- package/components/kbd.json +45 -0
- package/components/modal.json +63 -0
- package/components/number-input.json +58 -0
- package/components/otp-input.json +49 -0
- package/components/pagination.json +50 -0
- package/components/popover.json +58 -0
- package/components/priority-icon.json +53 -0
- package/components/progress.json +57 -0
- package/components/radio-group.json +48 -0
- package/components/rating.json +48 -0
- package/components/resizable.json +46 -0
- package/components/segmented-control.json +51 -0
- package/components/select.json +67 -0
- package/components/sheet.json +66 -0
- package/components/skeleton.json +48 -0
- package/components/slider.json +53 -0
- package/components/status-icon.json +53 -0
- package/components/stepper.json +50 -0
- package/components/table.json +53 -0
- package/components/tabs.json +54 -0
- package/components/tag-input.json +53 -0
- package/components/textarea.json +48 -0
- package/components/time-picker.json +58 -0
- package/components/timeline.json +54 -0
- package/components/toast.json +56 -0
- package/components/toggle.json +52 -0
- package/components/tooltip.json +54 -0
- package/components/tour.json +62 -0
- package/components/virtual-list.json +54 -0
- package/generated/catalog.ts +2037 -0
- package/index.json +413 -0
- package/package.json +47 -0
- package/schema/mlola.config.schema.json +51 -0
- package/schema/registry-index.schema.json +116 -0
- package/schema/registry-item.schema.json +80 -0
- package/snapshot/index.json +2938 -0
- package/snapshot/items/components/accordion.json +54 -0
- package/snapshot/items/components/activity-heatmap.json +54 -0
- package/snapshot/items/components/alert.json +56 -0
- package/snapshot/items/components/app-shell.json +53 -0
- package/snapshot/items/components/avatar.json +54 -0
- package/snapshot/items/components/badge.json +62 -0
- package/snapshot/items/components/breadcrumb.json +49 -0
- package/snapshot/items/components/button.json +62 -0
- package/snapshot/items/components/calendar.json +55 -0
- package/snapshot/items/components/card.json +53 -0
- package/snapshot/items/components/carousel.json +52 -0
- package/snapshot/items/components/checkbox.json +50 -0
- package/snapshot/items/components/color-picker.json +60 -0
- package/snapshot/items/components/combobox.json +64 -0
- package/snapshot/items/components/command-menu.json +64 -0
- package/snapshot/items/components/context-menu.json +61 -0
- package/snapshot/items/components/date-picker.json +59 -0
- package/snapshot/items/components/dropdown-menu.json +51 -0
- package/snapshot/items/components/dropzone.json +45 -0
- package/snapshot/items/components/empty-state.json +45 -0
- package/snapshot/items/components/hover-card.json +59 -0
- package/snapshot/items/components/input.json +56 -0
- package/snapshot/items/components/kbd.json +45 -0
- package/snapshot/items/components/modal.json +63 -0
- package/snapshot/items/components/number-input.json +58 -0
- package/snapshot/items/components/otp-input.json +49 -0
- package/snapshot/items/components/pagination.json +50 -0
- package/snapshot/items/components/popover.json +58 -0
- package/snapshot/items/components/priority-icon.json +53 -0
- package/snapshot/items/components/progress.json +57 -0
- package/snapshot/items/components/radio-group.json +48 -0
- package/snapshot/items/components/rating.json +48 -0
- package/snapshot/items/components/resizable.json +46 -0
- package/snapshot/items/components/segmented-control.json +51 -0
- package/snapshot/items/components/select.json +67 -0
- package/snapshot/items/components/sheet.json +66 -0
- package/snapshot/items/components/skeleton.json +48 -0
- package/snapshot/items/components/slider.json +53 -0
- package/snapshot/items/components/status-icon.json +53 -0
- package/snapshot/items/components/stepper.json +50 -0
- package/snapshot/items/components/table.json +53 -0
- package/snapshot/items/components/tabs.json +54 -0
- package/snapshot/items/components/tag-input.json +53 -0
- package/snapshot/items/components/textarea.json +48 -0
- package/snapshot/items/components/time-picker.json +58 -0
- package/snapshot/items/components/timeline.json +54 -0
- package/snapshot/items/components/toast.json +56 -0
- package/snapshot/items/components/toggle.json +52 -0
- package/snapshot/items/components/tooltip.json +54 -0
- package/snapshot/items/components/tour.json +62 -0
- package/snapshot/items/components/virtual-list.json +54 -0
- package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
- package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
- package/snapshot/source/packages/components/_internal/floating.ts +73 -0
- package/snapshot/source/packages/components/_internal/match.ts +39 -0
- package/snapshot/source/packages/components/_internal/react.ts +40 -0
- package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
- package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
- package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
- package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
- package/snapshot/source/packages/components/alert/alert.tsx +83 -0
- package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
- package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
- package/snapshot/source/packages/components/badge/badge.tsx +39 -0
- package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
- package/snapshot/source/packages/components/button/button.tsx +42 -0
- package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
- package/snapshot/source/packages/components/calendar/dates.ts +163 -0
- package/snapshot/source/packages/components/card/card.tsx +39 -0
- package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
- package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
- package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
- package/snapshot/source/packages/components/color-picker/color.ts +124 -0
- package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
- package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
- package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
- package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
- package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
- package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
- package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
- package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
- package/snapshot/source/packages/components/input/input.tsx +101 -0
- package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
- package/snapshot/source/packages/components/modal/modal.tsx +100 -0
- package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
- package/snapshot/source/packages/components/number-input/number.ts +50 -0
- package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
- package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
- package/snapshot/source/packages/components/popover/popover.tsx +107 -0
- package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
- package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
- package/snapshot/source/packages/components/progress/progress.tsx +110 -0
- package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
- package/snapshot/source/packages/components/rating/rating.tsx +94 -0
- package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
- package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
- package/snapshot/source/packages/components/select/select.tsx +332 -0
- package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
- package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
- package/snapshot/source/packages/components/slider/slider.tsx +99 -0
- package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
- package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
- package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
- package/snapshot/source/packages/components/table/table.tsx +66 -0
- package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
- package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
- package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
- package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
- package/snapshot/source/packages/components/time-picker/times.ts +76 -0
- package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
- package/snapshot/source/packages/components/toast/toast.tsx +317 -0
- package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
- package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
- package/snapshot/source/packages/components/tour/tour.tsx +193 -0
- package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
- package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
- 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
|
+
}
|