@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,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { IconMinus, IconPlus } from "@mlola-ui/icons";
|
|
5
|
+
import { Field, fieldDescription } from "../input/input";
|
|
6
|
+
import { cx } from "../_internal/react";
|
|
7
|
+
import { parseNumber, settle, stepValue } from "./number";
|
|
8
|
+
|
|
9
|
+
export { parseNumber, settle, stepValue } from "./number";
|
|
10
|
+
|
|
11
|
+
export interface NumberInputProps {
|
|
12
|
+
value?: number | null;
|
|
13
|
+
defaultValue?: number | null;
|
|
14
|
+
onValueChange?: (value: number | null) => void;
|
|
15
|
+
label?: React.ReactNode;
|
|
16
|
+
hint?: React.ReactNode;
|
|
17
|
+
error?: React.ReactNode;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
min?: number;
|
|
20
|
+
max?: number;
|
|
21
|
+
step?: number;
|
|
22
|
+
/** What Shift+arrows and Page Up/Down move by; ten steps by default. */
|
|
23
|
+
largeStep?: number;
|
|
24
|
+
/** Decimals kept when a typed value is committed. */
|
|
25
|
+
precision?: number;
|
|
26
|
+
/** How the value reads at rest, as Intl formats it: currency, unit, grouping. */
|
|
27
|
+
format?: Intl.NumberFormatOptions;
|
|
28
|
+
locale?: string;
|
|
29
|
+
/** Show − and + beside the value. */
|
|
30
|
+
buttons?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
id?: string;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A number field that reads well and types well: formatted at rest ($1,200.00,
|
|
39
|
+
* 12 kg), plain while editing, in the reader's own separators. Arrows step
|
|
40
|
+
* (Shift for ten), Page keys take large steps, Home and End go to the
|
|
41
|
+
* bounds, and holding − or + keeps counting.
|
|
42
|
+
*/
|
|
43
|
+
export function NumberInput({
|
|
44
|
+
value,
|
|
45
|
+
defaultValue = null,
|
|
46
|
+
onValueChange,
|
|
47
|
+
label,
|
|
48
|
+
hint,
|
|
49
|
+
error,
|
|
50
|
+
placeholder,
|
|
51
|
+
min,
|
|
52
|
+
max,
|
|
53
|
+
step = 1,
|
|
54
|
+
largeStep,
|
|
55
|
+
precision,
|
|
56
|
+
format,
|
|
57
|
+
locale = "en",
|
|
58
|
+
buttons = true,
|
|
59
|
+
disabled,
|
|
60
|
+
required,
|
|
61
|
+
id,
|
|
62
|
+
className,
|
|
63
|
+
}: NumberInputProps) {
|
|
64
|
+
const autoId = React.useId();
|
|
65
|
+
const fieldId = id ?? autoId;
|
|
66
|
+
const [inner, setInner] = React.useState<number | null>(defaultValue);
|
|
67
|
+
const current = value !== undefined ? value : inner;
|
|
68
|
+
const [editing, setEditing] = React.useState<string | null>(null);
|
|
69
|
+
const hold = React.useRef<{ timer?: number; interval?: number }>({});
|
|
70
|
+
const input = React.useRef<HTMLInputElement>(null);
|
|
71
|
+
const selectOnRender = React.useRef(false);
|
|
72
|
+
const latest = React.useRef(current);
|
|
73
|
+
latest.current = current;
|
|
74
|
+
const big = largeStep ?? step * 10;
|
|
75
|
+
|
|
76
|
+
const display = React.useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: precision ?? 20, ...format }), [locale, precision, format]);
|
|
77
|
+
const plain = React.useMemo(() => new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 }), [locale]);
|
|
78
|
+
const shown = editing ?? (current === null ? "" : display.format(current));
|
|
79
|
+
|
|
80
|
+
// On focus the plain value replaces the formatted one; select it before the next keystroke lands.
|
|
81
|
+
React.useLayoutEffect(() => {
|
|
82
|
+
if (!selectOnRender.current) return;
|
|
83
|
+
selectOnRender.current = false;
|
|
84
|
+
input.current?.select();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const commit = (next: number | null) => {
|
|
88
|
+
latest.current = next;
|
|
89
|
+
if (value === undefined) setInner(next);
|
|
90
|
+
onValueChange?.(next);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const nudge = (delta: number, by = step) => {
|
|
94
|
+
const next = stepValue(latest.current, delta, { step: by, min, max });
|
|
95
|
+
commit(next);
|
|
96
|
+
if (editing !== null) setEditing(plain.format(next));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const finish = () => {
|
|
100
|
+
if (editing === null) return;
|
|
101
|
+
const parsed = parseNumber(editing, locale);
|
|
102
|
+
commit(parsed === null ? null : settle(parsed, { min, max, precision }));
|
|
103
|
+
setEditing(null);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const stopHold = () => {
|
|
107
|
+
window.clearTimeout(hold.current.timer);
|
|
108
|
+
window.clearInterval(hold.current.interval);
|
|
109
|
+
};
|
|
110
|
+
React.useEffect(() => stopHold, []);
|
|
111
|
+
|
|
112
|
+
// Press and hold: one step now, then a steady run after a pause.
|
|
113
|
+
const startHold = (delta: number) => {
|
|
114
|
+
stopHold();
|
|
115
|
+
nudge(delta);
|
|
116
|
+
hold.current.timer = window.setTimeout(() => {
|
|
117
|
+
hold.current.interval = window.setInterval(() => nudge(delta), 60);
|
|
118
|
+
}, 400);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
122
|
+
const keys: Record<string, () => void> = {
|
|
123
|
+
ArrowUp: () => nudge(1, event.shiftKey ? big : step),
|
|
124
|
+
ArrowDown: () => nudge(-1, event.shiftKey ? big : step),
|
|
125
|
+
PageUp: () => nudge(1, big),
|
|
126
|
+
PageDown: () => nudge(-1, big),
|
|
127
|
+
Home: () => min !== undefined && commit(min),
|
|
128
|
+
End: () => max !== undefined && commit(max),
|
|
129
|
+
Enter: () => finish(),
|
|
130
|
+
};
|
|
131
|
+
if (keys[event.key]) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
keys[event.key]();
|
|
134
|
+
} else if (event.key === "Escape" && editing !== null) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
setEditing(null);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const atMin = current !== null && min !== undefined && current <= min;
|
|
141
|
+
const atMax = current !== null && max !== undefined && current >= max;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-number-input", className)}>
|
|
145
|
+
<div className="ml-number-input-control" data-disabled={disabled || undefined} data-invalid={error ? "" : undefined}>
|
|
146
|
+
<input
|
|
147
|
+
ref={input}
|
|
148
|
+
id={fieldId}
|
|
149
|
+
className="ml-number-input-field"
|
|
150
|
+
role="spinbutton"
|
|
151
|
+
inputMode="decimal"
|
|
152
|
+
autoComplete="off"
|
|
153
|
+
aria-valuenow={current ?? undefined}
|
|
154
|
+
aria-valuemin={min}
|
|
155
|
+
aria-valuemax={max}
|
|
156
|
+
aria-valuetext={current === null ? undefined : display.format(current)}
|
|
157
|
+
aria-invalid={error ? true : undefined}
|
|
158
|
+
aria-describedby={fieldDescription(fieldId, { hint, error })}
|
|
159
|
+
value={shown}
|
|
160
|
+
placeholder={placeholder}
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
required={required}
|
|
163
|
+
onFocus={() => {
|
|
164
|
+
selectOnRender.current = true;
|
|
165
|
+
setEditing(current === null ? "" : plain.format(current));
|
|
166
|
+
}}
|
|
167
|
+
onChange={(event) => setEditing(event.target.value)}
|
|
168
|
+
onBlur={finish}
|
|
169
|
+
onKeyDown={onKeyDown}
|
|
170
|
+
/>
|
|
171
|
+
{buttons ? (
|
|
172
|
+
<span className="ml-number-input-buttons">
|
|
173
|
+
<button type="button" className="ml-number-input-step" tabIndex={-1} aria-label="Decrease" disabled={disabled || atMin} onPointerDown={(event) => { event.preventDefault(); startHold(-1); }} onPointerUp={stopHold} onPointerLeave={stopHold} onPointerCancel={stopHold}>
|
|
174
|
+
<IconMinus aria-hidden="true" size="0.9em" />
|
|
175
|
+
</button>
|
|
176
|
+
<button type="button" className="ml-number-input-step" tabIndex={-1} aria-label="Increase" disabled={disabled || atMax} onPointerDown={(event) => { event.preventDefault(); startHold(1); }} onPointerUp={stopHold} onPointerLeave={stopHold} onPointerCancel={stopHold}>
|
|
177
|
+
<IconPlus aria-hidden="true" size="0.9em" />
|
|
178
|
+
</button>
|
|
179
|
+
</span>
|
|
180
|
+
) : null}
|
|
181
|
+
</div>
|
|
182
|
+
</Field>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Numbers as people type them, in their own locale. */
|
|
2
|
+
|
|
3
|
+
/** The locale's decimal and group separators, from how it writes 12345.6. */
|
|
4
|
+
export function separators(locale = "en") {
|
|
5
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
|
|
6
|
+
return { decimal: parts.find((part) => part.type === "decimal")?.value ?? ".", group: parts.find((part) => part.type === "group")?.value ?? "," };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read what was typed: group separators, currency signs and units are
|
|
11
|
+
* ignored, the locale's decimal separator (or a dot) marks the fraction, and
|
|
12
|
+
* a leading minus or parentheses make it negative. Returns null for nothing
|
|
13
|
+
* numeric.
|
|
14
|
+
*/
|
|
15
|
+
export function parseNumber(text: string, locale = "en"): number | null {
|
|
16
|
+
const { decimal, group } = separators(locale);
|
|
17
|
+
let body = text.trim();
|
|
18
|
+
if (!body) return null;
|
|
19
|
+
const negative = /^\(.*\)$/.test(body) || /^[-−]/.test(body) || /[-−]$/.test(body);
|
|
20
|
+
body = body.split(group).join("").replace(/\s/g, "");
|
|
21
|
+
if (decimal !== ".") body = body.split(decimal).join(".");
|
|
22
|
+
body = body.replace(/[^\d.]/g, "");
|
|
23
|
+
if (!body || body === ".") return null;
|
|
24
|
+
const firstDot = body.indexOf(".");
|
|
25
|
+
if (firstDot !== -1) body = body.slice(0, firstDot + 1) + body.slice(firstDot + 1).replace(/\./g, "");
|
|
26
|
+
const value = Number(body);
|
|
27
|
+
if (!Number.isFinite(value)) return null;
|
|
28
|
+
return negative ? -value : value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Clamp into [min, max] and round to `precision` decimals, without float noise. */
|
|
32
|
+
export function settle(value: number, { min = -Infinity, max = Infinity, precision }: { min?: number; max?: number; precision?: number }) {
|
|
33
|
+
const bounded = Math.min(max, Math.max(min, value));
|
|
34
|
+
return precision === undefined ? Number(bounded.toFixed(10)) : Number(bounded.toFixed(precision));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Step from a value by `delta` steps and land on the step grid measured from
|
|
39
|
+
* min (or zero), so 3.3 steps up to 4 and down to 2. An empty field starts
|
|
40
|
+
* at min, or zero.
|
|
41
|
+
*/
|
|
42
|
+
export function stepValue(value: number | null, delta: number, { step = 1, min, max }: { step?: number; min?: number; max?: number }) {
|
|
43
|
+
const origin = min !== undefined && Number.isFinite(min) ? min : 0;
|
|
44
|
+
const decimals = (String(step).split(".")[1] ?? "").length;
|
|
45
|
+
const precision = decimals || undefined;
|
|
46
|
+
if (value === null) return settle(origin, { min, max, precision });
|
|
47
|
+
const next = value + delta * step;
|
|
48
|
+
const onGrid = origin + Math.round((next - origin) / step) * step;
|
|
49
|
+
return settle(onGrid, { min, max, precision });
|
|
50
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useControllableState } from "../_internal/react";
|
|
5
|
+
import { Field, fieldDescription } from "../input/input";
|
|
6
|
+
|
|
7
|
+
export interface OtpInputProps {
|
|
8
|
+
length?: number;
|
|
9
|
+
value?: string;
|
|
10
|
+
defaultValue?: string;
|
|
11
|
+
onValueChange?: (value: string) => void;
|
|
12
|
+
/** Called once every digit is filled. */
|
|
13
|
+
onComplete?: (value: string) => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
/** "numeric" (default) or "alphanumeric". */
|
|
18
|
+
mode?: "numeric" | "alphanumeric";
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Split the boxes into groups, e.g. 3 for "123 456". */
|
|
21
|
+
groupSize?: number;
|
|
22
|
+
id?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A one-time code as separate boxes that behave like one field. Typing moves
|
|
28
|
+
* forward, Backspace moves back, arrows move freely, and pasting or the
|
|
29
|
+
* phone's SMS autofill fills every box at once.
|
|
30
|
+
*/
|
|
31
|
+
export function OtpInput({
|
|
32
|
+
length = 6,
|
|
33
|
+
value,
|
|
34
|
+
defaultValue = "",
|
|
35
|
+
onValueChange,
|
|
36
|
+
onComplete,
|
|
37
|
+
label,
|
|
38
|
+
hint,
|
|
39
|
+
error,
|
|
40
|
+
mode = "numeric",
|
|
41
|
+
disabled,
|
|
42
|
+
groupSize,
|
|
43
|
+
id,
|
|
44
|
+
className,
|
|
45
|
+
}: OtpInputProps) {
|
|
46
|
+
const [code, setCode] = useControllableState({ value, defaultValue, onChange: onValueChange });
|
|
47
|
+
const boxes = React.useRef<Array<HTMLInputElement | null>>([]);
|
|
48
|
+
const generated = React.useId();
|
|
49
|
+
const fieldId = id ?? `otp-${generated}`;
|
|
50
|
+
const allowed = mode === "numeric" ? /\d/ : /[a-z0-9]/i;
|
|
51
|
+
|
|
52
|
+
const commit = (next: string, focusIndex?: number) => {
|
|
53
|
+
const clean = next.slice(0, length);
|
|
54
|
+
setCode(clean);
|
|
55
|
+
if (focusIndex !== undefined) boxes.current[Math.min(focusIndex, length - 1)]?.focus();
|
|
56
|
+
if (clean.length === length && !clean.includes(" ")) onComplete?.(clean);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const fill = (index: number, text: string) => {
|
|
60
|
+
const chars = [...text].filter((char) => allowed.test(char));
|
|
61
|
+
if (!chars.length) return;
|
|
62
|
+
const current = code.padEnd(length, " ").split("");
|
|
63
|
+
chars.forEach((char, offset) => {
|
|
64
|
+
if (index + offset < length) current[index + offset] = mode === "numeric" ? char : char.toUpperCase();
|
|
65
|
+
});
|
|
66
|
+
commit(current.join("").trimEnd(), index + chars.length);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} className={className}>
|
|
71
|
+
<div className="ml-otp" role="group" aria-label={label ?? `${length}-character code`} data-invalid={error ? "" : undefined}>
|
|
72
|
+
{Array.from({ length }, (_, index) => (
|
|
73
|
+
<React.Fragment key={index}>
|
|
74
|
+
{groupSize && index > 0 && index % groupSize === 0 ? <span aria-hidden="true" className="ml-otp-separator" /> : null}
|
|
75
|
+
<input
|
|
76
|
+
ref={(node) => {
|
|
77
|
+
boxes.current[index] = node;
|
|
78
|
+
}}
|
|
79
|
+
id={index === 0 ? fieldId : undefined}
|
|
80
|
+
className="ml-otp-box"
|
|
81
|
+
inputMode={mode === "numeric" ? "numeric" : "text"}
|
|
82
|
+
autoComplete={index === 0 ? "one-time-code" : "off"}
|
|
83
|
+
aria-label={`Digit ${index + 1} of ${length}`}
|
|
84
|
+
aria-invalid={error ? true : undefined}
|
|
85
|
+
aria-describedby={index === 0 ? fieldDescription(fieldId, { hint, error }) : undefined}
|
|
86
|
+
maxLength={index === 0 ? length : 1}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
value={(code[index] ?? "").trim()}
|
|
89
|
+
onFocus={(event) => event.target.select()}
|
|
90
|
+
onChange={(event) => fill(index, event.target.value)}
|
|
91
|
+
onPaste={(event) => {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
fill(index, event.clipboardData.getData("text"));
|
|
94
|
+
}}
|
|
95
|
+
onKeyDown={(event) => {
|
|
96
|
+
if (event.key === "Backspace") {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
const current = code.padEnd(length, " ").split("");
|
|
99
|
+
const target = current[index]?.trim() ? index : Math.max(0, index - 1);
|
|
100
|
+
current[target] = " ";
|
|
101
|
+
commit(current.join("").trimEnd(), target);
|
|
102
|
+
} else if (event.key === "ArrowLeft") {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
boxes.current[Math.max(0, index - 1)]?.focus();
|
|
105
|
+
} else if (event.key === "ArrowRight") {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
boxes.current[Math.min(length - 1, index + 1)]?.focus();
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
</React.Fragment>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</Field>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cx } from "../_internal/react";
|
|
4
|
+
import { IconChevronLeft, IconChevronRight } from "@mlola-ui/icons";
|
|
5
|
+
|
|
6
|
+
type PageItem = number | "ellipsis-start" | "ellipsis-end";
|
|
7
|
+
const pages = (current: number, total: number, siblingCount: number): PageItem[] => {
|
|
8
|
+
if (total <= siblingCount * 2 + 5) return Array.from({ length: total }, (_, index) => index + 1);
|
|
9
|
+
const left = Math.max(2, current - siblingCount);
|
|
10
|
+
const right = Math.min(total - 1, current + siblingCount);
|
|
11
|
+
const result: PageItem[] = [1];
|
|
12
|
+
if (left > 2) result.push("ellipsis-start");
|
|
13
|
+
else for (let page = 2; page < left; page += 1) result.push(page);
|
|
14
|
+
for (let page = left; page <= right; page += 1) result.push(page);
|
|
15
|
+
if (right < total - 1) result.push("ellipsis-end");
|
|
16
|
+
else for (let page = right + 1; page < total; page += 1) result.push(page);
|
|
17
|
+
result.push(total);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface PaginationProps {
|
|
22
|
+
currentPage: number;
|
|
23
|
+
totalPages: number;
|
|
24
|
+
onPageChange: (page: number) => void;
|
|
25
|
+
siblingCount?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
export function Pagination({ currentPage, totalPages, onPageChange, siblingCount = 1, className }: PaginationProps) {
|
|
29
|
+
const total = Number.isFinite(totalPages) ? Math.max(1, Math.floor(totalPages)) : 1;
|
|
30
|
+
const requested = Number.isFinite(currentPage) ? Math.floor(currentPage) : 1;
|
|
31
|
+
const current = Math.min(total, Math.max(1, requested));
|
|
32
|
+
const siblings = Number.isFinite(siblingCount) ? Math.max(0, Math.floor(siblingCount)) : 1;
|
|
33
|
+
const go = (page: number) => {
|
|
34
|
+
const next = Math.min(total, Math.max(1, page));
|
|
35
|
+
if (next !== current) onPageChange(next);
|
|
36
|
+
};
|
|
37
|
+
return (
|
|
38
|
+
<nav aria-label="Pagination" className={cx("ml-pagination", className)}>
|
|
39
|
+
<ol className="ml-pagination-list">
|
|
40
|
+
<li>
|
|
41
|
+
<button type="button" aria-label="Go to previous page" disabled={current === 1} className="ml-pagination-button" onClick={() => go(current - 1)}>
|
|
42
|
+
<IconChevronLeft aria-hidden="true" size="1em" /><span className="ml-pagination-button-label">Previous</span>
|
|
43
|
+
</button>
|
|
44
|
+
</li>
|
|
45
|
+
{pages(current, total, siblings).map((item) => typeof item === "number" ? (
|
|
46
|
+
<li key={item}>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
aria-label={`Go to page ${item}`}
|
|
50
|
+
aria-current={item === current ? "page" : undefined}
|
|
51
|
+
data-state={item === current ? "active" : "inactive"}
|
|
52
|
+
className="ml-pagination-button"
|
|
53
|
+
onClick={() => go(item)}
|
|
54
|
+
>{item}</button>
|
|
55
|
+
</li>
|
|
56
|
+
) : <li key={item}><span aria-hidden="true" className="ml-pagination-ellipsis">…</span></li>)}
|
|
57
|
+
<li>
|
|
58
|
+
<button type="button" aria-label="Go to next page" disabled={current === total} className="ml-pagination-button" onClick={() => go(current + 1)}>
|
|
59
|
+
<span className="ml-pagination-button-label">Next</span><IconChevronRight aria-hidden="true" size="1em" />
|
|
60
|
+
</button>
|
|
61
|
+
</li>
|
|
62
|
+
</ol>
|
|
63
|
+
</nav>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useFloating, usePortalNode, type Align, type Side } from "../_internal/floating";
|
|
6
|
+
import { composeRefs, cx, useControllableState } from "../_internal/react";
|
|
7
|
+
|
|
8
|
+
export interface PopoverProps {
|
|
9
|
+
/** The element that opens the popover; it receives the ARIA wiring. */
|
|
10
|
+
trigger: React.ReactElement<Record<string, unknown>>;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
/** The layer's accessible name. */
|
|
13
|
+
label: string;
|
|
14
|
+
side?: Side;
|
|
15
|
+
align?: Align;
|
|
16
|
+
open?: boolean;
|
|
17
|
+
defaultOpen?: boolean;
|
|
18
|
+
onOpenChange?: (open: boolean) => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A non-modal layer anchored to its trigger: filters, share options, a
|
|
26
|
+
* definition. It opens on click, takes focus, and gives it back to the
|
|
27
|
+
* trigger when Escape, an outside click or moving focus away closes it.
|
|
28
|
+
*/
|
|
29
|
+
export function Popover({ trigger, children, label, side = "bottom", align = "start", open, defaultOpen = false, onOpenChange, className }: PopoverProps) {
|
|
30
|
+
const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
|
|
31
|
+
const anchor = React.useRef<HTMLElement>(null);
|
|
32
|
+
const layer = React.useRef<HTMLDivElement>(null);
|
|
33
|
+
const portal = usePortalNode();
|
|
34
|
+
const id = React.useId();
|
|
35
|
+
useFloating(anchor, layer, isOpen && Boolean(portal), { side, align });
|
|
36
|
+
|
|
37
|
+
const close = React.useCallback(
|
|
38
|
+
(restoreFocus: boolean) => {
|
|
39
|
+
setOpen(false);
|
|
40
|
+
if (restoreFocus) requestAnimationFrame(() => anchor.current?.focus());
|
|
41
|
+
},
|
|
42
|
+
[setOpen],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (!isOpen || !portal) return;
|
|
47
|
+
const frame = requestAnimationFrame(() => {
|
|
48
|
+
const node = layer.current;
|
|
49
|
+
(node?.querySelector<HTMLElement>(FOCUSABLE) ?? node)?.focus();
|
|
50
|
+
});
|
|
51
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
52
|
+
const target = event.target as Node;
|
|
53
|
+
if (!layer.current?.contains(target) && !anchor.current?.contains(target)) close(false);
|
|
54
|
+
};
|
|
55
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
56
|
+
if (event.key === "Escape") {
|
|
57
|
+
event.stopPropagation();
|
|
58
|
+
close(true);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
62
|
+
document.addEventListener("keydown", onKeyDown);
|
|
63
|
+
return () => {
|
|
64
|
+
cancelAnimationFrame(frame);
|
|
65
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
66
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
67
|
+
};
|
|
68
|
+
}, [isOpen, portal, close]);
|
|
69
|
+
|
|
70
|
+
const triggerProps = trigger.props as { onClick?: React.MouseEventHandler; ref?: React.Ref<HTMLElement> };
|
|
71
|
+
const anchored = React.cloneElement(trigger, {
|
|
72
|
+
ref: composeRefs(anchor, triggerProps.ref as React.Ref<HTMLElement> | undefined),
|
|
73
|
+
"aria-haspopup": "dialog",
|
|
74
|
+
"aria-expanded": isOpen,
|
|
75
|
+
"aria-controls": isOpen ? id : undefined,
|
|
76
|
+
onClick: (event: React.MouseEvent) => {
|
|
77
|
+
triggerProps.onClick?.(event);
|
|
78
|
+
if (!event.defaultPrevented) setOpen(!isOpen);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{anchored}
|
|
85
|
+
{isOpen && portal
|
|
86
|
+
? createPortal(
|
|
87
|
+
<div
|
|
88
|
+
ref={layer}
|
|
89
|
+
id={id}
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-label={label}
|
|
92
|
+
tabIndex={-1}
|
|
93
|
+
data-state="open"
|
|
94
|
+
className={cx("ml-popover", className)}
|
|
95
|
+
onBlur={(event) => {
|
|
96
|
+
const next = event.relatedTarget as Node | null;
|
|
97
|
+
if (next && !layer.current?.contains(next) && !anchor.current?.contains(next)) close(false);
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</div>,
|
|
102
|
+
portal,
|
|
103
|
+
)
|
|
104
|
+
: null}
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Work priorities and their names, kept apart from the icon so plain logic can use them. */
|
|
2
|
+
export type WorkPriority = "none" | "urgent" | "high" | "medium" | "low";
|
|
3
|
+
|
|
4
|
+
/** Every priority, most pressing first, for menus and sorting. */
|
|
5
|
+
export const WORK_PRIORITIES: WorkPriority[] = ["urgent", "high", "medium", "low", "none"];
|
|
6
|
+
|
|
7
|
+
export const PRIORITY_LABELS: Record<WorkPriority, string> = {
|
|
8
|
+
none: "No priority",
|
|
9
|
+
urgent: "Urgent",
|
|
10
|
+
high: "High",
|
|
11
|
+
medium: "Medium",
|
|
12
|
+
low: "Low",
|
|
13
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cx } from "../_internal/react";
|
|
3
|
+
import { PRIORITY_LABELS, type WorkPriority } from "./labels";
|
|
4
|
+
|
|
5
|
+
export type { WorkPriority } from "./labels";
|
|
6
|
+
export { PRIORITY_LABELS, WORK_PRIORITIES } from "./labels";
|
|
7
|
+
|
|
8
|
+
export interface PriorityIconProps extends Omit<React.SVGAttributes<SVGSVGElement>, "children"> {
|
|
9
|
+
priority: WorkPriority;
|
|
10
|
+
/** The accessible name; defaults to the priority label. Pass "" when a visible label sits beside it. */
|
|
11
|
+
label?: string;
|
|
12
|
+
size?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LEVEL: Record<WorkPriority, number> = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A work item's priority as signal bars: one, two or three filled for low,
|
|
19
|
+
* medium and high, a filled alert for urgent, and a quiet dash for none.
|
|
20
|
+
*/
|
|
21
|
+
export function PriorityIcon({ priority, label, size = "1em", className, ...props }: PriorityIconProps) {
|
|
22
|
+
const name = label ?? PRIORITY_LABELS[priority];
|
|
23
|
+
const level = LEVEL[priority];
|
|
24
|
+
return (
|
|
25
|
+
<svg
|
|
26
|
+
viewBox="0 0 14 14"
|
|
27
|
+
width={size}
|
|
28
|
+
height={size}
|
|
29
|
+
className={cx("ml-priority-icon", className)}
|
|
30
|
+
data-priority={priority}
|
|
31
|
+
role={name ? "img" : undefined}
|
|
32
|
+
aria-label={name || undefined}
|
|
33
|
+
aria-hidden={name ? undefined : true}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{priority === "urgent" ? (
|
|
37
|
+
<>
|
|
38
|
+
<rect x="1" y="1" width="12" height="12" rx="3" fill="currentColor" />
|
|
39
|
+
<path d="M7 3.8v3.9" stroke="var(--ml-priority-mark)" strokeWidth="1.6" strokeLinecap="round" />
|
|
40
|
+
<circle cx="7" cy="10" r="0.95" fill="var(--ml-priority-mark)" />
|
|
41
|
+
</>
|
|
42
|
+
) : priority === "none" ? (
|
|
43
|
+
<path d="M2.5 7h1.6M6.2 7h1.6M9.9 7h1.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
44
|
+
) : (
|
|
45
|
+
[0, 1, 2].map((bar) => (
|
|
46
|
+
<rect
|
|
47
|
+
key={bar}
|
|
48
|
+
x={1.5 + bar * 4}
|
|
49
|
+
y={9 - bar * 3.2}
|
|
50
|
+
width="3"
|
|
51
|
+
height={3.5 + bar * 3.2}
|
|
52
|
+
rx="1"
|
|
53
|
+
fill="currentColor"
|
|
54
|
+
opacity={bar < level ? 1 : 0.28}
|
|
55
|
+
/>
|
|
56
|
+
))
|
|
57
|
+
)}
|
|
58
|
+
</svg>
|
|
59
|
+
);
|
|
60
|
+
}
|