@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,248 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { rovingIndex } from "@mlola-ui/behavior/logic";
5
+ import { IconChevronLeft, IconChevronRight } from "@mlola-ui/icons";
6
+ import { cx, useControllableState } from "../_internal/react";
7
+
8
+ export interface CarouselProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
9
+ /** One slide per entry. */
10
+ items: React.ReactNode[];
11
+ /** Accessible name for the carousel and its indicators. */
12
+ label?: string;
13
+ /** Accessible name per slide; defaults to “Slide N”. */
14
+ itemLabels?: string[];
15
+ /** Arrows wrap past the ends. Defaults to true. */
16
+ loop?: boolean;
17
+ /** The first visible slide. */
18
+ value?: number;
19
+ defaultValue?: number;
20
+ onValueChange?: (index: number) => void;
21
+ /** Slides visible at once. Defaults to 1. */
22
+ perView?: number;
23
+ /** The narrowest a slide may get; on small screens fewer slides show instead. */
24
+ minSlideWidth?: string;
25
+ /** How much of the next slide peeks in, as a CSS length, hinting there is more. */
26
+ peek?: string;
27
+ /** Milliseconds between slides; off by default, paused on hover, focus, a hidden tab and reduced motion. */
28
+ autoplay?: number;
29
+ /** Dots under the slides, a counter, or nothing. */
30
+ indicators?: "dots" | "counter" | "none";
31
+ /** Arrows over the slide edges. Defaults to true. */
32
+ arrows?: boolean;
33
+ }
34
+
35
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
36
+
37
+ /**
38
+ * Slides on a native scroll-snap track: swipe on touch, scroll with a
39
+ * trackpad, or use the arrows, dots and keyboard. Several slides can show at
40
+ * once, with the next one peeking in. Autoplay shows its progress on the
41
+ * active dot and pauses whenever the reader is looking closer.
42
+ */
43
+ export function Carousel({
44
+ items,
45
+ label = "Carousel",
46
+ itemLabels,
47
+ loop = true,
48
+ value,
49
+ defaultValue = 0,
50
+ onValueChange,
51
+ perView = 1,
52
+ minSlideWidth,
53
+ peek,
54
+ autoplay,
55
+ indicators = "dots",
56
+ arrows = true,
57
+ className,
58
+ style,
59
+ ...props
60
+ }: CarouselProps) {
61
+ const count = items.length;
62
+ const view = Math.max(1, Math.min(perView, count));
63
+ // How many slides actually fit, which minSlideWidth can lower on narrow screens.
64
+ const [fits, setFits] = React.useState(Math.floor(view));
65
+ // Positions the first visible slide can take.
66
+ const positions = Math.max(1, count - fits + 1);
67
+ const [requested, setRequested] = useControllableState({ value, defaultValue, onChange: onValueChange });
68
+ const index = clamp(requested, 0, positions - 1);
69
+ const track = React.useRef<HTMLDivElement>(null);
70
+ const dots = React.useRef<Array<HTMLButtonElement | null>>([]);
71
+ const scrolled = React.useRef(index);
72
+ /** While an arrow or dot scrolls the track, passing positions are not reported. */
73
+ const heading = React.useRef<{ target: number; until: number } | null>(null);
74
+ const [paused, setPaused] = React.useState(false);
75
+ const [reduced, setReduced] = React.useState(false);
76
+
77
+ const labelFor = (position: number) => itemLabels?.[position] ?? `Slide ${position + 1}`;
78
+
79
+ const scrollTo = React.useCallback((target: number, smooth = true) => {
80
+ const element = track.current;
81
+ const slide = element?.children[target] as HTMLElement | undefined;
82
+ if (!element || !slide) return;
83
+ scrolled.current = target;
84
+ heading.current = { target, until: Date.now() + 800 };
85
+ // The track is the slides' offset parent, so offsetLeft is the scroll position.
86
+ element.scrollTo({ left: slide.offsetLeft, behavior: smooth && !reduced ? "smooth" : "auto" });
87
+ }, [reduced]);
88
+
89
+ const go = (next: number) => {
90
+ const target = loop ? ((next % positions) + positions) % positions : clamp(next, 0, positions - 1);
91
+ setRequested(target);
92
+ scrollTo(target);
93
+ };
94
+
95
+ // A value set from outside scrolls the track there.
96
+ React.useEffect(() => {
97
+ if (index !== scrolled.current) scrollTo(index);
98
+ }, [index, scrollTo]);
99
+
100
+ // The track's scroll position decides the current slide, whatever moved it.
101
+ const onScroll = () => {
102
+ const element = track.current;
103
+ if (!element) return;
104
+ const first = element.children[0] as HTMLElement | undefined;
105
+ const second = element.children[1] as HTMLElement | undefined;
106
+ const stride = first && second ? second.offsetLeft - first.offsetLeft : element.clientWidth;
107
+ const at = clamp(Math.round(element.scrollLeft / Math.max(1, stride)), 0, positions - 1);
108
+ // At the very end, the last position is current even if it cannot snap to the start.
109
+ const end = element.scrollLeft + element.clientWidth >= element.scrollWidth - 2 ? positions - 1 : at;
110
+ const flight = heading.current;
111
+ if (flight && Date.now() < flight.until && end !== flight.target) return;
112
+ heading.current = null;
113
+ if (end !== scrolled.current) {
114
+ scrolled.current = end;
115
+ setRequested(end);
116
+ }
117
+ };
118
+
119
+ React.useEffect(() => {
120
+ const element = track.current;
121
+ if (!element || typeof ResizeObserver === "undefined") return;
122
+ const measure = () => {
123
+ const first = element.children[0] as HTMLElement | undefined;
124
+ const second = element.children[1] as HTMLElement | undefined;
125
+ if (!first) return;
126
+ const stride = second ? second.offsetLeft - first.offsetLeft : first.offsetWidth;
127
+ setFits(Math.max(1, Math.min(count, Math.floor((element.clientWidth + 1) / Math.max(1, stride)) || 1)));
128
+ };
129
+ measure();
130
+ const observer = new ResizeObserver(measure);
131
+ observer.observe(element);
132
+ return () => observer.disconnect();
133
+ }, [count, view, minSlideWidth]);
134
+
135
+ React.useEffect(() => {
136
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
137
+ const update = () => setReduced(query.matches);
138
+ update();
139
+ query.addEventListener("change", update);
140
+ return () => query.removeEventListener("change", update);
141
+ }, []);
142
+
143
+ // Autoplay: one timer per slide, stopped while paused or hidden.
144
+ React.useEffect(() => {
145
+ if (!autoplay || paused || reduced || positions < 2) return;
146
+ const timer = window.setTimeout(() => {
147
+ if (document.visibilityState === "visible") go(index + 1);
148
+ }, autoplay);
149
+ return () => window.clearTimeout(timer);
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [autoplay, paused, reduced, index, positions]);
152
+
153
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
154
+ if (event.target !== event.currentTarget) return;
155
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
156
+ event.preventDefault();
157
+ go(index + (event.key === "ArrowRight" ? 1 : -1));
158
+ }
159
+ };
160
+
161
+ const onDotKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
162
+ const target = rovingIndex(positions, index, event.key, { horizontal: true, wrap: loop });
163
+ if (target < 0) return;
164
+ event.preventDefault();
165
+ go(target);
166
+ dots.current[target]?.focus();
167
+ };
168
+
169
+ if (count === 0) return null;
170
+
171
+ const atStart = !loop && index === 0;
172
+ const atEnd = !loop && index === positions - 1;
173
+ const playing = Boolean(autoplay) && !paused && !reduced && positions > 1;
174
+
175
+ return (
176
+ <div
177
+ {...props}
178
+ className={cx("ml-carousel", className)}
179
+ role="group"
180
+ aria-roledescription="carousel"
181
+ aria-label={label}
182
+ data-playing={playing || undefined}
183
+ style={{ ...style, "--ml-carousel-per-view": view, "--ml-carousel-peek": peek ?? "0px", "--ml-carousel-min": minSlideWidth ?? "0px", "--ml-carousel-autoplay": `${autoplay ?? 0}ms` } as React.CSSProperties}
184
+ onPointerEnter={() => setPaused(true)}
185
+ onPointerLeave={() => setPaused(false)}
186
+ onFocus={() => setPaused(true)}
187
+ onBlur={(event) => {
188
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) setPaused(false);
189
+ }}
190
+ >
191
+ <div className="ml-carousel-viewport">
192
+ <div ref={track} className="ml-carousel-track" tabIndex={0} aria-label={`${label}, use the arrow keys`} onScroll={onScroll} onKeyDown={onKeyDown}>
193
+ {items.map((item, position) => (
194
+ <div
195
+ key={position}
196
+ className="ml-carousel-slide"
197
+ role="group"
198
+ aria-roledescription="slide"
199
+ aria-label={`${position + 1} of ${count}: ${labelFor(position)}`}
200
+ data-current={(position >= index && position < index + fits) || undefined}
201
+ >
202
+ {item}
203
+ </div>
204
+ ))}
205
+ </div>
206
+ {arrows && positions > 1 ? (
207
+ <>
208
+ <button type="button" className="ml-carousel-arrow" data-side="previous" aria-label="Previous slide" disabled={atStart} onClick={() => go(index - 1)}>
209
+ <IconChevronLeft aria-hidden="true" size="1.1em" />
210
+ </button>
211
+ <button type="button" className="ml-carousel-arrow" data-side="next" aria-label="Next slide" disabled={atEnd} onClick={() => go(index + 1)}>
212
+ <IconChevronRight aria-hidden="true" size="1.1em" />
213
+ </button>
214
+ </>
215
+ ) : null}
216
+ </div>
217
+
218
+ {indicators === "dots" && positions > 1 ? (
219
+ <div className="ml-carousel-dots" role="group" aria-label={`Choose ${label.toLowerCase()}`} onKeyDown={onDotKeyDown}>
220
+ {Array.from({ length: positions }, (_, position) => {
221
+ const active = position === index;
222
+ return (
223
+ <button
224
+ key={position}
225
+ ref={(node) => {
226
+ dots.current[position] = node;
227
+ }}
228
+ type="button"
229
+ className="ml-carousel-dot"
230
+ aria-current={active ? "true" : undefined}
231
+ aria-label={labelFor(position)}
232
+ tabIndex={active ? 0 : -1}
233
+ onClick={() => go(position)}
234
+ >
235
+ {/* Keyed by index so the autoplay fill restarts on every slide. */}
236
+ {active && playing ? <span key={index} className="ml-carousel-dot-fill" /> : null}
237
+ </button>
238
+ );
239
+ })}
240
+ </div>
241
+ ) : indicators === "counter" ? (
242
+ <p className="ml-carousel-counter" aria-live="polite">
243
+ {index + 1} / {positions}
244
+ </p>
245
+ ) : null}
246
+ </div>
247
+ );
248
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { composeRefs, cx, useControllableState } from "../_internal/react";
5
+ import { IconCheck, IconMinus } from "@mlola-ui/icons";
6
+
7
+ interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> {
8
+ checked?: boolean;
9
+ defaultChecked?: boolean;
10
+ onCheckedChange?: (checked: boolean) => void;
11
+ indeterminate?: boolean;
12
+ label?: React.ReactNode;
13
+ description?: string;
14
+ error?: string;
15
+ }
16
+
17
+ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
18
+ ({ checked, defaultChecked = false, onCheckedChange, indeterminate = false, disabled = false, required = false, label, description, error, id, className, "aria-describedby": describedBy, ...props }, ref) => {
19
+ const [current, setCurrent] = useControllableState({ value: checked, defaultValue: defaultChecked, onChange: onCheckedChange });
20
+ const generatedId = React.useId();
21
+ const inputId = id ?? generatedId;
22
+ const descriptionId = `${inputId}-description`;
23
+ const errorId = `${inputId}-error`;
24
+ const inputRef = React.useRef<HTMLInputElement>(null);
25
+ React.useEffect(() => {
26
+ if (inputRef.current) inputRef.current.indeterminate = indeterminate;
27
+ }, [indeterminate]);
28
+ const state = indeterminate ? "indeterminate" : current ? "checked" : "unchecked";
29
+ return (
30
+ <div className={cx("ml-checkbox-field", className)} data-state={state} data-disabled={disabled ? "" : undefined} data-invalid={error ? "" : undefined}>
31
+ <span className="ml-checkbox-control">
32
+ <input
33
+ ref={composeRefs(inputRef, ref)}
34
+ id={inputId}
35
+ type="checkbox"
36
+ checked={current}
37
+ disabled={disabled}
38
+ required={required}
39
+ aria-checked={indeterminate ? "mixed" : current}
40
+ aria-invalid={error ? true : undefined}
41
+ aria-describedby={[describedBy, description && descriptionId, error && errorId].filter(Boolean).join(" ") || undefined}
42
+ className="ml-checkbox-input"
43
+ onChange={(event) => setCurrent(event.target.checked)}
44
+ {...props}
45
+ />
46
+ <span aria-hidden="true" className="ml-checkbox-indicator">
47
+ {indeterminate ? <IconMinus aria-hidden="true" size="0.75em" /> : current ? <IconCheck aria-hidden="true" size="0.75em" /> : null}
48
+ </span>
49
+ </span>
50
+ {label || description || error ? (
51
+ <span className="ml-checkbox-copy">
52
+ {label ? (
53
+ <label htmlFor={inputId} className="ml-checkbox-label">
54
+ {label}{required ? <span aria-hidden="true" className="ml-required-mark">*</span> : null}
55
+ </label>
56
+ ) : null}
57
+ {description ? <span id={descriptionId} className="ml-checkbox-description">{description}</span> : null}
58
+ {error ? <span id={errorId} role="alert" className="ml-checkbox-error">{error}</span> : null}
59
+ </span>
60
+ ) : null}
61
+ </div>
62
+ );
63
+ }
64
+ );
65
+ Checkbox.displayName = "Checkbox";
66
+ export { Checkbox };
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconPen } from "@mlola-ui/icons";
5
+ import { Field, fieldDescription } from "../input/input";
6
+ import { Popover } from "../popover/popover";
7
+ import { cx } from "../_internal/react";
8
+ import { contrastRatio, formatColor, hsvToRgb, parseColor, rgbToHsv, type ColorFormat, type HSVA, type RGBA } from "./color";
9
+
10
+ export type { ColorFormat, RGBA };
11
+ export { contrastRatio, formatColor, parseColor } from "./color";
12
+
13
+ export interface ColorPickerProps {
14
+ /** Any colour the picker reads: hex, rgb() or oklch(). */
15
+ value?: string;
16
+ defaultValue?: string;
17
+ /** Called with the colour in `format`. */
18
+ onValueChange?: (value: string) => void;
19
+ /** The format the value comes back in. */
20
+ format?: ColorFormat;
21
+ /** Offer an opacity slider. */
22
+ alpha?: boolean;
23
+ /** Colours one click away, such as the brand palette. */
24
+ swatches?: string[];
25
+ label?: React.ReactNode;
26
+ hint?: React.ReactNode;
27
+ error?: React.ReactNode;
28
+ disabled?: boolean;
29
+ id?: string;
30
+ className?: string;
31
+ }
32
+
33
+ const WHITE: RGBA = { r: 255, g: 255, b: 255, a: 1 };
34
+ const BLACK: RGBA = { r: 0, g: 0, b: 0, a: 1 };
35
+ const clamp = (value: number) => Math.min(1, Math.max(0, value));
36
+
37
+ /** Follow a pointer across an element from press to release, as fractions of its box. */
38
+ function usePointerTrack(onMove: (x: number, y: number) => void) {
39
+ const read = (event: React.PointerEvent<HTMLElement>) => {
40
+ const box = event.currentTarget.getBoundingClientRect();
41
+ onMove(clamp((event.clientX - box.left) / box.width), clamp((event.clientY - box.top) / box.height));
42
+ };
43
+ return {
44
+ onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
45
+ if (event.button !== 0) return;
46
+ event.currentTarget.setPointerCapture(event.pointerId);
47
+ event.currentTarget.focus();
48
+ read(event);
49
+ },
50
+ onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
51
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) read(event);
52
+ },
53
+ };
54
+ }
55
+
56
+ /** A horizontal slider for hue or opacity: pointer, arrows (Shift for ten), Home and End. */
57
+ function Track({ label, value, max, onChange, background, valueText, className }: { label: string; value: number; max: number; onChange: (value: number) => void; background: string; valueText: string; className: string }) {
58
+ const track = usePointerTrack((x) => onChange(x * max));
59
+ return (
60
+ <div
61
+ className={cx("ml-color-picker-track", className)}
62
+ role="slider"
63
+ tabIndex={0}
64
+ aria-label={label}
65
+ aria-valuemin={0}
66
+ aria-valuemax={max}
67
+ aria-valuenow={Math.round(value)}
68
+ aria-valuetext={valueText}
69
+ style={{ "--ml-color-track": background, "--ml-color-at": `${(value / max) * 100}%` } as React.CSSProperties}
70
+ onKeyDown={(event) => {
71
+ const step = (event.shiftKey ? 10 : 1) * (max / 100);
72
+ const keys: Record<string, number> = { ArrowRight: value + step, ArrowUp: value + step, ArrowLeft: value - step, ArrowDown: value - step, Home: 0, End: max };
73
+ if (event.key in keys) {
74
+ event.preventDefault();
75
+ onChange(Math.min(max, Math.max(0, keys[event.key])));
76
+ }
77
+ }}
78
+ {...track}
79
+ >
80
+ <span className="ml-color-picker-thumb" aria-hidden="true" />
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A colour field: a swatch and its value that open a picker with a
87
+ * saturation and brightness area, hue and opacity sliders, a typed value in
88
+ * hex, rgb or oklch, the system eyedropper where the browser has one, preset
89
+ * swatches, and the colour's contrast against white and black.
90
+ */
91
+ export function ColorPicker({ value, defaultValue = "#0a84ff", onValueChange, format = "hex", alpha = false, swatches, label, hint, error, disabled, id, className }: ColorPickerProps) {
92
+ const autoId = React.useId();
93
+ const fieldId = id ?? autoId;
94
+ const [inner, setInner] = React.useState(defaultValue);
95
+ const current = value ?? inner;
96
+ const rgba = parseColor(current) ?? parseColor(defaultValue) ?? BLACK;
97
+ // HSV is kept locally so hue survives greys and black, where RGB forgets it.
98
+ const [hsv, setHsv] = React.useState<HSVA>(() => rgbToHsv(rgba));
99
+ const [view, setView] = React.useState<ColorFormat>(format);
100
+ const [draft, setDraft] = React.useState<string | null>(null);
101
+ const [open, setOpen] = React.useState(false);
102
+ const shownHex = formatColor(rgba, "hex");
103
+
104
+ // Follow a value set from outside, unless it is the colour already shown.
105
+ React.useEffect(() => {
106
+ if (formatColor(hsvToRgb(hsv), "hex") !== shownHex) setHsv(rgbToHsv(rgba));
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [shownHex]);
109
+
110
+ const emit = (next: HSVA) => {
111
+ setHsv(next);
112
+ const text = formatColor(hsvToRgb(next), format);
113
+ if (value === undefined) setInner(text);
114
+ onValueChange?.(text);
115
+ };
116
+
117
+ const emitRgb = (next: RGBA) => {
118
+ const converted = rgbToHsv(next);
119
+ emit(converted.s === 0 || converted.v === 0 ? { ...converted, h: hsv.h } : converted);
120
+ };
121
+
122
+ const area = usePointerTrack((x, y) => emit({ ...hsv, s: x, v: 1 - y }));
123
+ const solid = formatColor({ ...rgba, a: 1 }, "hex");
124
+ const onWhite = contrastRatio(rgba, WHITE);
125
+ const onBlack = contrastRatio(rgba, BLACK);
126
+ const EyeDropper = typeof window !== "undefined" ? (window as unknown as { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper : undefined;
127
+
128
+ const trigger = (
129
+ <button
130
+ id={fieldId}
131
+ type="button"
132
+ className="ml-input ml-color-picker-trigger"
133
+ disabled={disabled}
134
+ aria-invalid={error ? true : undefined}
135
+ aria-describedby={fieldDescription(fieldId, { hint, error })}
136
+ aria-label={`${typeof label === "string" ? label : "Colour"}: ${formatColor(rgba, format)}`}
137
+ >
138
+ <span className="ml-color-picker-swatch" style={{ "--ml-color": formatColor(rgba, "rgb") } as React.CSSProperties} aria-hidden="true" />
139
+ <span className="ml-color-picker-value">{formatColor(rgba, format)}</span>
140
+ </button>
141
+ );
142
+
143
+ return (
144
+ <Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} className={cx("ml-color-picker", className)}>
145
+ <Popover trigger={trigger} label={typeof label === "string" ? `Choose ${label.toLowerCase()}` : "Choose a colour"} open={open} onOpenChange={setOpen} className="ml-color-picker-popover">
146
+ <div className="ml-color-picker-panel">
147
+ <div
148
+ className="ml-color-picker-area"
149
+ role="slider"
150
+ tabIndex={0}
151
+ aria-label="Saturation and brightness"
152
+ aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
153
+ style={{ "--ml-color-hue": `hsl(${hsv.h} 100% 50%)`, "--ml-color-x": `${hsv.s * 100}%`, "--ml-color-y": `${(1 - hsv.v) * 100}%` } as React.CSSProperties}
154
+ onKeyDown={(event) => {
155
+ const step = event.shiftKey ? 0.1 : 0.01;
156
+ const moves: Record<string, Partial<HSVA>> = { ArrowRight: { s: clamp(hsv.s + step) }, ArrowLeft: { s: clamp(hsv.s - step) }, ArrowUp: { v: clamp(hsv.v + step) }, ArrowDown: { v: clamp(hsv.v - step) } };
157
+ if (moves[event.key]) {
158
+ event.preventDefault();
159
+ emit({ ...hsv, ...moves[event.key] });
160
+ }
161
+ }}
162
+ {...area}
163
+ >
164
+ <span className="ml-color-picker-thumb" aria-hidden="true" style={{ background: solid }} />
165
+ </div>
166
+ <div className="ml-color-picker-sliders">
167
+ <Track
168
+ className="ml-color-picker-hue"
169
+ label="Hue"
170
+ value={hsv.h}
171
+ max={360}
172
+ valueText={`${Math.round(hsv.h)} degrees`}
173
+ background="linear-gradient(90deg, #f00, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00)"
174
+ onChange={(h) => emit({ ...hsv, h })}
175
+ />
176
+ {alpha ? (
177
+ <Track
178
+ className="ml-color-picker-alpha"
179
+ label="Opacity"
180
+ value={hsv.a * 100}
181
+ max={100}
182
+ valueText={`${Math.round(hsv.a * 100)}%`}
183
+ background={`linear-gradient(90deg, transparent, ${solid})`}
184
+ onChange={(a) => emit({ ...hsv, a: a / 100 })}
185
+ />
186
+ ) : null}
187
+ </div>
188
+ <div className="ml-color-picker-row">
189
+ <button type="button" className="ml-color-picker-format" aria-label={`Format: ${view}. Switch`} onClick={() => setView(view === "hex" ? "rgb" : view === "rgb" ? "oklch" : "hex")}>
190
+ {view.toUpperCase()}
191
+ </button>
192
+ <input
193
+ className="ml-color-picker-text"
194
+ aria-label={`Colour as ${view}`}
195
+ value={draft ?? formatColor(rgba, view)}
196
+ spellCheck={false}
197
+ onChange={(event) => {
198
+ setDraft(event.target.value);
199
+ const parsed = parseColor(event.target.value);
200
+ if (parsed) emitRgb(alpha ? parsed : { ...parsed, a: 1 });
201
+ }}
202
+ onBlur={() => setDraft(null)}
203
+ onKeyDown={(event) => {
204
+ if (event.key === "Enter") setDraft(null);
205
+ }}
206
+ />
207
+ {EyeDropper ? (
208
+ <button
209
+ type="button"
210
+ className="ml-color-picker-eyedropper"
211
+ aria-label="Pick a colour from the screen"
212
+ onClick={async () => {
213
+ try {
214
+ const result = await new EyeDropper().open();
215
+ const parsed = parseColor(result.sRGBHex);
216
+ if (parsed) emitRgb(parsed);
217
+ } catch {
218
+ // The reader pressed Escape.
219
+ }
220
+ }}
221
+ >
222
+ <IconPen aria-hidden="true" size="1em" />
223
+ </button>
224
+ ) : null}
225
+ </div>
226
+ {swatches?.length ? (
227
+ <div className="ml-color-picker-swatches" role="group" aria-label="Presets">
228
+ {swatches.map((swatch) => {
229
+ const parsed = parseColor(swatch);
230
+ if (!parsed) return null;
231
+ const hex = formatColor(parsed, "hex");
232
+ return (
233
+ <button
234
+ key={swatch}
235
+ type="button"
236
+ className="ml-color-picker-preset"
237
+ aria-label={hex}
238
+ aria-pressed={hex === shownHex}
239
+ style={{ "--ml-color": hex } as React.CSSProperties}
240
+ onClick={() => emitRgb(parsed)}
241
+ />
242
+ );
243
+ })}
244
+ </div>
245
+ ) : null}
246
+ <p className="ml-color-picker-contrast">
247
+ <span className="ml-color-picker-sample" style={{ "--ml-color": solid, color: onWhite >= onBlack ? "#fff" : "#000" } as React.CSSProperties} aria-hidden="true">
248
+ Aa
249
+ </span>
250
+ <span>
251
+ {onWhite.toFixed(1)}:1 on white <b data-pass={onWhite >= 4.5 || undefined}>{onWhite >= 4.5 ? "AA" : onWhite >= 3 ? "AA large" : "Fails"}</b>
252
+ </span>
253
+ <span>
254
+ {onBlack.toFixed(1)}:1 on black <b data-pass={onBlack >= 4.5 || undefined}>{onBlack >= 4.5 ? "AA" : onBlack >= 3 ? "AA large" : "Fails"}</b>
255
+ </span>
256
+ </p>
257
+ </div>
258
+ </Popover>
259
+ </Field>
260
+ );
261
+ }