@mlola-ui/registry 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/components/accordion.json +54 -0
  4. package/components/activity-heatmap.json +54 -0
  5. package/components/alert.json +56 -0
  6. package/components/app-shell.json +53 -0
  7. package/components/avatar.json +54 -0
  8. package/components/badge.json +62 -0
  9. package/components/breadcrumb.json +49 -0
  10. package/components/button.json +62 -0
  11. package/components/calendar.json +55 -0
  12. package/components/card.json +53 -0
  13. package/components/carousel.json +52 -0
  14. package/components/checkbox.json +50 -0
  15. package/components/color-picker.json +60 -0
  16. package/components/combobox.json +64 -0
  17. package/components/command-menu.json +64 -0
  18. package/components/context-menu.json +61 -0
  19. package/components/date-picker.json +59 -0
  20. package/components/dropdown-menu.json +51 -0
  21. package/components/dropzone.json +45 -0
  22. package/components/empty-state.json +45 -0
  23. package/components/hover-card.json +59 -0
  24. package/components/input.json +56 -0
  25. package/components/kbd.json +45 -0
  26. package/components/modal.json +63 -0
  27. package/components/number-input.json +58 -0
  28. package/components/otp-input.json +49 -0
  29. package/components/pagination.json +50 -0
  30. package/components/popover.json +58 -0
  31. package/components/priority-icon.json +53 -0
  32. package/components/progress.json +57 -0
  33. package/components/radio-group.json +48 -0
  34. package/components/rating.json +48 -0
  35. package/components/resizable.json +46 -0
  36. package/components/segmented-control.json +51 -0
  37. package/components/select.json +67 -0
  38. package/components/sheet.json +66 -0
  39. package/components/skeleton.json +48 -0
  40. package/components/slider.json +53 -0
  41. package/components/status-icon.json +53 -0
  42. package/components/stepper.json +50 -0
  43. package/components/table.json +53 -0
  44. package/components/tabs.json +54 -0
  45. package/components/tag-input.json +53 -0
  46. package/components/textarea.json +48 -0
  47. package/components/time-picker.json +58 -0
  48. package/components/timeline.json +54 -0
  49. package/components/toast.json +56 -0
  50. package/components/toggle.json +52 -0
  51. package/components/tooltip.json +54 -0
  52. package/components/tour.json +62 -0
  53. package/components/virtual-list.json +54 -0
  54. package/generated/catalog.ts +2037 -0
  55. package/index.json +413 -0
  56. package/package.json +47 -0
  57. package/schema/mlola.config.schema.json +51 -0
  58. package/schema/registry-index.schema.json +116 -0
  59. package/schema/registry-item.schema.json +80 -0
  60. package/snapshot/index.json +2938 -0
  61. package/snapshot/items/components/accordion.json +54 -0
  62. package/snapshot/items/components/activity-heatmap.json +54 -0
  63. package/snapshot/items/components/alert.json +56 -0
  64. package/snapshot/items/components/app-shell.json +53 -0
  65. package/snapshot/items/components/avatar.json +54 -0
  66. package/snapshot/items/components/badge.json +62 -0
  67. package/snapshot/items/components/breadcrumb.json +49 -0
  68. package/snapshot/items/components/button.json +62 -0
  69. package/snapshot/items/components/calendar.json +55 -0
  70. package/snapshot/items/components/card.json +53 -0
  71. package/snapshot/items/components/carousel.json +52 -0
  72. package/snapshot/items/components/checkbox.json +50 -0
  73. package/snapshot/items/components/color-picker.json +60 -0
  74. package/snapshot/items/components/combobox.json +64 -0
  75. package/snapshot/items/components/command-menu.json +64 -0
  76. package/snapshot/items/components/context-menu.json +61 -0
  77. package/snapshot/items/components/date-picker.json +59 -0
  78. package/snapshot/items/components/dropdown-menu.json +51 -0
  79. package/snapshot/items/components/dropzone.json +45 -0
  80. package/snapshot/items/components/empty-state.json +45 -0
  81. package/snapshot/items/components/hover-card.json +59 -0
  82. package/snapshot/items/components/input.json +56 -0
  83. package/snapshot/items/components/kbd.json +45 -0
  84. package/snapshot/items/components/modal.json +63 -0
  85. package/snapshot/items/components/number-input.json +58 -0
  86. package/snapshot/items/components/otp-input.json +49 -0
  87. package/snapshot/items/components/pagination.json +50 -0
  88. package/snapshot/items/components/popover.json +58 -0
  89. package/snapshot/items/components/priority-icon.json +53 -0
  90. package/snapshot/items/components/progress.json +57 -0
  91. package/snapshot/items/components/radio-group.json +48 -0
  92. package/snapshot/items/components/rating.json +48 -0
  93. package/snapshot/items/components/resizable.json +46 -0
  94. package/snapshot/items/components/segmented-control.json +51 -0
  95. package/snapshot/items/components/select.json +67 -0
  96. package/snapshot/items/components/sheet.json +66 -0
  97. package/snapshot/items/components/skeleton.json +48 -0
  98. package/snapshot/items/components/slider.json +53 -0
  99. package/snapshot/items/components/status-icon.json +53 -0
  100. package/snapshot/items/components/stepper.json +50 -0
  101. package/snapshot/items/components/table.json +53 -0
  102. package/snapshot/items/components/tabs.json +54 -0
  103. package/snapshot/items/components/tag-input.json +53 -0
  104. package/snapshot/items/components/textarea.json +48 -0
  105. package/snapshot/items/components/time-picker.json +58 -0
  106. package/snapshot/items/components/timeline.json +54 -0
  107. package/snapshot/items/components/toast.json +56 -0
  108. package/snapshot/items/components/toggle.json +52 -0
  109. package/snapshot/items/components/tooltip.json +54 -0
  110. package/snapshot/items/components/tour.json +62 -0
  111. package/snapshot/items/components/virtual-list.json +54 -0
  112. package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
  113. package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
  114. package/snapshot/source/packages/components/_internal/floating.ts +73 -0
  115. package/snapshot/source/packages/components/_internal/match.ts +39 -0
  116. package/snapshot/source/packages/components/_internal/react.ts +40 -0
  117. package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
  118. package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
  119. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  120. package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
  121. package/snapshot/source/packages/components/alert/alert.tsx +83 -0
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
  124. package/snapshot/source/packages/components/badge/badge.tsx +39 -0
  125. package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  126. package/snapshot/source/packages/components/button/button.tsx +42 -0
  127. package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
  128. package/snapshot/source/packages/components/calendar/dates.ts +163 -0
  129. package/snapshot/source/packages/components/card/card.tsx +39 -0
  130. package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
  131. package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
  132. package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
  133. package/snapshot/source/packages/components/color-picker/color.ts +124 -0
  134. package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
  135. package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
  136. package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
  137. package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
  138. package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  139. package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
  140. package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
  141. package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
  142. package/snapshot/source/packages/components/input/input.tsx +101 -0
  143. package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
  144. package/snapshot/source/packages/components/modal/modal.tsx +100 -0
  145. package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
  146. package/snapshot/source/packages/components/number-input/number.ts +50 -0
  147. package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
  148. package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
  149. package/snapshot/source/packages/components/popover/popover.tsx +107 -0
  150. package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
  151. package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  152. package/snapshot/source/packages/components/progress/progress.tsx +110 -0
  153. package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
  154. package/snapshot/source/packages/components/rating/rating.tsx +94 -0
  155. package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
  156. package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  157. package/snapshot/source/packages/components/select/select.tsx +332 -0
  158. package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
  159. package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
  160. package/snapshot/source/packages/components/slider/slider.tsx +99 -0
  161. package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
  162. package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
  163. package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
  164. package/snapshot/source/packages/components/table/table.tsx +66 -0
  165. package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
  166. package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
  167. package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
  168. package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
  169. package/snapshot/source/packages/components/time-picker/times.ts +76 -0
  170. package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
  171. package/snapshot/source/packages/components/toast/toast.tsx +317 -0
  172. package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
  173. package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
  174. package/snapshot/source/packages/components/tour/tour.tsx +193 -0
  175. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  176. package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
  177. package/snapshot/source-manifest.json +67 -0
@@ -0,0 +1,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
+ }