@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour conversions for the picker: hex, rgb() and oklch() in and out, HSV
|
|
3
|
+
* for the picking surface, and WCAG contrast. sRGB throughout; OKLCH through
|
|
4
|
+
* OKLab, the space the Mlola engine derives its palettes in.
|
|
5
|
+
*/
|
|
6
|
+
export interface RGBA {
|
|
7
|
+
/** 0 to 255. */
|
|
8
|
+
r: number;
|
|
9
|
+
g: number;
|
|
10
|
+
b: number;
|
|
11
|
+
/** 0 to 1. */
|
|
12
|
+
a: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HSVA {
|
|
16
|
+
/** 0 to 360. */
|
|
17
|
+
h: number;
|
|
18
|
+
/** 0 to 1. */
|
|
19
|
+
s: number;
|
|
20
|
+
v: number;
|
|
21
|
+
a: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ColorFormat = "hex" | "rgb" | "oklch";
|
|
25
|
+
|
|
26
|
+
const clamp = (value: number, low = 0, high = 1) => Math.min(high, Math.max(low, value));
|
|
27
|
+
const round = (value: number, digits = 0) => Number(value.toFixed(digits));
|
|
28
|
+
|
|
29
|
+
const toLinear = (channel: number) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
|
30
|
+
const toGamma = (channel: number) => (channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055);
|
|
31
|
+
|
|
32
|
+
export function rgbToOklch({ r, g, b }: RGBA) {
|
|
33
|
+
const [lr, lg, lb] = [r, g, b].map((channel) => toLinear(channel / 255));
|
|
34
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
35
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
36
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
37
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
38
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
39
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
40
|
+
const C = Math.sqrt(A * A + B * B);
|
|
41
|
+
const h = C < 0.0001 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
|
|
42
|
+
return { l: L, c: C, h };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function oklchToRgb(L: number, C: number, h: number, a = 1): RGBA {
|
|
46
|
+
const radians = (h * Math.PI) / 180;
|
|
47
|
+
const A = C * Math.cos(radians);
|
|
48
|
+
const B = C * Math.sin(radians);
|
|
49
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
50
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
51
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
52
|
+
const linear = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
|
|
53
|
+
const [r, g, b] = linear.map((channel) => Math.round(clamp(toGamma(clamp(channel))) * 255));
|
|
54
|
+
return { r, g, b, a };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read #rgb, #rgba, #rrggbb, #rrggbbaa, rgb()/rgba() or oklch(); null for anything else. */
|
|
58
|
+
export function parseColor(text: string): RGBA | null {
|
|
59
|
+
const value = text.trim().toLowerCase();
|
|
60
|
+
const hex = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(value);
|
|
61
|
+
if (hex) {
|
|
62
|
+
let digits = hex[1];
|
|
63
|
+
if (digits.length <= 4) digits = [...digits].map((digit) => digit + digit).join("");
|
|
64
|
+
const channel = (index: number) => parseInt(digits.slice(index * 2, index * 2 + 2), 16);
|
|
65
|
+
return { r: channel(0), g: channel(1), b: channel(2), a: digits.length === 8 ? round(channel(3) / 255, 3) : 1 };
|
|
66
|
+
}
|
|
67
|
+
const rgb = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)(?:[\s,/]+([\d.]+%?))?\s*\)$/.exec(value);
|
|
68
|
+
if (rgb) {
|
|
69
|
+
const channel = (part: string) => clamp(part.endsWith("%") ? (parseFloat(part) / 100) * 255 : parseFloat(part), 0, 255);
|
|
70
|
+
const alpha = rgb[4] === undefined ? 1 : rgb[4].endsWith("%") ? parseFloat(rgb[4]) / 100 : parseFloat(rgb[4]);
|
|
71
|
+
return { r: Math.round(channel(rgb[1])), g: Math.round(channel(rgb[2])), b: Math.round(channel(rgb[3])), a: clamp(alpha) };
|
|
72
|
+
}
|
|
73
|
+
const oklch = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?))?\s*\)$/.exec(value);
|
|
74
|
+
if (oklch) {
|
|
75
|
+
const lightness = oklch[1].endsWith("%") ? parseFloat(oklch[1]) / 100 : parseFloat(oklch[1]);
|
|
76
|
+
const alpha = oklch[4] === undefined ? 1 : oklch[4].endsWith("%") ? parseFloat(oklch[4]) / 100 : parseFloat(oklch[4]);
|
|
77
|
+
return oklchToRgb(lightness, parseFloat(oklch[2]), parseFloat(oklch[3]), clamp(alpha));
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rgbToHsv({ r, g, b, a }: RGBA): HSVA {
|
|
83
|
+
const [red, green, blue] = [r / 255, g / 255, b / 255];
|
|
84
|
+
const max = Math.max(red, green, blue);
|
|
85
|
+
const delta = max - Math.min(red, green, blue);
|
|
86
|
+
let h = 0;
|
|
87
|
+
if (delta) {
|
|
88
|
+
if (max === red) h = ((green - blue) / delta) % 6;
|
|
89
|
+
else if (max === green) h = (blue - red) / delta + 2;
|
|
90
|
+
else h = (red - green) / delta + 4;
|
|
91
|
+
}
|
|
92
|
+
return { h: (h * 60 + 360) % 360, s: max ? delta / max : 0, v: max, a };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function hsvToRgb({ h, s, v, a }: HSVA): RGBA {
|
|
96
|
+
const chroma = v * s;
|
|
97
|
+
const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
98
|
+
const match = v - chroma;
|
|
99
|
+
const [r, g, b] = h < 60 ? [chroma, x, 0] : h < 120 ? [x, chroma, 0] : h < 180 ? [0, chroma, x] : h < 240 ? [0, x, chroma] : h < 300 ? [x, 0, chroma] : [chroma, 0, x];
|
|
100
|
+
return { r: Math.round((r + match) * 255), g: Math.round((g + match) * 255), b: Math.round((b + match) * 255), a };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hex2 = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
|
104
|
+
|
|
105
|
+
export function formatColor(color: RGBA, format: ColorFormat = "hex"): string {
|
|
106
|
+
const alpha = color.a < 1;
|
|
107
|
+
if (format === "rgb") return alpha ? `rgb(${color.r} ${color.g} ${color.b} / ${round(color.a, 2)})` : `rgb(${color.r} ${color.g} ${color.b})`;
|
|
108
|
+
if (format === "oklch") {
|
|
109
|
+
const { l, c, h } = rgbToOklch(color);
|
|
110
|
+
return `oklch(${round(l * 100, 1)}% ${round(c, 3)} ${round(h, 1)}${alpha ? ` / ${round(color.a, 2)}` : ""})`;
|
|
111
|
+
}
|
|
112
|
+
return `#${hex2(color.r)}${hex2(color.g)}${hex2(color.b)}${alpha ? hex2(color.a * 255) : ""}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** WCAG 2 relative luminance and contrast ratio, 1 to 21. */
|
|
116
|
+
export function luminance({ r, g, b }: RGBA) {
|
|
117
|
+
const [lr, lg, lb] = [r, g, b].map((channel) => toLinear(channel / 255));
|
|
118
|
+
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function contrastRatio(first: RGBA, second: RGBA) {
|
|
122
|
+
const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
|
123
|
+
return (light + 0.05) / (dark + 0.05);
|
|
124
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { IconCheck, IconChevronDown, IconPlus, IconX } from "@mlola-ui/icons";
|
|
5
|
+
import { Field, fieldDescription } from "../input/input";
|
|
6
|
+
import { cx } from "../_internal/react";
|
|
7
|
+
import { matchCommand } from "../_internal/match";
|
|
8
|
+
import { revealIn } from "../_internal/scroll";
|
|
9
|
+
|
|
10
|
+
export interface ComboboxOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
/** An icon, avatar or swatch before the label. */
|
|
15
|
+
leading?: React.ReactNode;
|
|
16
|
+
/** More words that find this option. */
|
|
17
|
+
keywords?: string[];
|
|
18
|
+
group?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rank options against what was typed: prefix, word start, substring, then letters in order. */
|
|
23
|
+
export function filterOptions(options: ComboboxOption[], query: string) {
|
|
24
|
+
if (!query.trim()) return options;
|
|
25
|
+
return options
|
|
26
|
+
.map((option) => ({ option, match: matchCommand(option.label, query, option.keywords) }))
|
|
27
|
+
.filter((entry) => entry.match)
|
|
28
|
+
.sort((a, b) => b.match!.score - a.match!.score)
|
|
29
|
+
.map((entry) => entry.option);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ComboboxListProps {
|
|
33
|
+
id: string;
|
|
34
|
+
options: ComboboxOption[];
|
|
35
|
+
/** Index of the highlighted row. */
|
|
36
|
+
active: number;
|
|
37
|
+
onActiveChange: (index: number) => void;
|
|
38
|
+
onPick: (option: ComboboxOption) => void;
|
|
39
|
+
isSelected?: (option: ComboboxOption) => boolean;
|
|
40
|
+
/** A last row that creates what was typed. */
|
|
41
|
+
create?: { label: string; onCreate: () => void } | null;
|
|
42
|
+
empty?: React.ReactNode;
|
|
43
|
+
side: "top" | "bottom";
|
|
44
|
+
label?: string;
|
|
45
|
+
grouped?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The popup list a combobox or tag input shows under its field: grouped,
|
|
50
|
+
* highlighted by pointer or arrows, never taking focus from the input.
|
|
51
|
+
*/
|
|
52
|
+
export function ComboboxList({ id, options, active, onActiveChange, onPick, isSelected, create, empty, side, label, grouped = true }: ComboboxListProps) {
|
|
53
|
+
const list = React.useRef<HTMLUListElement>(null);
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
revealIn(list.current, list.current?.querySelector<HTMLElement>("[data-highlighted]") ?? null);
|
|
56
|
+
}, [active, options]);
|
|
57
|
+
let lastGroup: string | undefined;
|
|
58
|
+
return (
|
|
59
|
+
<div className="ml-combobox-popover" data-side={side} onMouseDown={(event) => event.preventDefault()}>
|
|
60
|
+
<ul ref={list} id={id} role="listbox" aria-label={label} className="ml-combobox-list">
|
|
61
|
+
{options.map((option, index) => {
|
|
62
|
+
const heading = grouped && option.group && option.group !== lastGroup ? option.group : null;
|
|
63
|
+
lastGroup = option.group;
|
|
64
|
+
const selected = isSelected?.(option) ?? false;
|
|
65
|
+
return (
|
|
66
|
+
<React.Fragment key={option.value}>
|
|
67
|
+
{heading ? (
|
|
68
|
+
<li role="presentation" className="ml-combobox-group">
|
|
69
|
+
{heading}
|
|
70
|
+
</li>
|
|
71
|
+
) : null}
|
|
72
|
+
<li
|
|
73
|
+
id={`${id}-${index}`}
|
|
74
|
+
role="option"
|
|
75
|
+
aria-selected={selected}
|
|
76
|
+
aria-disabled={option.disabled || undefined}
|
|
77
|
+
className="ml-combobox-option"
|
|
78
|
+
data-highlighted={index === active || undefined}
|
|
79
|
+
onPointerMove={() => index !== active && onActiveChange(index)}
|
|
80
|
+
onClick={() => !option.disabled && onPick(option)}
|
|
81
|
+
>
|
|
82
|
+
{option.leading ? (
|
|
83
|
+
<span className="ml-combobox-leading" aria-hidden="true">
|
|
84
|
+
{option.leading}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
<span className="ml-combobox-text">
|
|
88
|
+
<span className="ml-combobox-label">{option.label}</span>
|
|
89
|
+
{option.description ? <span className="ml-combobox-description">{option.description}</span> : null}
|
|
90
|
+
</span>
|
|
91
|
+
{selected ? <IconCheck aria-hidden="true" size="0.95em" className="ml-combobox-check" /> : null}
|
|
92
|
+
</li>
|
|
93
|
+
</React.Fragment>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
{create ? (
|
|
97
|
+
<li
|
|
98
|
+
id={`${id}-${options.length}`}
|
|
99
|
+
role="option"
|
|
100
|
+
aria-selected={false}
|
|
101
|
+
className="ml-combobox-option"
|
|
102
|
+
data-create=""
|
|
103
|
+
data-highlighted={active === options.length || undefined}
|
|
104
|
+
onPointerMove={() => active !== options.length && onActiveChange(options.length)}
|
|
105
|
+
onClick={create.onCreate}
|
|
106
|
+
>
|
|
107
|
+
<span className="ml-combobox-leading" aria-hidden="true">
|
|
108
|
+
<IconPlus size="1em" />
|
|
109
|
+
</span>
|
|
110
|
+
<span className="ml-combobox-label">{create.label}</span>
|
|
111
|
+
</li>
|
|
112
|
+
) : null}
|
|
113
|
+
{!options.length && !create ? <li className="ml-combobox-empty">{empty ?? "No matches"}</li> : null}
|
|
114
|
+
</ul>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Open the list above the field when there is not enough room below it. */
|
|
120
|
+
export function sideFor(element: HTMLElement | null, room = 280): "top" | "bottom" {
|
|
121
|
+
if (!element) return "bottom";
|
|
122
|
+
const rect = element.getBoundingClientRect();
|
|
123
|
+
return window.innerHeight - rect.bottom < room && rect.top > window.innerHeight - rect.bottom ? "top" : "bottom";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ComboboxProps {
|
|
127
|
+
options: ComboboxOption[];
|
|
128
|
+
value?: string | null;
|
|
129
|
+
defaultValue?: string | null;
|
|
130
|
+
onValueChange?: (value: string | null) => void;
|
|
131
|
+
label?: React.ReactNode;
|
|
132
|
+
hint?: React.ReactNode;
|
|
133
|
+
error?: React.ReactNode;
|
|
134
|
+
placeholder?: string;
|
|
135
|
+
/** Offer to create what was typed when nothing matches it exactly. */
|
|
136
|
+
onCreate?: (text: string) => void;
|
|
137
|
+
/** Shown when nothing matches. */
|
|
138
|
+
emptyMessage?: React.ReactNode;
|
|
139
|
+
clearable?: boolean;
|
|
140
|
+
disabled?: boolean;
|
|
141
|
+
required?: boolean;
|
|
142
|
+
id?: string;
|
|
143
|
+
className?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A text field that filters a list as you type: the searchable choice for
|
|
148
|
+
* long lists such as countries, people or repositories. Arrows move, Enter
|
|
149
|
+
* chooses, Escape restores; with `onCreate`, a missing option can be added.
|
|
150
|
+
*/
|
|
151
|
+
export function Combobox({
|
|
152
|
+
options,
|
|
153
|
+
value,
|
|
154
|
+
defaultValue = null,
|
|
155
|
+
onValueChange,
|
|
156
|
+
label,
|
|
157
|
+
hint,
|
|
158
|
+
error,
|
|
159
|
+
placeholder = "Search…",
|
|
160
|
+
onCreate,
|
|
161
|
+
emptyMessage,
|
|
162
|
+
clearable = true,
|
|
163
|
+
disabled,
|
|
164
|
+
required,
|
|
165
|
+
id,
|
|
166
|
+
className,
|
|
167
|
+
}: ComboboxProps) {
|
|
168
|
+
const autoId = React.useId();
|
|
169
|
+
const fieldId = id ?? autoId;
|
|
170
|
+
const listId = `${fieldId}-list`;
|
|
171
|
+
const [inner, setInner] = React.useState<string | null>(defaultValue);
|
|
172
|
+
const current = value !== undefined ? value : inner;
|
|
173
|
+
const chosen = options.find((option) => option.value === current) ?? null;
|
|
174
|
+
const [query, setQuery] = React.useState(chosen?.label ?? "");
|
|
175
|
+
const [open, setOpen] = React.useState(false);
|
|
176
|
+
const [active, setActive] = React.useState(0);
|
|
177
|
+
const [side, setSide] = React.useState<"top" | "bottom">("bottom");
|
|
178
|
+
const control = React.useRef<HTMLDivElement>(null);
|
|
179
|
+
const typed = React.useRef(false);
|
|
180
|
+
|
|
181
|
+
// Show the chosen label whenever the value changes from outside or the list closes.
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
if (!open) setQuery(chosen?.label ?? "");
|
|
184
|
+
}, [chosen?.label, open]);
|
|
185
|
+
|
|
186
|
+
const shown = typed.current ? filterOptions(options, query) : options;
|
|
187
|
+
const exact = options.some((option) => option.label.toLowerCase() === query.trim().toLowerCase());
|
|
188
|
+
const create = onCreate && query.trim() && !exact ? { label: `Create “${query.trim()}”`, onCreate: () => pickCreate() } : null;
|
|
189
|
+
const count = shown.length + (create ? 1 : 0);
|
|
190
|
+
|
|
191
|
+
const commit = (next: string | null) => {
|
|
192
|
+
if (value === undefined) setInner(next);
|
|
193
|
+
onValueChange?.(next);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const openList = () => {
|
|
197
|
+
if (disabled) return;
|
|
198
|
+
setSide(sideFor(control.current));
|
|
199
|
+
setOpen(true);
|
|
200
|
+
const index = shown.findIndex((option) => option.value === current);
|
|
201
|
+
setActive(Math.max(0, index));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const close = () => {
|
|
205
|
+
setOpen(false);
|
|
206
|
+
typed.current = false;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const pick = (option: ComboboxOption) => {
|
|
210
|
+
commit(option.value);
|
|
211
|
+
setQuery(option.label);
|
|
212
|
+
close();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function pickCreate() {
|
|
216
|
+
const text = query.trim();
|
|
217
|
+
if (!text) return;
|
|
218
|
+
onCreate?.(text);
|
|
219
|
+
close();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
223
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
if (!open) return openList();
|
|
226
|
+
if (!count) return;
|
|
227
|
+
setActive((index) => (index + (event.key === "ArrowDown" ? 1 : -1) + count) % count);
|
|
228
|
+
} else if (event.key === "Enter" && open) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
if (active < shown.length) {
|
|
231
|
+
const option = shown[active];
|
|
232
|
+
if (option && !option.disabled) pick(option);
|
|
233
|
+
} else if (create) pickCreate();
|
|
234
|
+
} else if (event.key === "Escape") {
|
|
235
|
+
if (open) {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
setQuery(chosen?.label ?? "");
|
|
238
|
+
close();
|
|
239
|
+
} else if (clearable && current) {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
commit(null);
|
|
242
|
+
setQuery("");
|
|
243
|
+
}
|
|
244
|
+
} else if (event.key === "Tab" && open) close();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-combobox", className)}>
|
|
249
|
+
<div ref={control} className="ml-combobox-control" data-open={open || undefined}>
|
|
250
|
+
{chosen?.leading && !typed.current ? (
|
|
251
|
+
<span className="ml-combobox-value-leading" aria-hidden="true">
|
|
252
|
+
{chosen.leading}
|
|
253
|
+
</span>
|
|
254
|
+
) : null}
|
|
255
|
+
<input
|
|
256
|
+
id={fieldId}
|
|
257
|
+
className="ml-input ml-combobox-input"
|
|
258
|
+
data-leading={chosen?.leading && !typed.current ? "" : undefined}
|
|
259
|
+
data-clearable={clearable && current && !disabled ? "" : undefined}
|
|
260
|
+
role="combobox"
|
|
261
|
+
aria-expanded={open}
|
|
262
|
+
aria-controls={listId}
|
|
263
|
+
aria-autocomplete="list"
|
|
264
|
+
aria-activedescendant={open && count ? `${listId}-${active}` : undefined}
|
|
265
|
+
aria-invalid={error ? true : undefined}
|
|
266
|
+
aria-describedby={fieldDescription(fieldId, { hint, error })}
|
|
267
|
+
value={query}
|
|
268
|
+
placeholder={placeholder}
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
autoComplete="off"
|
|
271
|
+
spellCheck={false}
|
|
272
|
+
onChange={(event) => {
|
|
273
|
+
typed.current = true;
|
|
274
|
+
setQuery(event.target.value);
|
|
275
|
+
setActive(0);
|
|
276
|
+
if (!open) openList();
|
|
277
|
+
}}
|
|
278
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
279
|
+
onClick={() => (open ? undefined : openList())}
|
|
280
|
+
onKeyDown={onKeyDown}
|
|
281
|
+
onBlur={() => {
|
|
282
|
+
setQuery(chosen?.label ?? "");
|
|
283
|
+
close();
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
{clearable && current && !disabled ? (
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className="ml-combobox-clear"
|
|
290
|
+
aria-label="Clear"
|
|
291
|
+
tabIndex={-1}
|
|
292
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
293
|
+
onClick={() => {
|
|
294
|
+
commit(null);
|
|
295
|
+
setQuery("");
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<IconX aria-hidden="true" size="0.9em" />
|
|
299
|
+
</button>
|
|
300
|
+
) : null}
|
|
301
|
+
<IconChevronDown aria-hidden="true" size="1.1em" className="ml-combobox-chevron" />
|
|
302
|
+
{open ? (
|
|
303
|
+
<ComboboxList
|
|
304
|
+
id={listId}
|
|
305
|
+
options={shown}
|
|
306
|
+
active={active}
|
|
307
|
+
onActiveChange={setActive}
|
|
308
|
+
onPick={pick}
|
|
309
|
+
isSelected={(option) => option.value === current}
|
|
310
|
+
create={create}
|
|
311
|
+
empty={emptyMessage}
|
|
312
|
+
side={side}
|
|
313
|
+
label={typeof label === "string" ? label : undefined}
|
|
314
|
+
grouped={!typed.current}
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
</Field>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { IconSearch } from "@mlola-ui/icons";
|
|
5
|
+
import { useControllableState } from "../_internal/react";
|
|
6
|
+
import { revealIn } from "../_internal/scroll";
|
|
7
|
+
import { Kbd, Shortcut } from "../kbd/kbd";
|
|
8
|
+
import { Modal } from "../modal/modal";
|
|
9
|
+
import { matchCommand } from "../_internal/match";
|
|
10
|
+
|
|
11
|
+
export interface CommandItem {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
/** e.g. "mod+shift+p", shown on the row. */
|
|
17
|
+
shortcut?: string;
|
|
18
|
+
/** Extra words that should find this command. */
|
|
19
|
+
keywords?: string[];
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
onSelect: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CommandGroup {
|
|
25
|
+
heading: string;
|
|
26
|
+
items: CommandItem[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CommandMenuProps {
|
|
30
|
+
groups: CommandGroup[];
|
|
31
|
+
open?: boolean;
|
|
32
|
+
defaultOpen?: boolean;
|
|
33
|
+
onOpenChange?: (open: boolean) => void;
|
|
34
|
+
/** Toggles the menu from anywhere. `null` disables it. */
|
|
35
|
+
hotkey?: string | null;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesHotkey(event: KeyboardEvent, hotkey: string) {
|
|
41
|
+
const parts = hotkey.toLowerCase().split("+");
|
|
42
|
+
const key = parts[parts.length - 1];
|
|
43
|
+
const wantsMod = parts.includes("mod");
|
|
44
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
45
|
+
return event.key.toLowerCase() === key && mod === wantsMod && event.shiftKey === parts.includes("shift") && event.altKey === parts.includes("alt");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Highlight({ text, positions }: { text: string; positions: number[] }) {
|
|
49
|
+
if (!positions.length) return <>{text}</>;
|
|
50
|
+
const marked = new Set(positions);
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{[...text].map((char, index) =>
|
|
54
|
+
marked.has(index) ? (
|
|
55
|
+
<mark key={index} className="ml-command-match">
|
|
56
|
+
{char}
|
|
57
|
+
</mark>
|
|
58
|
+
) : (
|
|
59
|
+
<React.Fragment key={index}>{char}</React.Fragment>
|
|
60
|
+
),
|
|
61
|
+
)}
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The ⌘K palette: every action a product offers, one keystroke away. Type to
|
|
68
|
+
* rank commands (prefix, word start, substring, then letters in order), move
|
|
69
|
+
* with the arrows, run with Enter.
|
|
70
|
+
*/
|
|
71
|
+
export function CommandMenu({ groups, open, defaultOpen = false, onOpenChange, hotkey = "mod+k", placeholder = "Type a command or search…", label = "Command menu" }: CommandMenuProps) {
|
|
72
|
+
const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
|
|
73
|
+
const [query, setQuery] = React.useState("");
|
|
74
|
+
const [active, setActive] = React.useState(0);
|
|
75
|
+
const listId = React.useId();
|
|
76
|
+
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (!hotkey) return;
|
|
79
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
80
|
+
if (matchesHotkey(event, hotkey)) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
setOpen(!isOpen);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
window.addEventListener("keydown", onKeyDown);
|
|
86
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
87
|
+
}, [hotkey, isOpen, setOpen]);
|
|
88
|
+
|
|
89
|
+
const results = React.useMemo(() => {
|
|
90
|
+
return groups
|
|
91
|
+
.map((group) => ({
|
|
92
|
+
heading: group.heading,
|
|
93
|
+
items: group.items
|
|
94
|
+
.map((item) => ({ item, match: matchCommand(item.label, query, item.keywords) }))
|
|
95
|
+
.filter((entry): entry is { item: CommandItem; match: NonNullable<ReturnType<typeof matchCommand>> } => entry.match !== null)
|
|
96
|
+
.sort((left, right) => (query.trim() ? right.match.score - left.match.score : 0)),
|
|
97
|
+
}))
|
|
98
|
+
.filter((group) => group.items.length > 0);
|
|
99
|
+
}, [groups, query]);
|
|
100
|
+
const flat = results.flatMap((group) => group.items);
|
|
101
|
+
const current = Math.min(active, Math.max(0, flat.length - 1));
|
|
102
|
+
|
|
103
|
+
const close = () => {
|
|
104
|
+
setOpen(false);
|
|
105
|
+
setQuery("");
|
|
106
|
+
setActive(0);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const run = (item: CommandItem | undefined) => {
|
|
110
|
+
if (!item || item.disabled) return;
|
|
111
|
+
close();
|
|
112
|
+
item.onSelect();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let index = -1;
|
|
116
|
+
return (
|
|
117
|
+
<Modal open={isOpen} onClose={close} label={label} size="md" className="ml-command">
|
|
118
|
+
<div className="ml-command-search">
|
|
119
|
+
<IconSearch aria-hidden="true" size="1.1em" />
|
|
120
|
+
<input
|
|
121
|
+
className="ml-command-input"
|
|
122
|
+
role="combobox"
|
|
123
|
+
aria-expanded="true"
|
|
124
|
+
aria-controls={listId}
|
|
125
|
+
aria-activedescendant={flat.length ? `${listId}-${current}` : undefined}
|
|
126
|
+
aria-autocomplete="list"
|
|
127
|
+
aria-label={label}
|
|
128
|
+
placeholder={placeholder}
|
|
129
|
+
value={query}
|
|
130
|
+
autoFocus
|
|
131
|
+
onChange={(event) => {
|
|
132
|
+
setQuery(event.target.value);
|
|
133
|
+
setActive(0);
|
|
134
|
+
}}
|
|
135
|
+
onKeyDown={(event) => {
|
|
136
|
+
if (event.key === "ArrowDown") setActive((current + 1) % Math.max(1, flat.length));
|
|
137
|
+
else if (event.key === "ArrowUp") setActive((current - 1 + flat.length) % Math.max(1, flat.length));
|
|
138
|
+
else if (event.key === "Home") setActive(0);
|
|
139
|
+
else if (event.key === "End") setActive(Math.max(0, flat.length - 1));
|
|
140
|
+
else if (event.key === "Enter") run(flat[current]?.item);
|
|
141
|
+
else return;
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div id={listId} role="listbox" aria-label="Commands" className="ml-command-list">
|
|
147
|
+
{results.map((group) => (
|
|
148
|
+
<div key={group.heading} role="group" aria-label={group.heading} className="ml-command-group">
|
|
149
|
+
<p className="ml-command-heading" aria-hidden="true">
|
|
150
|
+
{group.heading}
|
|
151
|
+
</p>
|
|
152
|
+
{group.items.map(({ item, match }) => {
|
|
153
|
+
index += 1;
|
|
154
|
+
const position = index;
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
key={item.id}
|
|
158
|
+
id={`${listId}-${position}`}
|
|
159
|
+
role="option"
|
|
160
|
+
aria-selected={position === current}
|
|
161
|
+
aria-disabled={item.disabled || undefined}
|
|
162
|
+
className="ml-command-item"
|
|
163
|
+
onPointerMove={() => setActive(position)}
|
|
164
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
165
|
+
onClick={() => run(item)}
|
|
166
|
+
ref={(node) => {
|
|
167
|
+
if (position === current && node) revealIn(node.closest<HTMLElement>(".ml-command-list"), node);
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{item.icon ? <span className="ml-command-icon" aria-hidden="true">{item.icon}</span> : null}
|
|
171
|
+
<span className="ml-command-text">
|
|
172
|
+
<span className="ml-command-label">
|
|
173
|
+
<Highlight text={item.label} positions={match.positions} />
|
|
174
|
+
</span>
|
|
175
|
+
{item.description ? <span className="ml-command-description">{item.description}</span> : null}
|
|
176
|
+
</span>
|
|
177
|
+
{item.shortcut ? <Shortcut keys={item.shortcut} /> : null}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
))}
|
|
183
|
+
{flat.length === 0 ? <p className="ml-command-empty">No commands match “{query.trim()}”.</p> : null}
|
|
184
|
+
</div>
|
|
185
|
+
<footer className="ml-command-footer" aria-hidden="true">
|
|
186
|
+
<span>
|
|
187
|
+
<Kbd>↑</Kbd>
|
|
188
|
+
<Kbd>↓</Kbd> to move
|
|
189
|
+
</span>
|
|
190
|
+
<span>
|
|
191
|
+
<Kbd>↵</Kbd> to run
|
|
192
|
+
</span>
|
|
193
|
+
<span>
|
|
194
|
+
<Kbd>esc</Kbd> to close
|
|
195
|
+
</span>
|
|
196
|
+
</footer>
|
|
197
|
+
</Modal>
|
|
198
|
+
);
|
|
199
|
+
}
|