mlola-ui 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 +72 -0
- package/bin/index.js +4 -0
- package/package.json +44 -0
- package/registry/catalog.json +97 -0
- package/registry/index.json +2938 -0
- package/registry/source/packages/components/_internal/anchor.ts +54 -0
- package/registry/source/packages/components/_internal/dialog.ts +142 -0
- package/registry/source/packages/components/_internal/floating.ts +73 -0
- package/registry/source/packages/components/_internal/match.ts +39 -0
- package/registry/source/packages/components/_internal/react.ts +40 -0
- package/registry/source/packages/components/_internal/scroll.ts +15 -0
- package/registry/source/packages/components/accordion/accordion.tsx +164 -0
- package/registry/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
- package/registry/source/packages/components/activity-heatmap/calendar.ts +92 -0
- package/registry/source/packages/components/alert/alert.tsx +83 -0
- package/registry/source/packages/components/app-shell/app-shell.tsx +186 -0
- package/registry/source/packages/components/avatar/avatar.tsx +56 -0
- package/registry/source/packages/components/badge/badge.tsx +39 -0
- package/registry/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
- package/registry/source/packages/components/button/button.tsx +42 -0
- package/registry/source/packages/components/calendar/calendar.tsx +309 -0
- package/registry/source/packages/components/calendar/dates.ts +163 -0
- package/registry/source/packages/components/card/card.tsx +39 -0
- package/registry/source/packages/components/carousel/carousel.tsx +248 -0
- package/registry/source/packages/components/checkbox/checkbox.tsx +66 -0
- package/registry/source/packages/components/color-picker/color-picker.tsx +261 -0
- package/registry/source/packages/components/color-picker/color.ts +124 -0
- package/registry/source/packages/components/combobox/combobox.tsx +320 -0
- package/registry/source/packages/components/command-menu/command-menu.tsx +199 -0
- package/registry/source/packages/components/context-menu/context-menu.tsx +192 -0
- package/registry/source/packages/components/date-picker/date-picker.tsx +185 -0
- package/registry/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
- package/registry/source/packages/components/dropzone/dropzone.tsx +185 -0
- package/registry/source/packages/components/empty-state/empty-state.tsx +32 -0
- package/registry/source/packages/components/hover-card/hover-card.tsx +103 -0
- package/registry/source/packages/components/input/input.tsx +101 -0
- package/registry/source/packages/components/kbd/kbd.tsx +54 -0
- package/registry/source/packages/components/modal/modal.tsx +100 -0
- package/registry/source/packages/components/number-input/number-input.tsx +184 -0
- package/registry/source/packages/components/number-input/number.ts +50 -0
- package/registry/source/packages/components/otp-input/otp-input.tsx +116 -0
- package/registry/source/packages/components/pagination/pagination.tsx +65 -0
- package/registry/source/packages/components/popover/popover.tsx +107 -0
- package/registry/source/packages/components/priority-icon/labels.ts +13 -0
- package/registry/source/packages/components/priority-icon/priority-icon.tsx +60 -0
- package/registry/source/packages/components/progress/progress.tsx +110 -0
- package/registry/source/packages/components/radio-group/radio-group.tsx +106 -0
- package/registry/source/packages/components/rating/rating.tsx +94 -0
- package/registry/source/packages/components/resizable/resizable.tsx +171 -0
- package/registry/source/packages/components/segmented-control/segmented-control.tsx +78 -0
- package/registry/source/packages/components/select/select.tsx +332 -0
- package/registry/source/packages/components/sheet/sheet.tsx +109 -0
- package/registry/source/packages/components/skeleton/skeleton.tsx +84 -0
- package/registry/source/packages/components/slider/slider.tsx +99 -0
- package/registry/source/packages/components/status-icon/labels.ts +14 -0
- package/registry/source/packages/components/status-icon/status-icon.tsx +76 -0
- package/registry/source/packages/components/stepper/stepper.tsx +87 -0
- package/registry/source/packages/components/table/table.tsx +66 -0
- package/registry/source/packages/components/tabs/tabs.tsx +163 -0
- package/registry/source/packages/components/tag-input/tag-input.tsx +273 -0
- package/registry/source/packages/components/textarea/textarea.tsx +70 -0
- package/registry/source/packages/components/time-picker/time-picker.tsx +286 -0
- package/registry/source/packages/components/time-picker/times.ts +76 -0
- package/registry/source/packages/components/timeline/timeline.tsx +115 -0
- package/registry/source/packages/components/toast/toast.tsx +317 -0
- package/registry/source/packages/components/toggle/toggle.tsx +80 -0
- package/registry/source/packages/components/tooltip/tooltip.tsx +70 -0
- package/registry/source/packages/components/tour/tour.tsx +193 -0
- package/registry/source/packages/components/virtual-list/virtual-list.tsx +121 -0
- package/registry/source/packages/components/virtual-list/virtual.ts +33 -0
- package/registry/source-manifest.json +67 -0
- package/src/cli.js +262 -0
- package/src/config.js +114 -0
- package/src/doctor.js +156 -0
- package/src/installer.js +131 -0
- package/src/pro.js +160 -0
- package/src/registry.js +91 -0
- package/src/theme-pull.js +91 -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
|
+
}
|