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