@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,70 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { composeRefs, cx } from "../_internal/react";
5
+ import { Field, fieldDescription } from "../input/input";
6
+
7
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
8
+ label?: string;
9
+ hint?: string;
10
+ error?: string;
11
+ /** Grow with the text up to this many rows, then scroll. */
12
+ maxRows?: number;
13
+ /** Show a live character count against `maxLength`. */
14
+ showCount?: boolean;
15
+ containerClassName?: string;
16
+ }
17
+
18
+ /**
19
+ * Multi-line text that grows as it is written. With a `maxLength`, a counter
20
+ * appears near the limit and turns to the danger colour past it.
21
+ */
22
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
23
+ ({ label, hint, error, maxRows = 12, showCount = false, containerClassName, className, id, disabled, required, rows = 3, maxLength, onChange, value, defaultValue, style, "aria-describedby": describedBy, ...props }, ref) => {
24
+ const generated = React.useId();
25
+ const fieldId = id ?? `textarea-${generated}`;
26
+ const node = React.useRef<HTMLTextAreaElement>(null);
27
+ const [length, setLength] = React.useState(() => String(value ?? defaultValue ?? "").length);
28
+ const current = value !== undefined ? String(value).length : length;
29
+
30
+ const resize = React.useCallback(() => {
31
+ const element = node.current;
32
+ if (!element || CSS.supports?.("field-sizing", "content")) return;
33
+ element.style.height = "auto";
34
+ element.style.height = `${element.scrollHeight}px`;
35
+ }, []);
36
+ React.useLayoutEffect(resize, [resize, value]);
37
+
38
+ const near = maxLength !== undefined && (showCount || current > maxLength * 0.8);
39
+ return (
40
+ <Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={containerClassName}>
41
+ <textarea
42
+ ref={composeRefs(ref, node)}
43
+ id={fieldId}
44
+ rows={rows}
45
+ disabled={disabled}
46
+ required={required}
47
+ maxLength={maxLength}
48
+ value={value}
49
+ defaultValue={defaultValue}
50
+ aria-invalid={error ? true : undefined}
51
+ aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
52
+ className={cx("ml-textarea", className)}
53
+ style={{ ...style, ["--ml-textarea-max-rows" as string]: maxRows }}
54
+ onChange={(event) => {
55
+ setLength(event.target.value.length);
56
+ resize();
57
+ onChange?.(event);
58
+ }}
59
+ {...props}
60
+ />
61
+ {near ? (
62
+ <p className="ml-textarea-count" data-full={current >= (maxLength ?? Infinity) || undefined} aria-live="polite">
63
+ {current} / {maxLength}
64
+ </p>
65
+ ) : null}
66
+ </Field>
67
+ );
68
+ },
69
+ );
70
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconClock } 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 { fromMinutes, isTimeAllowed, nearestAllowed, nextAllowed, toMinutes, type TimeMatchers, type TimeRules } from "./times";
9
+
10
+ export type { TimeMatcher, TimeMatchers } from "./times";
11
+ export { isTimeAllowed, matchesTime } from "./times";
12
+
13
+ export interface TimePickerProps {
14
+ label?: React.ReactNode;
15
+ hint?: React.ReactNode;
16
+ error?: React.ReactNode;
17
+ /** "HH:MM", 24-hour. */
18
+ value?: string | null;
19
+ defaultValue?: string | null;
20
+ onValueChange?: (value: string | null) => void;
21
+ /** 12 or 24; by default the locale decides. */
22
+ hourCycle?: 12 | 24;
23
+ /** Minutes the arrow keys move by. */
24
+ step?: number;
25
+ /** Minutes between times in the quick list. */
26
+ listStep?: number;
27
+ min?: string;
28
+ max?: string;
29
+ /**
30
+ * Times that cannot be picked: "HH:MM", a range "12:00-13:00", `{ from, to }`
31
+ * or a function. Ranges include their start and exclude their end, so
32
+ * 13:00 is free again after a 12:00-13:00 break.
33
+ */
34
+ disabledTimes?: TimeMatchers;
35
+ /** When given, only these times can be picked, such as bookable slots; same forms as `disabledTimes`. */
36
+ enabledTimes?: TimeMatchers;
37
+ locale?: string;
38
+ disabled?: boolean;
39
+ required?: boolean;
40
+ id?: string;
41
+ className?: string;
42
+ }
43
+
44
+ const pad = (value: number) => String(value).padStart(2, "0");
45
+ const wrap = (value: number, size: number) => ((value % size) + size) % size;
46
+
47
+ type Segment = "hour" | "minute" | "period";
48
+
49
+ /**
50
+ * A time field made of spinbuttons: type the digits or use the arrows, and
51
+ * the field moves on by itself. A quick list offers common times. The value
52
+ * is always "HH:MM" in 24-hour time, whatever the reader sees.
53
+ */
54
+ export function TimePicker({
55
+ label,
56
+ hint,
57
+ error,
58
+ value,
59
+ defaultValue = null,
60
+ onValueChange,
61
+ hourCycle,
62
+ step = 1,
63
+ listStep = 30,
64
+ min,
65
+ max,
66
+ disabledTimes,
67
+ enabledTimes,
68
+ locale = "en",
69
+ disabled,
70
+ required,
71
+ id,
72
+ className,
73
+ }: TimePickerProps) {
74
+ const autoId = React.useId();
75
+ const fieldId = id ?? autoId;
76
+ const [inner, setInner] = React.useState<string | null>(defaultValue);
77
+ const current = value !== undefined ? value : inner;
78
+ const total = toMinutes(current);
79
+ const cycle = hourCycle ?? (new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle?.startsWith("h1") ? 12 : 24);
80
+ const [open, setOpen] = React.useState(false);
81
+ const list = React.useRef<HTMLUListElement>(null);
82
+ const buffer = React.useRef("");
83
+ const segments = React.useRef(new Map<Segment, HTMLSpanElement>());
84
+ const order: Segment[] = cycle === 12 ? ["hour", "minute", "period"] : ["hour", "minute"];
85
+
86
+ const rules: TimeRules = { min, max, disabled: disabledTimes, enabled: enabledTimes };
87
+ const unavailable = total !== null && !isTimeAllowed(total, rules);
88
+
89
+ /** Arrow keys land on the next allowed time in their direction, never on a refused one. */
90
+ const move = (target: number, direction: 1 | -1, by: number) => {
91
+ const next = nextAllowed(wrap(target, 1440), direction, by, rules);
92
+ if (next !== null) commit(next);
93
+ };
94
+
95
+ const commit = (minutes: number | null) => {
96
+ // Typed and stepped times stay inside [min, max], like the quick list.
97
+ const bounded = minutes === null ? null : Math.min(toMinutes(max) ?? 1439, Math.max(toMinutes(min) ?? 0, wrap(minutes, 1440)));
98
+ const next = bounded === null ? null : fromMinutes(bounded);
99
+ if (value === undefined) setInner(next);
100
+ onValueChange?.(next);
101
+ };
102
+
103
+ const base = total ?? 9 * 60;
104
+ const hours = Math.floor(base / 60);
105
+ const minutes = base % 60;
106
+ const isPm = hours >= 12;
107
+
108
+ const focusNext = (segment: Segment, direction = 1) => {
109
+ const index = order.indexOf(segment) + direction;
110
+ if (order[index]) segments.current.get(order[index])?.focus();
111
+ };
112
+
113
+ const setHour = (hour: number) => commit(hour * 60 + minutes);
114
+ const setMinute = (minute: number) => commit(hours * 60 + minute);
115
+
116
+ const onKeyDown = (event: React.KeyboardEvent, segment: Segment) => {
117
+ const key = event.key;
118
+ if (key === "ArrowLeft" || key === "ArrowRight") {
119
+ event.preventDefault();
120
+ buffer.current = "";
121
+ focusNext(segment, key === "ArrowRight" ? 1 : -1);
122
+ return;
123
+ }
124
+ if (key === "ArrowUp" || key === "ArrowDown") {
125
+ event.preventDefault();
126
+ const direction = key === "ArrowUp" ? 1 : -1;
127
+ if (segment === "hour") move(wrap(hours + direction, 24) * 60 + minutes, direction, 60);
128
+ else if (segment === "minute") move(hours * 60 + wrap(Math.round(minutes / step) * step + direction * step, 60), direction, step);
129
+ else {
130
+ const flipped = nearestAllowed(wrap(base + (isPm ? -720 : 720), 1440), rules);
131
+ if (flipped !== null) commit(flipped);
132
+ }
133
+ return;
134
+ }
135
+ if (segment === "period" && /^[ap]$/i.test(key)) {
136
+ event.preventDefault();
137
+ const wantPm = key.toLowerCase() === "p";
138
+ if (wantPm !== isPm) commit(base + (wantPm ? 720 : -720));
139
+ // Typed times may land on a refused one; the field snaps when focus leaves it.
140
+ return;
141
+ }
142
+ if (key === "Backspace" || key === "Delete") {
143
+ event.preventDefault();
144
+ buffer.current = "";
145
+ commit(null);
146
+ return;
147
+ }
148
+ if (!/^\d$/.test(key) || segment === "period") return;
149
+ event.preventDefault();
150
+ buffer.current = (buffer.current + key).slice(-2);
151
+ const typed = Number(buffer.current);
152
+ if (segment === "hour") {
153
+ const limit = cycle === 12 ? 12 : 23;
154
+ const hour24 = cycle === 12 ? (typed % 12) + (isPm ? 12 : 0) : typed;
155
+ if (typed <= limit) setHour(hour24);
156
+ // One digit is enough when no second digit could follow it.
157
+ if (buffer.current.length === 2 || typed * 10 > limit) {
158
+ buffer.current = "";
159
+ focusNext(segment);
160
+ }
161
+ } else {
162
+ if (typed < 60) setMinute(typed);
163
+ if (buffer.current.length === 2 || typed * 10 >= 60) {
164
+ buffer.current = "";
165
+ focusNext(segment);
166
+ }
167
+ }
168
+ };
169
+
170
+ const displayHour = total === null ? "--" : cycle === 12 ? String(((hours + 11) % 12) + 1) : pad(hours);
171
+ const displayMinute = total === null ? "--" : pad(minutes);
172
+ const format = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", hourCycle: cycle === 12 ? "h12" : "h23", timeZone: "UTC" });
173
+ const spoken = (value: number) => format.format(new Date(Date.UTC(2000, 0, 1, Math.floor(value / 60), value % 60)));
174
+
175
+ const low = toMinutes(min) ?? 0;
176
+ const high = toMinutes(max) ?? 1439;
177
+ // With enabledTimes the list offers exactly the allowed slots, exact times
178
+ // included; otherwise it walks the grid and shows refused times dimmed.
179
+ const grid: number[] = [];
180
+ for (let at = low; at <= high; at += listStep) grid.push(at);
181
+ const exact = (Array.isArray(enabledTimes) ? enabledTimes : enabledTimes === undefined ? [] : [enabledTimes])
182
+ .filter((matcher): matcher is string => typeof matcher === "string" && !matcher.includes("-"))
183
+ .map((time) => toMinutes(time))
184
+ .filter((minutes): minutes is number => minutes !== null);
185
+ const options = enabledTimes === undefined ? grid : [...new Set([...grid, ...exact])].sort((a, b) => a - b).filter((at) => isTimeAllowed(at, rules));
186
+ const shownAt = total !== null ? options.reduce((best, at) => (Math.abs(at - total) < Math.abs(best - total) ? at : best), options[0] ?? low) : (options.find((at) => isTimeAllowed(at, rules)) ?? low);
187
+
188
+ // Open the list centred on the current time, once the popover has laid out.
189
+ // Only the list scrolls, never the page.
190
+ React.useEffect(() => {
191
+ if (!open) return;
192
+ const frame = requestAnimationFrame(() => {
193
+ const scroller = list.current;
194
+ const anchor = scroller?.querySelector<HTMLElement>("[data-anchor]");
195
+ if (!scroller || !anchor) return;
196
+ const box = scroller.getBoundingClientRect();
197
+ const rect = anchor.getBoundingClientRect();
198
+ scroller.scrollTop += rect.top - box.top - (scroller.clientHeight - rect.height) / 2;
199
+ });
200
+ return () => cancelAnimationFrame(frame);
201
+ }, [open]);
202
+
203
+ const segmentProps = (segment: Segment, now: number | null, lowest: number, highest: number, text: string) => ({
204
+ ref: (element: HTMLSpanElement | null) => {
205
+ if (element) segments.current.set(segment, element);
206
+ else segments.current.delete(segment);
207
+ },
208
+ role: "spinbutton",
209
+ tabIndex: disabled ? -1 : 0,
210
+ "aria-label": segment === "hour" ? "Hours" : segment === "minute" ? "Minutes" : "AM or PM",
211
+ "aria-valuemin": lowest,
212
+ "aria-valuemax": highest,
213
+ "aria-valuenow": now ?? undefined,
214
+ "aria-valuetext": total === null ? "Empty" : text,
215
+ "aria-disabled": disabled || undefined,
216
+ className: "ml-time-picker-segment",
217
+ "data-empty": total === null || undefined,
218
+ onKeyDown: (event: React.KeyboardEvent) => !disabled && onKeyDown(event, segment),
219
+ onFocus: () => (buffer.current = ""),
220
+ });
221
+
222
+ return (
223
+ <Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-time-picker", className)}>
224
+ <div
225
+ className="ml-input ml-time-picker-control"
226
+ role="group"
227
+ id={fieldId}
228
+ aria-label={typeof label === "string" ? label : "Time"}
229
+ aria-describedby={fieldDescription(fieldId, { hint, error })}
230
+ aria-invalid={error || unavailable ? true : undefined}
231
+ data-disabled={disabled || undefined}
232
+ data-unavailable={unavailable || undefined}
233
+ onBlur={(event) => {
234
+ // Snap a refused time to the nearest allowed one when the reader moves on.
235
+ if (event.currentTarget.contains(event.relatedTarget as Node) || !unavailable || total === null) return;
236
+ commit(nearestAllowed(total, rules));
237
+ }}
238
+ >
239
+ <span {...segmentProps("hour", total === null ? null : hours, 0, 23, displayHour)}>{displayHour}</span>
240
+ <span className="ml-time-picker-colon" aria-hidden="true">
241
+ :
242
+ </span>
243
+ <span {...segmentProps("minute", total === null ? null : minutes, 0, 59, displayMinute)}>{displayMinute}</span>
244
+ {cycle === 12 ? (
245
+ <span {...segmentProps("period", total === null ? null : isPm ? 1 : 0, 0, 1, isPm ? "PM" : "AM")} data-period="">
246
+ {total === null ? "--" : isPm ? "PM" : "AM"}
247
+ </span>
248
+ ) : null}
249
+ <Popover
250
+ label="Choose a time"
251
+ open={open}
252
+ onOpenChange={setOpen}
253
+ side="bottom"
254
+ align="end"
255
+ trigger={
256
+ <button type="button" className="ml-time-picker-open" aria-label="Choose from a list" disabled={disabled}>
257
+ <IconClock aria-hidden="true" size="1em" />
258
+ </button>
259
+ }
260
+ >
261
+ <ul ref={list} className="ml-time-picker-list" role="listbox" aria-label="Times">
262
+ {options.map((option) => (
263
+ <li key={option} role="presentation">
264
+ <button
265
+ type="button"
266
+ role="option"
267
+ aria-selected={option === total}
268
+ aria-disabled={!isTimeAllowed(option, rules) || undefined}
269
+ disabled={!isTimeAllowed(option, rules)}
270
+ className="ml-time-picker-option"
271
+ data-anchor={option === shownAt || undefined}
272
+ onClick={() => {
273
+ commit(option);
274
+ setOpen(false);
275
+ }}
276
+ >
277
+ {spoken(option)}
278
+ </button>
279
+ </li>
280
+ ))}
281
+ </ul>
282
+ </Popover>
283
+ </div>
284
+ </Field>
285
+ );
286
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Times of day as minutes since midnight, and the rules that allow or refuse
3
+ * them. Every range is half-open, [from, to): "12:00-13:00" covers 12:00 to
4
+ * 12:59, so a lunch break ends when 13:00 becomes free again, and opening
5
+ * hours "09:00-17:00" offer a last start at 16:59.
6
+ */
7
+ export type TimeMatcher = string | { from: string; to: string } | ((time: string) => boolean);
8
+ export type TimeMatchers = TimeMatcher | TimeMatcher[];
9
+
10
+ const pad = (value: number) => String(value).padStart(2, "0");
11
+
12
+ export const toMinutes = (value: string | null | undefined) => {
13
+ if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
14
+ const [hours, minutes] = value.split(":").map(Number);
15
+ return hours * 60 + minutes;
16
+ };
17
+
18
+ export const fromMinutes = (total: number) => `${pad(Math.floor(total / 60) % 24)}:${pad(total % 60)}`;
19
+
20
+ function inRange(minutes: number, from: string, to: string) {
21
+ const start = toMinutes(from);
22
+ const end = toMinutes(to);
23
+ if (start === null || end === null) return false;
24
+ // A range that ends before it starts runs past midnight, like "22:00-06:00".
25
+ return start <= end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
26
+ }
27
+
28
+ export function matchesTime(minutes: number, matchers?: TimeMatchers): boolean {
29
+ if (matchers === undefined) return false;
30
+ const list = Array.isArray(matchers) ? matchers : [matchers];
31
+ const time = fromMinutes(minutes);
32
+ return list.some((matcher) => {
33
+ if (typeof matcher === "function") return matcher(time);
34
+ if (typeof matcher === "string") {
35
+ const [from, to] = matcher.split("-").map((part) => part.trim());
36
+ return to ? inRange(minutes, from, to) : toMinutes(from) === minutes;
37
+ }
38
+ return inRange(minutes, matcher.from, matcher.to);
39
+ });
40
+ }
41
+
42
+ export interface TimeRules {
43
+ min?: string;
44
+ max?: string;
45
+ disabled?: TimeMatchers;
46
+ /** When given, only these times can be picked (min, max and disabled still apply). */
47
+ enabled?: TimeMatchers;
48
+ }
49
+
50
+ export function isTimeAllowed(minutes: number, rules: TimeRules) {
51
+ const low = toMinutes(rules.min) ?? 0;
52
+ const high = toMinutes(rules.max) ?? 1439;
53
+ return minutes >= low && minutes <= high && !matchesTime(minutes, rules.disabled) && (rules.enabled === undefined || matchesTime(minutes, rules.enabled));
54
+ }
55
+
56
+ /** The first allowed time from `start` moving by `step` minutes in `direction`, wrapping at midnight. */
57
+ export function nextAllowed(start: number, direction: 1 | -1, step: number, rules: TimeRules): number | null {
58
+ const size = Math.max(1, step);
59
+ for (let moved = 0; moved < 1440; moved += size) {
60
+ const minutes = (((start + direction * moved) % 1440) + 1440) % 1440;
61
+ if (isTimeAllowed(minutes, rules)) return minutes;
62
+ }
63
+ // Nothing on the step grid: fall back to minute by minute.
64
+ if (size > 1) return nextAllowed(start, direction, 1, rules);
65
+ return null;
66
+ }
67
+
68
+ /** The allowed time closest to `minutes`; on a tie, the later one. */
69
+ export function nearestAllowed(minutes: number, rules: TimeRules): number | null {
70
+ if (isTimeAllowed(minutes, rules)) return minutes;
71
+ for (let distance = 1; distance < 1440; distance++) {
72
+ if (minutes + distance <= 1439 && isTimeAllowed(minutes + distance, rules)) return minutes + distance;
73
+ if (minutes - distance >= 0 && isTimeAllowed(minutes - distance, rules)) return minutes - distance;
74
+ }
75
+ return null;
76
+ }
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconCheck } from "@mlola-ui/icons";
5
+ import { cx } from "../_internal/react";
6
+
7
+ type TimelineStatus = "complete" | "current" | "upcoming";
8
+ type TimelineTone = "neutral" | "primary" | "info" | "success" | "warning" | "danger";
9
+
10
+ const Timeline = React.forwardRef<HTMLOListElement, React.OlHTMLAttributes<HTMLOListElement>>(
11
+ ({ className, children, ...rest }, ref) => (
12
+ <ol ref={ref} className={cx("ml-timeline", className)} {...rest}>
13
+ {children}
14
+ </ol>
15
+ )
16
+ );
17
+ Timeline.displayName = "Timeline";
18
+
19
+ interface TimelineItemProps extends Omit<React.LiHTMLAttributes<HTMLLIElement>, "title"> {
20
+ /** Progress through a sequence: done, happening now, or still to come. */
21
+ status?: TimelineStatus;
22
+ /** Colours an event marker, for activity feeds: a failed deploy, a merged change. */
23
+ tone?: TimelineTone;
24
+ time?: React.ReactNode;
25
+ /** A machine-readable time for `<time dateTime>`. */
26
+ dateTime?: string;
27
+ title: React.ReactNode;
28
+ description?: React.ReactNode;
29
+ /** Replaces the status mark inside the marker. */
30
+ icon?: React.ReactNode;
31
+ }
32
+
33
+ /**
34
+ * One event: a marker on the rail, then a title with its time and an
35
+ * optional description. Anything passed as children (a quote, a card, a
36
+ * diff) sits under the description, still aligned to the text column.
37
+ */
38
+ const TimelineItem = React.forwardRef<HTMLLIElement, TimelineItemProps>(
39
+ ({ status = "upcoming", tone, time, dateTime, title, description, icon, className, children, ...rest }, ref) => (
40
+ <li ref={ref} className={cx("ml-timeline-item", className)} data-status={status} aria-current={status === "current" ? "step" : undefined} {...rest}>
41
+ <TimelineDot status={status} data-tone={tone}>
42
+ {icon}
43
+ </TimelineDot>
44
+ <div className="ml-timeline-body">
45
+ <div className="ml-timeline-heading">
46
+ <TimelineTitle>{title}</TimelineTitle>
47
+ {time != null ? <TimelineTime dateTime={dateTime}>{time}</TimelineTime> : null}
48
+ </div>
49
+ {description != null ? <TimelineDescription>{description}</TimelineDescription> : null}
50
+ {children != null ? <div className="ml-timeline-content">{children}</div> : null}
51
+ </div>
52
+ </li>
53
+ )
54
+ );
55
+ TimelineItem.displayName = "TimelineItem";
56
+
57
+ interface TimelineDotProps extends React.HTMLAttributes<HTMLSpanElement> {
58
+ status?: TimelineStatus;
59
+ }
60
+
61
+ const TimelineDot = React.forwardRef<HTMLSpanElement, TimelineDotProps>(
62
+ ({ status = "upcoming", className, children, ...rest }, ref) => (
63
+ <span
64
+ ref={ref}
65
+ aria-hidden="true"
66
+ data-status={status}
67
+ className={cx("ml-timeline-marker", className)}
68
+ {...rest}
69
+ >
70
+ {children ?? getStatusFallback(status)}
71
+ </span>
72
+ )
73
+ );
74
+ TimelineDot.displayName = "TimelineDot";
75
+
76
+ function getStatusFallback(status: TimelineStatus) {
77
+ if (status === "complete") return <IconCheck size="0.75rem" />;
78
+ if (status === "current") return <span className="ml-timeline-pulse" />;
79
+ return null;
80
+ }
81
+
82
+ const TimelineTime = React.forwardRef<HTMLTimeElement, React.TimeHTMLAttributes<HTMLTimeElement>>(
83
+ ({ className, children, ...rest }, ref) => (
84
+ <time ref={ref} className={cx("ml-timeline-time", className)} {...rest}>
85
+ {children}
86
+ </time>
87
+ )
88
+ );
89
+ TimelineTime.displayName = "TimelineTime";
90
+
91
+ const TimelineTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
92
+ ({ className, children, ...rest }, ref) => (
93
+ <p ref={ref} className={cx("ml-timeline-title", className)} {...rest}>
94
+ {children}
95
+ </p>
96
+ )
97
+ );
98
+ TimelineTitle.displayName = "TimelineTitle";
99
+
100
+ const TimelineDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
101
+ ({ className, children, ...rest }, ref) => (
102
+ <p ref={ref} className={cx("ml-timeline-description", className)} {...rest}>
103
+ {children}
104
+ </p>
105
+ )
106
+ );
107
+ TimelineDescription.displayName = "TimelineDescription";
108
+ export {
109
+ Timeline,
110
+ TimelineItem,
111
+ TimelineDot,
112
+ TimelineTime,
113
+ TimelineTitle,
114
+ TimelineDescription };
115
+ export type { TimelineStatus, TimelineTone, TimelineItemProps, TimelineDotProps };