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