@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,317 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconCircleCheck, IconCircleX, IconInfo, IconTriangleAlert, IconX } from "@mlola-ui/icons";
5
+ import { cx } from "../_internal/react";
6
+
7
+ /** The meaning of the message, from the theme's colour roles. */
8
+ export type ToastTone = "neutral" | "info" | "success" | "warning" | "danger";
9
+ export type ToastPosition = "top-right" | "top-center" | "bottom-right" | "bottom-center";
10
+
11
+ interface ToastAction {
12
+ label: string;
13
+ onClick: () => void;
14
+ }
15
+
16
+ interface ToastOptions {
17
+ /** A second line under the message. */
18
+ description?: string;
19
+ /** Milliseconds on screen. 0 or Infinity keeps it until dismissed. Loading toasts never time out. */
20
+ duration?: number;
21
+ position?: ToastPosition;
22
+ tone?: ToastTone;
23
+ action?: ToastAction;
24
+ /** Replaces the tone's icon. `null` shows none. */
25
+ icon?: React.ReactNode;
26
+ }
27
+
28
+ interface ToastItem {
29
+ id: number;
30
+ message: string;
31
+ description?: string;
32
+ tone: ToastTone;
33
+ /** Waiting on work: a spinner, no timeout, until updated. */
34
+ loading: boolean;
35
+ duration: number;
36
+ position: ToastPosition;
37
+ action?: ToastAction;
38
+ icon?: React.ReactNode;
39
+ /** Bumped on every update, so a changed toast restarts its timer. */
40
+ revision: number;
41
+ closing: boolean;
42
+ }
43
+
44
+ type ToastListener = (toasts: ToastItem[]) => void;
45
+
46
+ const DEFAULT_DURATION = 5000;
47
+ /** Problems stay longer: they are the toasts people most need to read. */
48
+ const URGENT_DURATION = 8000;
49
+
50
+ function durationFor(tone: ToastTone, loading: boolean, requested?: number) {
51
+ if (loading) return Infinity;
52
+ if (requested !== undefined) return requested;
53
+ return tone === "danger" || tone === "warning" ? URGENT_DURATION : DEFAULT_DURATION;
54
+ }
55
+
56
+ /** How long a dismissed toast takes to fold away: the theme's normal duration, which the exit transition uses. */
57
+ function exitMs() {
58
+ const value = typeof document === "undefined" ? "" : getComputedStyle(document.documentElement).getPropertyValue("--ml-duration-normal");
59
+ const ms = parseFloat(value);
60
+ return Number.isFinite(ms) ? ms : 240;
61
+ }
62
+
63
+ type Options = ToastOptions & { loading?: boolean };
64
+
65
+ let nextId = 1;
66
+ let store: ToastItem[] = [];
67
+ const listeners = new Set<ToastListener>();
68
+ const emit = () => listeners.forEach((listener) => listener(store));
69
+
70
+ function pushToast(message: string, options: Options = {}) {
71
+ const id = nextId++;
72
+ store = [
73
+ ...store,
74
+ {
75
+ id,
76
+ message,
77
+ description: options.description,
78
+ tone: options.tone ?? "neutral",
79
+ loading: Boolean(options.loading),
80
+ duration: durationFor(options.tone ?? "neutral", Boolean(options.loading), options.duration),
81
+ position: options.position ?? "top-right",
82
+ action: options.action,
83
+ icon: options.icon,
84
+ revision: 0,
85
+ closing: false,
86
+ },
87
+ ];
88
+ emit();
89
+ return id;
90
+ }
91
+
92
+ /** Change a toast in place: a loading toast becoming a success, for example. */
93
+ function updateToast(id: number, message: string, options: Options = {}) {
94
+ store = store.map((item) =>
95
+ item.id === id
96
+ ? {
97
+ ...item,
98
+ message,
99
+ description: options.description,
100
+ tone: options.tone ?? item.tone,
101
+ loading: Boolean(options.loading),
102
+ duration: durationFor(options.tone ?? item.tone, Boolean(options.loading), options.duration),
103
+ action: options.action,
104
+ icon: options.icon,
105
+ revision: item.revision + 1,
106
+ }
107
+ : item,
108
+ );
109
+ emit();
110
+ }
111
+
112
+ /** Fold a toast away, or every toast when no id is given. */
113
+ function dismissToast(id?: number) {
114
+ const targets = store.filter((item) => (id === undefined || item.id === id) && !item.closing).map((item) => item.id);
115
+ if (!targets.length) return;
116
+ store = store.map((item) => (targets.includes(item.id) ? { ...item, closing: true } : item));
117
+ emit();
118
+ setTimeout(() => {
119
+ store = store.filter((item) => !targets.includes(item.id));
120
+ emit();
121
+ }, exitMs());
122
+ }
123
+
124
+ type ToastFn = (message: string, options?: Omit<ToastOptions, "tone">) => number;
125
+
126
+ interface PromiseMessages<T> {
127
+ loading: string;
128
+ success: string | ((value: T) => string);
129
+ error: string | ((error: unknown) => string);
130
+ }
131
+
132
+ interface ToastApi extends ToastFn {
133
+ info: ToastFn;
134
+ success: ToastFn;
135
+ warning: ToastFn;
136
+ danger: ToastFn;
137
+ loading: ToastFn;
138
+ /** Show a loading toast that becomes a success or a danger toast when the promise settles. */
139
+ promise: <T>(promise: Promise<T>, messages: PromiseMessages<T>, options?: Omit<ToastOptions, "tone">) => Promise<T>;
140
+ update: (id: number, message: string, options?: ToastOptions) => void;
141
+ dismiss: (id?: number) => void;
142
+ }
143
+
144
+ const toneFn = (tone: ToastTone): ToastFn => (message, options) => pushToast(message, { ...options, tone });
145
+
146
+ const toast: ToastApi = Object.assign((message: string, options?: Omit<ToastOptions, "tone">) => pushToast(message, options), {
147
+ info: toneFn("info"),
148
+ success: toneFn("success"),
149
+ warning: toneFn("warning"),
150
+ danger: toneFn("danger"),
151
+ loading: ((message, options) => pushToast(message, { ...options, loading: true })) as ToastFn,
152
+ promise<T>(promise: Promise<T>, messages: PromiseMessages<T>, options?: Omit<ToastOptions, "tone">) {
153
+ const id = pushToast(messages.loading, { ...options, loading: true });
154
+ promise.then(
155
+ (value) => updateToast(id, typeof messages.success === "function" ? messages.success(value) : messages.success, { ...options, tone: "success" }),
156
+ (error) => updateToast(id, typeof messages.error === "function" ? messages.error(error) : messages.error, { ...options, tone: "danger" }),
157
+ );
158
+ return promise;
159
+ },
160
+ update: updateToast,
161
+ dismiss: dismissToast,
162
+ });
163
+
164
+ const TONE_ICONS: Partial<Record<ToastTone, React.ReactNode>> = {
165
+ success: <IconCircleCheck size="1.125em" />,
166
+ danger: <IconCircleX size="1.125em" />,
167
+ warning: <IconTriangleAlert size="1.125em" />,
168
+ info: <IconInfo size="1.125em" />,
169
+ };
170
+
171
+ /** Horizontal distance a swipe must travel to dismiss. */
172
+ const SWIPE_DISMISS_PX = 72;
173
+
174
+ function ToastCard({ item, paused }: { item: ToastItem; paused: boolean }) {
175
+ const remaining = React.useRef(item.duration);
176
+ const [drag, setDrag] = React.useState<{ start: number; offset: number } | null>(null);
177
+
178
+ // A changed toast (loading → success) gets its full time again.
179
+ React.useEffect(() => {
180
+ remaining.current = item.duration;
181
+ }, [item.revision, item.duration]);
182
+
183
+ // The timer counts only while the toast can actually be read: not while
184
+ // hovered, focused, or in a hidden tab. It resumes where it paused.
185
+ React.useEffect(() => {
186
+ if (paused || item.closing || !Number.isFinite(item.duration) || item.duration <= 0) return;
187
+ const started = Date.now();
188
+ const timer = window.setTimeout(() => dismissToast(item.id), remaining.current);
189
+ return () => {
190
+ window.clearTimeout(timer);
191
+ remaining.current = Math.max(0, remaining.current - (Date.now() - started));
192
+ };
193
+ }, [paused, item.closing, item.duration, item.id, item.revision]);
194
+
195
+ const urgent = item.tone === "danger" || item.tone === "warning";
196
+ const icon = item.loading ? <span className="ml-toast-spinner" /> : item.icon === undefined ? TONE_ICONS[item.tone] : item.icon;
197
+
198
+ return (
199
+ <div className="ml-toast-slot" data-state={item.closing ? "closed" : "open"}>
200
+ <div
201
+ role={urgent ? "alert" : "status"}
202
+ aria-live={urgent ? "assertive" : "polite"}
203
+ aria-atomic="true"
204
+ aria-busy={item.loading || undefined}
205
+ data-tone={item.tone}
206
+ data-has-description={item.description ? "" : undefined}
207
+ data-swiping={drag ? "" : undefined}
208
+ className="ml-toast"
209
+ style={drag ? { transform: `translateX(${drag.offset}px)`, opacity: Math.max(0.2, 1 - Math.abs(drag.offset) / 200) } : undefined}
210
+ onPointerDown={(event) => {
211
+ if (event.pointerType === "mouse" || (event.target as HTMLElement).closest("button")) return;
212
+ event.currentTarget.setPointerCapture(event.pointerId);
213
+ setDrag({ start: event.clientX, offset: 0 });
214
+ }}
215
+ onPointerMove={(event) => {
216
+ if (drag) setDrag({ ...drag, offset: event.clientX - drag.start });
217
+ }}
218
+ onPointerUp={() => {
219
+ if (drag && Math.abs(drag.offset) > SWIPE_DISMISS_PX) dismissToast(item.id);
220
+ setDrag(null);
221
+ }}
222
+ onPointerCancel={() => setDrag(null)}
223
+ >
224
+ {icon ? (
225
+ <span aria-hidden="true" className="ml-toast-icon">
226
+ {icon}
227
+ </span>
228
+ ) : null}
229
+ <div className="ml-toast-content">
230
+ <p className="ml-toast-message">{item.message}</p>
231
+ {item.description ? <p className="ml-toast-description">{item.description}</p> : null}
232
+ </div>
233
+ {item.action ? (
234
+ <button
235
+ type="button"
236
+ className="ml-toast-action"
237
+ onClick={() => {
238
+ item.action?.onClick();
239
+ dismissToast(item.id);
240
+ }}
241
+ >
242
+ {item.action.label}
243
+ </button>
244
+ ) : null}
245
+ <button type="button" aria-label="Dismiss notification" className="ml-toast-close" onClick={() => dismissToast(item.id)}>
246
+ <IconX aria-hidden="true" size="0.875em" />
247
+ </button>
248
+ </div>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ function useDocumentHidden() {
254
+ return React.useSyncExternalStore(
255
+ (onChange) => {
256
+ document.addEventListener("visibilitychange", onChange);
257
+ return () => document.removeEventListener("visibilitychange", onChange);
258
+ },
259
+ () => document.hidden,
260
+ () => false,
261
+ );
262
+ }
263
+
264
+ interface ToasterProps {
265
+ position?: ToastPosition;
266
+ /** Overrides the region name. Distinct names keep several Toasters navigable. */
267
+ label?: string;
268
+ /** Toasts shown at once; the rest wait their turn. */
269
+ max?: number;
270
+ className?: string;
271
+ }
272
+
273
+ /**
274
+ * Where toasts appear. The newest sits nearest the screen edge; older ones
275
+ * step away from it. Hovering or focusing the stack pauses every timer, so
276
+ * nothing disappears while it is being read.
277
+ */
278
+ function Toaster({ position = "top-right", label, max = 3, className }: ToasterProps) {
279
+ const [toasts, setToasts] = React.useState(() => store.filter((item) => item.position === position));
280
+ const [hovered, setHovered] = React.useState(false);
281
+ const [focused, setFocused] = React.useState(false);
282
+ const hidden = useDocumentHidden();
283
+
284
+ React.useEffect(() => {
285
+ const listener: ToastListener = (all) => setToasts(all.filter((item) => item.position === position));
286
+ listeners.add(listener);
287
+ listener(store);
288
+ return () => {
289
+ listeners.delete(listener);
290
+ };
291
+ }, [position]);
292
+
293
+ const shown = toasts.slice(-Math.max(1, max));
294
+ const ordered = position.startsWith("top") ? [...shown].reverse() : shown;
295
+
296
+ return (
297
+ <div
298
+ role="region"
299
+ aria-label={label ?? `Notifications (${position.replace("-", " ")})`}
300
+ data-position={position}
301
+ className={cx("ml-toaster", className)}
302
+ onMouseEnter={() => setHovered(true)}
303
+ onMouseLeave={() => setHovered(false)}
304
+ onFocus={() => setFocused(true)}
305
+ onBlur={(event) => {
306
+ if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
307
+ }}
308
+ >
309
+ {ordered.map((item) => (
310
+ <ToastCard key={item.id} item={item} paused={hovered || focused || hidden} />
311
+ ))}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ export { toast, dismissToast, Toaster };
317
+ export type { ToastOptions, ToastAction, ToastApi };
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx, useControllableState } from "../_internal/react";
5
+
6
+ type SwitchSize = "sm" | "md" | "lg";
7
+ interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
8
+ checked?: boolean;
9
+ defaultChecked?: boolean;
10
+ onCheckedChange?: (checked: boolean) => void;
11
+ size?: SwitchSize;
12
+ label?: string;
13
+ }
14
+ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
15
+ ({ checked, defaultChecked = false, onCheckedChange, disabled = false, size = "md", label, className, id, onClick, "aria-label": ariaLabel, ...props }, ref) => {
16
+ const [on, setOn] = useControllableState({ value: checked, defaultValue: defaultChecked, onChange: onCheckedChange });
17
+ const generated = React.useId();
18
+ const labelId = label ? `${id ?? generated}-label` : undefined;
19
+ return (
20
+ <span className="ml-switch-field" data-disabled={disabled ? "" : undefined}>
21
+ <button
22
+ ref={ref}
23
+ id={id}
24
+ type="button"
25
+ role="switch"
26
+ aria-checked={on}
27
+ aria-labelledby={labelId}
28
+ aria-label={label ? undefined : ariaLabel ?? "Toggle setting"}
29
+ disabled={disabled}
30
+ data-state={on ? "checked" : "unchecked"}
31
+ data-size={size}
32
+ className={cx("ml-toggle", className)}
33
+ onClick={(event) => {
34
+ onClick?.(event);
35
+ if (!event.defaultPrevented) setOn(!on);
36
+ }}
37
+ {...props}
38
+ >
39
+ <span aria-hidden="true" className="ml-toggle-thumb" />
40
+ </button>
41
+ {label ? <span id={labelId} className="ml-switch-label">{label}</span> : null}
42
+ </span>
43
+ );
44
+ }
45
+ );
46
+ Switch.displayName = "Switch";
47
+
48
+ type ToggleSize = "sm" | "md" | "lg";
49
+ interface ToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
50
+ pressed?: boolean;
51
+ defaultPressed?: boolean;
52
+ onPressedChange?: (pressed: boolean) => void;
53
+ size?: ToggleSize;
54
+ }
55
+ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
56
+ ({ pressed, defaultPressed = false, onPressedChange, disabled = false, size = "md", className, children, onClick, ...props }, ref) => {
57
+ const [on, setOn] = useControllableState({ value: pressed, defaultValue: defaultPressed, onChange: onPressedChange });
58
+ return (
59
+ <button
60
+ ref={ref}
61
+ type="button"
62
+ aria-pressed={on}
63
+ disabled={disabled}
64
+ data-state={on ? "on" : "off"}
65
+ data-size={size}
66
+ className={cx("ml-toggle-button", className)}
67
+ onClick={(event) => {
68
+ onClick?.(event);
69
+ if (!event.defaultPrevented) setOn(!on);
70
+ }}
71
+ {...props}
72
+ >
73
+ {children}
74
+ </button>
75
+ );
76
+ }
77
+ );
78
+ Toggle.displayName = "Toggle";
79
+ export { Switch, Toggle };
80
+ export type { SwitchProps, ToggleProps };
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ export type TooltipPlacement = "top" | "bottom" | "left" | "right";
7
+ interface TooltipProps {
8
+ content: React.ReactNode;
9
+ placement?: TooltipPlacement;
10
+ delay?: number;
11
+ children: React.ReactElement<Record<string, unknown>>;
12
+ className?: string;
13
+ }
14
+
15
+ function Tooltip({ content, placement = "top", delay = 200, children, className }: TooltipProps) {
16
+ const [open, setOpen] = React.useState(false);
17
+ const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
18
+ const id = React.useId();
19
+ const clear = () => {
20
+ if (timer.current) clearTimeout(timer.current);
21
+ timer.current = null;
22
+ };
23
+ const show = () => {
24
+ clear();
25
+ timer.current = setTimeout(() => setOpen(true), Math.max(0, delay));
26
+ };
27
+ const hide = () => {
28
+ clear();
29
+ setOpen(false);
30
+ };
31
+ React.useEffect(() => clear, []);
32
+ React.useEffect(() => {
33
+ if (!open) return;
34
+ const onKeyDown = (event: KeyboardEvent) => {
35
+ if (event.key === "Escape") hide();
36
+ };
37
+ document.addEventListener("keydown", onKeyDown);
38
+ return () => document.removeEventListener("keydown", onKeyDown);
39
+ }, [open]);
40
+ type Handlers = {
41
+ onMouseEnter?: React.MouseEventHandler;
42
+ onMouseLeave?: React.MouseEventHandler;
43
+ onFocus?: React.FocusEventHandler;
44
+ onBlur?: React.FocusEventHandler;
45
+ "aria-describedby"?: string;
46
+ };
47
+ const childProps = children.props as Handlers;
48
+ const trigger = React.cloneElement(children, {
49
+ onMouseEnter: (event: React.MouseEvent) => { childProps.onMouseEnter?.(event); show(); },
50
+ onMouseLeave: (event: React.MouseEvent) => { childProps.onMouseLeave?.(event); hide(); },
51
+ onFocus: (event: React.FocusEvent) => { childProps.onFocus?.(event); show(); },
52
+ onBlur: (event: React.FocusEvent) => { childProps.onBlur?.(event); hide(); },
53
+ "aria-describedby": open
54
+ ? [childProps["aria-describedby"], id].filter(Boolean).join(" ")
55
+ : childProps["aria-describedby"],
56
+ } as Record<string, unknown>);
57
+ return (
58
+ <span className="ml-tooltip-root" onMouseEnter={clear} onMouseLeave={hide}>
59
+ {trigger}
60
+ {open ? (
61
+ <span id={id} role="tooltip" data-side={placement} data-state="open" className={cx("ml-tooltip", className)}>
62
+ {content}
63
+ <span aria-hidden="true" className="ml-tooltip-arrow" />
64
+ </span>
65
+ ) : null}
66
+ </span>
67
+ );
68
+ }
69
+ export { Tooltip };
70
+ export type { TooltipProps };
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { IconX } from "@mlola-ui/icons";
6
+ import { cx } from "../_internal/react";
7
+ import { usePortalNode, type Side } from "../_internal/floating";
8
+ import { placeFloating } from "../_internal/anchor";
9
+
10
+ export interface TourStep {
11
+ /** A CSS selector or a ref for the element to point at; none shows the card centred. */
12
+ target?: string | React.RefObject<HTMLElement | null>;
13
+ title: string;
14
+ body?: React.ReactNode;
15
+ side?: Side;
16
+ }
17
+
18
+ export interface TourProps {
19
+ steps: TourStep[];
20
+ open: boolean;
21
+ onOpenChange: (open: boolean) => void;
22
+ step?: number;
23
+ defaultStep?: number;
24
+ onStepChange?: (step: number) => void;
25
+ /** Called when the reader reaches the end, not when they skip. */
26
+ onFinish?: () => void;
27
+ label?: string;
28
+ className?: string;
29
+ }
30
+
31
+ const PAD = 6;
32
+
33
+ function resolve(target: TourStep["target"]) {
34
+ if (!target) return null;
35
+ if (typeof target === "string") return typeof document === "undefined" ? null : document.querySelector<HTMLElement>(target);
36
+ return target.current;
37
+ }
38
+
39
+ /**
40
+ * A guided tour: the page dims, a spotlight opens on one element at a time,
41
+ * and a card beside it explains what it is for. Back, Next and the arrow
42
+ * keys move through the steps; Escape or Skip ends it. Each step scrolls its
43
+ * element into view and follows it as the page moves.
44
+ */
45
+ export function Tour({ steps, open, onOpenChange, step: stepProp, defaultStep = 0, onStepChange, onFinish, label = "Tour", className }: TourProps) {
46
+ const portal = usePortalNode();
47
+ const [inner, setInner] = React.useState(defaultStep);
48
+ const index = Math.min(steps.length - 1, Math.max(0, stepProp ?? inner));
49
+ const setIndex = (next: number) => {
50
+ if (stepProp === undefined) setInner(next);
51
+ onStepChange?.(next);
52
+ };
53
+ const [rect, setRect] = React.useState<DOMRect | null>(null);
54
+ const card = React.useRef<HTMLDivElement>(null);
55
+ const titleId = React.useId();
56
+ const current = steps[index];
57
+
58
+ // Place the card beside the spotlight whenever the target moves; centre it when there is none.
59
+ React.useLayoutEffect(() => {
60
+ const element = card.current;
61
+ if (!element) return;
62
+ if (!rect) {
63
+ element.style.left = "";
64
+ element.style.top = "";
65
+ return;
66
+ }
67
+ const place = placeFloating(
68
+ { x: rect.left - PAD, y: rect.top - PAD, width: rect.width + PAD * 2, height: rect.height + PAD * 2 },
69
+ { width: element.offsetWidth, height: element.offsetHeight },
70
+ { width: window.innerWidth, height: window.innerHeight },
71
+ { side: current?.side ?? "bottom", align: "center", offset: 14 },
72
+ );
73
+ element.style.left = `${place.x}px`;
74
+ element.style.top = `${place.y}px`;
75
+ });
76
+
77
+ // Bring the step's element into view, then keep the spotlight on it.
78
+ React.useEffect(() => {
79
+ if (!open) return;
80
+ const element = resolve(current?.target);
81
+ if (!element) {
82
+ setRect(null);
83
+ return;
84
+ }
85
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
86
+ element.scrollIntoView({ block: "center", inline: "nearest", behavior: reduce ? "auto" : "smooth" });
87
+ let frame = 0;
88
+ const measure = () => {
89
+ cancelAnimationFrame(frame);
90
+ frame = requestAnimationFrame(() => setRect(element.getBoundingClientRect()));
91
+ };
92
+ measure();
93
+ window.addEventListener("scroll", measure, true);
94
+ window.addEventListener("resize", measure);
95
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
96
+ observer?.observe(element);
97
+ return () => {
98
+ cancelAnimationFrame(frame);
99
+ window.removeEventListener("scroll", measure, true);
100
+ window.removeEventListener("resize", measure);
101
+ observer?.disconnect();
102
+ };
103
+ }, [open, index, current?.target]);
104
+
105
+ React.useEffect(() => {
106
+ if (open) requestAnimationFrame(() => card.current?.focus({ preventScroll: true }));
107
+ }, [open, index]);
108
+
109
+ const finish = () => {
110
+ onOpenChange(false);
111
+ onFinish?.();
112
+ };
113
+ const next = () => (index === steps.length - 1 ? finish() : setIndex(index + 1));
114
+ const back = () => index > 0 && setIndex(index - 1);
115
+
116
+ if (!open || !portal || !current) return null;
117
+
118
+ return createPortal(
119
+ <div className={cx("ml-tour", className)}>
120
+ <div className="ml-tour-scrim" data-spotlight={rect ? "" : undefined} aria-hidden="true" />
121
+ {rect ? (
122
+ <div
123
+ className="ml-tour-spotlight"
124
+ aria-hidden="true"
125
+ style={{ left: rect.left - PAD, top: rect.top - PAD, width: rect.width + PAD * 2, height: rect.height + PAD * 2 }}
126
+ />
127
+ ) : null}
128
+ <div
129
+ ref={card}
130
+ className="ml-tour-card"
131
+ data-centred={rect ? undefined : ""}
132
+ role="dialog"
133
+ aria-modal="true"
134
+ aria-label={`${label}: step ${index + 1} of ${steps.length}`}
135
+ aria-describedby={titleId}
136
+ tabIndex={-1}
137
+ onKeyDown={(event) => {
138
+ if (event.key === "Escape") {
139
+ event.preventDefault();
140
+ onOpenChange(false);
141
+ } else if (event.key === "ArrowRight") {
142
+ event.preventDefault();
143
+ next();
144
+ } else if (event.key === "ArrowLeft") {
145
+ event.preventDefault();
146
+ back();
147
+ } else if (event.key === "Tab") {
148
+ // Focus stays in the card while the tour is open.
149
+ const focusable = [...(card.current?.querySelectorAll<HTMLElement>("button") ?? [])];
150
+ const first = focusable[0];
151
+ const last = focusable[focusable.length - 1];
152
+ if (event.shiftKey && document.activeElement === first) {
153
+ event.preventDefault();
154
+ last?.focus();
155
+ } else if (!event.shiftKey && document.activeElement === last) {
156
+ event.preventDefault();
157
+ first?.focus();
158
+ }
159
+ }
160
+ }}
161
+ >
162
+ <div className="ml-tour-head">
163
+ <span className="ml-tour-count">
164
+ {index + 1} of {steps.length}
165
+ </span>
166
+ <button type="button" className="ml-tour-skip" aria-label="End the tour" onClick={() => onOpenChange(false)}>
167
+ <IconX aria-hidden="true" size="0.95em" />
168
+ </button>
169
+ </div>
170
+ <h2 id={titleId} className="ml-tour-title">
171
+ {current.title}
172
+ </h2>
173
+ {current.body ? <div className="ml-tour-body">{current.body}</div> : null}
174
+ <div className="ml-tour-foot">
175
+ <span className="ml-tour-dots" aria-hidden="true">
176
+ {steps.map((_, position) => (
177
+ <span key={position} className="ml-tour-dot" data-active={position === index || undefined} />
178
+ ))}
179
+ </span>
180
+ {index > 0 ? (
181
+ <button type="button" className="ml-tour-button" onClick={back}>
182
+ Back
183
+ </button>
184
+ ) : null}
185
+ <button type="button" className="ml-tour-button" data-primary="" onClick={next}>
186
+ {index === steps.length - 1 ? "Done" : "Next"}
187
+ </button>
188
+ </div>
189
+ </div>
190
+ </div>,
191
+ portal,
192
+ );
193
+ }