@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,192 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { IconCheck } from "@mlola-ui/icons";
6
+ import { cx } from "../_internal/react";
7
+ import { usePortalNode } from "../_internal/floating";
8
+
9
+ export interface ContextMenuItem {
10
+ label: string;
11
+ icon?: React.ReactNode;
12
+ shortcut?: string;
13
+ disabled?: boolean;
14
+ danger?: boolean;
15
+ /** Shows a tick, for toggles such as "Show grid". */
16
+ checked?: boolean;
17
+ /** Draw a divider above this item. */
18
+ separatorBefore?: boolean;
19
+ onSelect?: () => void;
20
+ }
21
+
22
+ export interface ContextMenuProps {
23
+ /** The area that answers a right-click, a long press or the menu key. */
24
+ children: React.ReactNode;
25
+ /** The items, or a function that builds them for what was clicked. */
26
+ items: ContextMenuItem[] | ((target: EventTarget | null) => ContextMenuItem[]);
27
+ label?: string;
28
+ disabled?: boolean;
29
+ className?: string;
30
+ }
31
+
32
+ /** Keep a menu of this size inside the window, flipping from the pointer when it would overflow. */
33
+ export function placeMenu(x: number, y: number, width: number, height: number, viewport: { width: number; height: number }, margin = 8) {
34
+ const left = x + width + margin > viewport.width ? Math.max(margin, x - width) : x;
35
+ const top = y + height + margin > viewport.height ? Math.max(margin, y - height) : y;
36
+ return { left, top };
37
+ }
38
+
39
+ /**
40
+ * Actions for what is under the pointer: right-click (or long-press, or the
41
+ * menu key) opens a menu at that spot, kept inside the window. Arrows move,
42
+ * a letter jumps, Enter chooses, Escape returns focus where it was.
43
+ */
44
+ export function ContextMenu({ children, items, label = "Actions", disabled, className }: ContextMenuProps) {
45
+ const portal = usePortalNode();
46
+ const [state, setState] = React.useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
47
+ const [active, setActive] = React.useState(0);
48
+ const menu = React.useRef<HTMLDivElement>(null);
49
+ const returnTo = React.useRef<HTMLElement | null>(null);
50
+ const press = React.useRef<number | undefined>(undefined);
51
+
52
+ // From the keyboard the first item is highlighted; from a pointer nothing is until the pointer or an arrow moves.
53
+ const openAt = (x: number, y: number, target: EventTarget | null, keyboard = false) => {
54
+ const list = typeof items === "function" ? items(target) : items;
55
+ if (!list.length) return;
56
+ returnTo.current = document.activeElement as HTMLElement | null;
57
+ setState({ x, y, items: list });
58
+ setActive(keyboard ? list.findIndex((item) => !item.disabled) : -1);
59
+ };
60
+
61
+ const close = React.useCallback((restore = true) => {
62
+ setState(null);
63
+ if (restore) requestAnimationFrame(() => returnTo.current?.focus?.());
64
+ }, []);
65
+
66
+ // Place inside the window once the menu's size is known, then take focus.
67
+ React.useLayoutEffect(() => {
68
+ const element = menu.current;
69
+ if (!state || !element) return;
70
+ const { left, top } = placeMenu(state.x, state.y, element.offsetWidth, element.offsetHeight, { width: window.innerWidth, height: window.innerHeight });
71
+ element.style.left = `${left}px`;
72
+ element.style.top = `${top}px`;
73
+ element.focus({ preventScroll: true });
74
+ }, [state]);
75
+
76
+ React.useEffect(() => {
77
+ if (!state) return;
78
+ const away = (event: Event) => {
79
+ if (!menu.current?.contains(event.target as Node)) close(false);
80
+ };
81
+ const shut = () => close(false);
82
+ document.addEventListener("pointerdown", away, true);
83
+ window.addEventListener("resize", shut);
84
+ window.addEventListener("scroll", shut, true);
85
+ window.addEventListener("blur", shut);
86
+ return () => {
87
+ document.removeEventListener("pointerdown", away, true);
88
+ window.removeEventListener("resize", shut);
89
+ window.removeEventListener("scroll", shut, true);
90
+ window.removeEventListener("blur", shut);
91
+ };
92
+ }, [state, close]);
93
+
94
+ const choose = (item: ContextMenuItem | undefined) => {
95
+ if (!item || item.disabled) return;
96
+ close();
97
+ item.onSelect?.();
98
+ };
99
+
100
+ const move = (step: number) => {
101
+ if (!state) return;
102
+ const count = state.items.length;
103
+ let next = active < 0 ? (step > 0 ? -1 : count) : active;
104
+ for (let tries = 0; tries < count; tries++) {
105
+ next = (next + step + count) % count;
106
+ if (!state.items[next].disabled) break;
107
+ }
108
+ setActive(next);
109
+ };
110
+
111
+ const onMenuKey = (event: React.KeyboardEvent) => {
112
+ if (!state) return;
113
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
114
+ event.preventDefault();
115
+ move(event.key === "ArrowDown" ? 1 : -1);
116
+ } else if (event.key === "Home" || event.key === "End") {
117
+ event.preventDefault();
118
+ setActive(event.key === "Home" ? 0 : state.items.length - 1);
119
+ } else if (event.key === "Enter" || event.key === " ") {
120
+ event.preventDefault();
121
+ choose(state.items[active]);
122
+ } else if (event.key === "Escape" || event.key === "Tab") {
123
+ event.preventDefault();
124
+ close();
125
+ } else if (/^\w$/.test(event.key)) {
126
+ // A letter jumps to the next item that starts with it.
127
+ const letter = event.key.toLowerCase();
128
+ const order = state.items.map((_, index) => (active + 1 + index) % state.items.length);
129
+ const found = order.find((index) => !state.items[index].disabled && state.items[index].label.toLowerCase().startsWith(letter));
130
+ if (found !== undefined) setActive(found);
131
+ }
132
+ };
133
+
134
+ return (
135
+ <div
136
+ className={cx("ml-context-menu-area", className)}
137
+ onContextMenu={(event) => {
138
+ if (disabled) return;
139
+ event.preventDefault();
140
+ openAt(event.clientX, event.clientY, event.target);
141
+ }}
142
+ onKeyDown={(event) => {
143
+ if (disabled) return;
144
+ if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
145
+ event.preventDefault();
146
+ const rect = (event.target as HTMLElement).getBoundingClientRect();
147
+ openAt(rect.left + 8, rect.bottom, event.target, true);
148
+ }
149
+ }}
150
+ onPointerDown={(event) => {
151
+ // A long press opens the menu on touch screens.
152
+ if (event.pointerType !== "touch" || disabled) return;
153
+ const { clientX, clientY, target } = event;
154
+ press.current = window.setTimeout(() => openAt(clientX, clientY, target), 520);
155
+ }}
156
+ onPointerUp={() => window.clearTimeout(press.current)}
157
+ onPointerMove={() => window.clearTimeout(press.current)}
158
+ >
159
+ {children}
160
+ {state && portal
161
+ ? createPortal(
162
+ <div ref={menu} className="ml-context-menu" role="menu" aria-label={label} tabIndex={-1} onKeyDown={onMenuKey} style={{ left: state.x, top: state.y }}>
163
+ {state.items.map((item, index) => (
164
+ <React.Fragment key={`${item.label}-${index}`}>
165
+ {item.separatorBefore ? <div className="ml-context-menu-separator" role="separator" /> : null}
166
+ <button
167
+ type="button"
168
+ role={item.checked === undefined ? "menuitem" : "menuitemcheckbox"}
169
+ aria-checked={item.checked === undefined ? undefined : item.checked}
170
+ tabIndex={-1}
171
+ className="ml-context-menu-item"
172
+ data-highlighted={index === active || undefined}
173
+ data-danger={item.danger || undefined}
174
+ aria-disabled={item.disabled || undefined}
175
+ onPointerMove={() => !item.disabled && index !== active && setActive(index)}
176
+ onClick={() => choose(item)}
177
+ >
178
+ <span className="ml-context-menu-icon" aria-hidden="true">
179
+ {item.checked ? <IconCheck size="1em" /> : item.icon}
180
+ </span>
181
+ <span className="ml-context-menu-label">{item.label}</span>
182
+ {item.shortcut ? <span className="ml-context-menu-shortcut">{item.shortcut}</span> : null}
183
+ </button>
184
+ </React.Fragment>
185
+ ))}
186
+ </div>,
187
+ portal,
188
+ )
189
+ : null}
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconCalendar, IconX } from "@mlola-ui/icons";
5
+ import { Calendar, addDays, isDateBlocked, rangeProblem, today as todayOf, type DateMatchers, type DateRange, type ISODate } from "../calendar/calendar";
6
+ import { Field, fieldDescription } from "../input/input";
7
+ import { Popover } from "../popover/popover";
8
+ import { cx } from "../_internal/react";
9
+ import { startOfMonth, toTime } from "../calendar/dates";
10
+
11
+ export type { DateMatcher, DateMatchers, DateRange, ISODate } from "../calendar/calendar";
12
+
13
+ export interface DatePreset {
14
+ label: string;
15
+ value: ISODate | DateRange;
16
+ }
17
+
18
+ interface BaseProps {
19
+ label?: React.ReactNode;
20
+ hint?: React.ReactNode;
21
+ error?: React.ReactNode;
22
+ placeholder?: string;
23
+ min?: ISODate;
24
+ max?: ISODate;
25
+ /** Dates that cannot be picked: dates, `{ from, to }`, `{ before }`, `{ after }`, `{ dayOfWeek }` or a function. */
26
+ disabledDates?: DateMatchers;
27
+ /** When given, only these dates can be picked. */
28
+ enabledDates?: DateMatchers;
29
+ isDisabled?: (date: ISODate) => boolean;
30
+ /** Quick picks beside the calendar, such as "Last 7 days". */
31
+ presets?: DatePreset[];
32
+ weekStart?: 0 | 1;
33
+ locale?: string;
34
+ /** Show a button that empties the field. */
35
+ clearable?: boolean;
36
+ disabled?: boolean;
37
+ required?: boolean;
38
+ id?: string;
39
+ /** Today's date, for server rendering. */
40
+ today?: ISODate;
41
+ className?: string;
42
+ }
43
+
44
+ interface SingleProps {
45
+ mode?: "single";
46
+ value?: ISODate | null;
47
+ defaultValue?: ISODate | null;
48
+ onValueChange?: (value: ISODate | null) => void;
49
+ }
50
+
51
+ interface RangeProps {
52
+ mode: "range";
53
+ value?: DateRange | null;
54
+ defaultValue?: DateRange | null;
55
+ onValueChange?: (value: DateRange | null) => void;
56
+ /** Shortest range in days, counted inclusively. */
57
+ minDays?: number;
58
+ /** Longest range in days, counted inclusively. */
59
+ maxDays?: number;
60
+ /** Let a range pass over disabled dates. */
61
+ allowDisabledInRange?: boolean;
62
+ }
63
+
64
+ export type DatePickerProps = BaseProps & (SingleProps | RangeProps);
65
+
66
+ /** Ranges people reach for, relative to today. */
67
+ export function rangePresets(today = todayOf()): DatePreset[] {
68
+ return [
69
+ { label: "Today", value: { from: today, to: today } },
70
+ { label: "Last 7 days", value: { from: addDays(today, -6), to: today } },
71
+ { label: "Last 30 days", value: { from: addDays(today, -29), to: today } },
72
+ { label: "This month", value: { from: startOfMonth(today), to: today } },
73
+ ];
74
+ }
75
+
76
+ /**
77
+ * A date field that opens a calendar: one date, or a range with presets
78
+ * beside two months. The field shows the choice in the reader's own format;
79
+ * the value stays an ISO date, so it round-trips through forms and APIs.
80
+ */
81
+ export function DatePicker(props: DatePickerProps) {
82
+ const { label, hint, error, placeholder, min, max, disabledDates, enabledDates, isDisabled, presets, weekStart, locale = "en", clearable = true, disabled, required, id, today, className } = props;
83
+ const mode = props.mode ?? "single";
84
+ const autoId = React.useId();
85
+ const fieldId = id ?? autoId;
86
+ const [open, setOpen] = React.useState(false);
87
+ const [inner, setInner] = React.useState<ISODate | DateRange | null>((props.defaultValue as ISODate | DateRange | null) ?? null);
88
+ const value = props.value !== undefined ? (props.value as ISODate | DateRange | null) : inner;
89
+ const commit = (next: ISODate | DateRange | null) => {
90
+ if (props.value === undefined) setInner(next);
91
+ (props.onValueChange as ((value: ISODate | DateRange | null) => void) | undefined)?.(next);
92
+ };
93
+
94
+ const format = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
95
+ const show = (date: ISODate) => format.format(new Date(toTime(date)));
96
+ const text =
97
+ value === null
98
+ ? null
99
+ : typeof value === "string"
100
+ ? show(value)
101
+ : value.to && value.to !== value.from
102
+ ? `${show(value.from)} – ${show(value.to)}`
103
+ : show(value.from);
104
+ const shownPresets = presets ?? (mode === "range" ? rangePresets(today) : []);
105
+ const blocked = (date: ISODate) => isDateBlocked(date, { min, max, disabled: disabledDates, enabled: enabledDates }) || Boolean(isDisabled?.(date));
106
+ const rangeRules = props.mode === "range" ? { minDays: props.minDays, maxDays: props.maxDays, allowBlockedInside: props.allowDisabledInRange } : {};
107
+ // A preset the rules refuse stays in the list, but cannot be chosen.
108
+ const presetRefused = (preset: DatePreset) =>
109
+ typeof preset.value === "string" ? blocked(preset.value) : rangeProblem(preset.value.from, preset.value.to ?? preset.value.from, blocked, rangeRules) !== null;
110
+ const rules = { min, max, disabledDates, enabledDates, isDisabled, weekStart, locale, today };
111
+
112
+ const trigger = (
113
+ <button
114
+ id={fieldId}
115
+ type="button"
116
+ className="ml-input ml-date-picker-trigger"
117
+ data-empty={text ? undefined : ""}
118
+ disabled={disabled}
119
+ aria-invalid={error ? true : undefined}
120
+ aria-describedby={fieldDescription(fieldId, { hint, error })}
121
+ >
122
+ <IconCalendar aria-hidden="true" size="1em" className="ml-date-picker-icon" />
123
+ <span className="ml-date-picker-value">{text ?? placeholder ?? (mode === "range" ? "Pick a range" : "Pick a date")}</span>
124
+ </button>
125
+ );
126
+
127
+ return (
128
+ <Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-date-picker", className)}>
129
+ <div className="ml-date-picker-control">
130
+ <Popover trigger={trigger} label={typeof label === "string" ? label : mode === "range" ? "Choose dates" : "Choose a date"} open={open} onOpenChange={setOpen} side="bottom" align="start" className="ml-date-picker-popover">
131
+ <div className="ml-date-picker-panel" data-presets={shownPresets.length ? "" : undefined}>
132
+ {shownPresets.length ? (
133
+ <ul className="ml-date-picker-presets" aria-label="Presets">
134
+ {shownPresets.map((preset) => (
135
+ <li key={preset.label}>
136
+ <button
137
+ type="button"
138
+ className="ml-date-picker-preset"
139
+ disabled={presetRefused(preset)}
140
+ onClick={() => {
141
+ commit(preset.value);
142
+ setOpen(false);
143
+ }}
144
+ >
145
+ {preset.label}
146
+ </button>
147
+ </li>
148
+ ))}
149
+ </ul>
150
+ ) : null}
151
+ {mode === "range" ? (
152
+ <Calendar
153
+ mode="range"
154
+ months={2}
155
+ value={value as DateRange | null}
156
+ onValueChange={(next) => {
157
+ commit(next);
158
+ if (next?.to) setOpen(false);
159
+ }}
160
+ minDays={props.mode === "range" ? props.minDays : undefined}
161
+ maxDays={props.mode === "range" ? props.maxDays : undefined}
162
+ allowDisabledInRange={props.mode === "range" ? props.allowDisabledInRange : undefined}
163
+ {...rules}
164
+ />
165
+ ) : (
166
+ <Calendar
167
+ value={value as ISODate | null}
168
+ onValueChange={(next) => {
169
+ commit(next);
170
+ if (next) setOpen(false);
171
+ }}
172
+ {...rules}
173
+ />
174
+ )}
175
+ </div>
176
+ </Popover>
177
+ {clearable && text && !disabled ? (
178
+ <button type="button" className="ml-date-picker-clear" aria-label="Clear date" onClick={() => commit(null)}>
179
+ <IconX aria-hidden="true" size="0.9em" />
180
+ </button>
181
+ ) : null}
182
+ </div>
183
+ </Field>
184
+ );
185
+ }
@@ -0,0 +1,157 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { rovingIndex } from "@mlola-ui/behavior/logic";
5
+ import { composeRefs, cx } from "../_internal/react";
6
+ import { IconChevronDown } from "@mlola-ui/icons";
7
+
8
+ interface DropdownMenuItem {
9
+ label: string;
10
+ shortcut?: string;
11
+ icon?: React.ReactNode;
12
+ disabled?: boolean;
13
+ danger?: boolean;
14
+ separatorBefore?: boolean;
15
+ }
16
+
17
+ interface DropdownMenuProps {
18
+ trigger: React.ReactNode;
19
+ items: DropdownMenuItem[];
20
+ label?: string;
21
+ align?: "start" | "end";
22
+ onSelect?: (label: string) => void;
23
+ className?: string;
24
+ }
25
+
26
+ const DropdownMenu = React.forwardRef<HTMLDivElement, DropdownMenuProps>(
27
+ ({ trigger, items, label = "Menu", align = "start", onSelect, className }, ref) => {
28
+ const [open, setOpen] = React.useState(false);
29
+ const [activeIndex, setActiveIndex] = React.useState(-1);
30
+ const rootRef = React.useRef<HTMLDivElement>(null);
31
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
32
+ const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
33
+ const enabled = React.useMemo(
34
+ () => items.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0),
35
+ [items]
36
+ );
37
+ const close = React.useCallback((restore = false) => {
38
+ setOpen(false);
39
+ setActiveIndex(-1);
40
+ if (restore) requestAnimationFrame(() => triggerRef.current?.focus());
41
+ }, []);
42
+ const focus = React.useCallback((index: number) => {
43
+ if (index < 0) return;
44
+ setActiveIndex(index);
45
+ requestAnimationFrame(() => itemRefs.current[index]?.focus());
46
+ }, []);
47
+ const openAt = (index: number) => {
48
+ setOpen(true);
49
+ focus(index);
50
+ };
51
+ const choose = (index: number) => {
52
+ const item = items[index];
53
+ if (!item || item.disabled) return;
54
+ onSelect?.(item.label);
55
+ close(true);
56
+ };
57
+ // Shared with the framework-free runtime, so the highlight steps over
58
+ // disabled items the same way in both.
59
+ const move = (key: string) => {
60
+ const target = rovingIndex(items.length, activeIndex, key, {
61
+ horizontal: false,
62
+ enabled: (at) => !items[at].disabled,
63
+ });
64
+ if (target >= 0) focus(target);
65
+ };
66
+
67
+ React.useEffect(() => {
68
+ if (!open) return;
69
+ const onPointerDown = (event: MouseEvent) => {
70
+ if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) close();
71
+ };
72
+ document.addEventListener("mousedown", onPointerDown);
73
+ return () => document.removeEventListener("mousedown", onPointerDown);
74
+ }, [close, open]);
75
+
76
+ return (
77
+ <div ref={composeRefs(rootRef, ref)} className={cx("ml-dropdown", className)} data-state={open ? "open" : "closed"}>
78
+ <button
79
+ ref={triggerRef}
80
+ type="button"
81
+ aria-haspopup="menu"
82
+ aria-expanded={open}
83
+ className="ml-dropdown-trigger"
84
+ onClick={() => open ? close() : openAt(enabled[0] ?? -1)}
85
+ onKeyDown={(event) => {
86
+ if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
87
+ event.preventDefault();
88
+ openAt(enabled[0] ?? -1);
89
+ } else if (event.key === "ArrowUp") {
90
+ event.preventDefault();
91
+ openAt(enabled[enabled.length - 1] ?? -1);
92
+ } else if (event.key === "Escape") {
93
+ close(true);
94
+ }
95
+ }}
96
+ >
97
+ <span className="ml-dropdown-trigger-label">{trigger}</span>
98
+ <IconChevronDown aria-hidden="true" className="ml-dropdown-chevron" size="1em" />
99
+ </button>
100
+ {open ? (
101
+ <div
102
+ role="menu"
103
+ aria-label={label}
104
+ data-align={align}
105
+ className="ml-dropdown-menu"
106
+ onKeyDown={(event) => {
107
+ if (event.key === "Escape") {
108
+ event.preventDefault();
109
+ close(true);
110
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
111
+ event.preventDefault();
112
+ move(event.key);
113
+ } else if (event.key === "Home" || event.key === "End") {
114
+ event.preventDefault();
115
+ focus(event.key === "Home" ? enabled[0] : enabled[enabled.length - 1]);
116
+ } else if (event.key === "Tab") {
117
+ close();
118
+ } else if (event.key.length === 1 && /\S/.test(event.key)) {
119
+ const start = Math.max(0, enabled.indexOf(activeIndex) + 1);
120
+ const ordered = [...enabled.slice(start), ...enabled.slice(0, start)];
121
+ const match = ordered.find((index) => items[index].label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()));
122
+ if (match !== undefined) focus(match);
123
+ }
124
+ }}
125
+ >
126
+ <div className="ml-dropdown-label">{label}</div>
127
+ {items.map((item, index) => (
128
+ <React.Fragment key={`${item.label}-${index}`}>
129
+ {item.separatorBefore ? <div role="separator" className="ml-dropdown-separator" /> : null}
130
+ <button
131
+ ref={(node) => { itemRefs.current[index] = node; }}
132
+ type="button"
133
+ role="menuitem"
134
+ tabIndex={activeIndex === index ? 0 : -1}
135
+ disabled={item.disabled}
136
+ aria-disabled={item.disabled || undefined}
137
+ data-highlighted={activeIndex === index ? "" : undefined}
138
+ data-danger={item.danger ? "" : undefined}
139
+ className="ml-dropdown-item"
140
+ onFocus={() => !item.disabled && setActiveIndex(index)}
141
+ onPointerMove={() => !item.disabled && focus(index)}
142
+ onClick={() => choose(index)}
143
+ >
144
+ {item.icon ? <span aria-hidden="true" className="ml-dropdown-item-icon">{item.icon}</span> : null}
145
+ <span className="ml-dropdown-item-label">{item.label}</span>
146
+ {item.shortcut ? <span aria-hidden="true" className="ml-dropdown-shortcut">{item.shortcut}</span> : null}
147
+ </button>
148
+ </React.Fragment>
149
+ ))}
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ }
155
+ );
156
+ DropdownMenu.displayName = "DropdownMenu";
157
+ export { DropdownMenu };