@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,332 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { rovingIndex } from "@mlola-ui/behavior/logic";
|
|
6
|
+
import { useFloating, usePortalNode } from "../_internal/floating";
|
|
7
|
+
import { cx, useControllableState } from "../_internal/react";
|
|
8
|
+
import { IconCheck, IconChevronDown, IconX } from "@mlola-ui/icons";
|
|
9
|
+
|
|
10
|
+
export interface SelectOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
group?: string;
|
|
15
|
+
/** A second line under the label, announced as the option's description. */
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
/** Decoration before the label (a mark or icon). Shown on the trigger too. */
|
|
18
|
+
leading?: React.ReactNode;
|
|
19
|
+
/** Decoration after the label, such as badges. Decorative: put anything a
|
|
20
|
+
screen reader needs into `description`. */
|
|
21
|
+
trailing?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SelectSize = "sm" | "md" | "lg";
|
|
25
|
+
|
|
26
|
+
interface SelectBaseProps {
|
|
27
|
+
options: SelectOption[];
|
|
28
|
+
/** Cap the chips rendered before collapsing into a counter. */
|
|
29
|
+
maxVisibleChips?: number;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
searchable?: boolean;
|
|
32
|
+
clearable?: boolean;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
size?: SelectSize;
|
|
35
|
+
error?: string;
|
|
36
|
+
label?: string;
|
|
37
|
+
/** Keep the label for assistive technology but do not show it (toolbars). */
|
|
38
|
+
hideLabel?: boolean;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SelectSingleProps {
|
|
43
|
+
multiple?: false;
|
|
44
|
+
value?: string;
|
|
45
|
+
defaultValue?: string;
|
|
46
|
+
onValueChange?: (value: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Select more than one option. Chips replace the single-value label. */
|
|
50
|
+
interface SelectMultipleProps {
|
|
51
|
+
multiple: true;
|
|
52
|
+
value?: string[];
|
|
53
|
+
defaultValue?: string[];
|
|
54
|
+
onValueChange?: (value: string[]) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SelectProps = SelectBaseProps & (SelectSingleProps | SelectMultipleProps);
|
|
58
|
+
|
|
59
|
+
export function Select(props: SelectProps) {
|
|
60
|
+
const {
|
|
61
|
+
options,
|
|
62
|
+
maxVisibleChips = 3,
|
|
63
|
+
placeholder = "Select…",
|
|
64
|
+
searchable = false,
|
|
65
|
+
clearable = false,
|
|
66
|
+
disabled = false,
|
|
67
|
+
size = "md",
|
|
68
|
+
error,
|
|
69
|
+
label,
|
|
70
|
+
hideLabel = false,
|
|
71
|
+
className,
|
|
72
|
+
} = props;
|
|
73
|
+
const multiple = props.multiple === true;
|
|
74
|
+
const [selectedValue, setSelectedValue] = useControllableState<string>({
|
|
75
|
+
value: props.multiple ? undefined : props.value,
|
|
76
|
+
defaultValue: props.multiple ? "" : (props.defaultValue ?? ""),
|
|
77
|
+
onChange: props.multiple ? undefined : props.onValueChange,
|
|
78
|
+
});
|
|
79
|
+
const [selectedValues, setSelectedValues] = useControllableState<string[]>({
|
|
80
|
+
value: props.multiple ? props.value : undefined,
|
|
81
|
+
defaultValue: props.multiple ? (props.defaultValue ?? []) : [],
|
|
82
|
+
onChange: props.multiple ? props.onValueChange : undefined,
|
|
83
|
+
});
|
|
84
|
+
const [open, setOpenState] = React.useState(false);
|
|
85
|
+
const [query, setQuery] = React.useState("");
|
|
86
|
+
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
87
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
88
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
89
|
+
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
90
|
+
const controlRef = React.useRef<HTMLDivElement>(null);
|
|
91
|
+
const popoverRef = React.useRef<HTMLDivElement>(null);
|
|
92
|
+
const portal = usePortalNode();
|
|
93
|
+
// The list lives on <body>, fixed beside the control: no scrolling panel or
|
|
94
|
+
// clipped card can cut it off, and it opens upward when there is no room below.
|
|
95
|
+
useFloating(controlRef, popoverRef, open && Boolean(portal), { side: "bottom", align: "start", offset: 6 });
|
|
96
|
+
React.useLayoutEffect(() => {
|
|
97
|
+
if (open && popoverRef.current && controlRef.current) popoverRef.current.style.minWidth = `${controlRef.current.offsetWidth}px`;
|
|
98
|
+
}, [open, portal]);
|
|
99
|
+
const setOpen = (next: boolean | ((current: boolean) => boolean)) => setOpenState((current) => (typeof next === "function" ? next(current) : next));
|
|
100
|
+
const id = React.useId();
|
|
101
|
+
const labelId = `${id}-label`;
|
|
102
|
+
const listId = `${id}-listbox`;
|
|
103
|
+
const errorId = `${id}-error`;
|
|
104
|
+
const selected = options.find((option) => option.value === selectedValue);
|
|
105
|
+
const chosen = React.useMemo(
|
|
106
|
+
() => (multiple ? options.filter((option) => selectedValues.includes(option.value)) : []),
|
|
107
|
+
[multiple, options, selectedValues],
|
|
108
|
+
);
|
|
109
|
+
const isChosen = (option: SelectOption) =>
|
|
110
|
+
multiple ? selectedValues.includes(option.value) : option.value === selectedValue;
|
|
111
|
+
const filtered = React.useMemo(() => {
|
|
112
|
+
const normalized = query.trim().toLocaleLowerCase();
|
|
113
|
+
return normalized
|
|
114
|
+
? options.filter((option) => option.label.toLocaleLowerCase().includes(normalized))
|
|
115
|
+
: options;
|
|
116
|
+
}, [options, query]);
|
|
117
|
+
const active = filtered[activeIndex];
|
|
118
|
+
|
|
119
|
+
const close = React.useCallback((restoreFocus = false) => {
|
|
120
|
+
setOpenState(false);
|
|
121
|
+
setQuery("");
|
|
122
|
+
setActiveIndex(-1);
|
|
123
|
+
if (restoreFocus) requestAnimationFrame(() => triggerRef.current?.focus());
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
React.useEffect(() => {
|
|
127
|
+
if (disabled) close();
|
|
128
|
+
}, [disabled, close]);
|
|
129
|
+
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
if (!open) return;
|
|
132
|
+
const selectedIndex = filtered.findIndex(
|
|
133
|
+
(option) => option.value === selectedValue && !option.disabled
|
|
134
|
+
);
|
|
135
|
+
setActiveIndex(
|
|
136
|
+
selectedIndex >= 0 ? selectedIndex : filtered.findIndex((option) => !option.disabled)
|
|
137
|
+
);
|
|
138
|
+
if (searchable) requestAnimationFrame(() => searchRef.current?.focus());
|
|
139
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
140
|
+
if (event.target instanceof Node && !rootRef.current?.contains(event.target) && !popoverRef.current?.contains(event.target)) {
|
|
141
|
+
close();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
145
|
+
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
146
|
+
}, [open, searchable, selectedValue, filtered, close]);
|
|
147
|
+
|
|
148
|
+
// Shared with the framework-free runtime: one definition of how the
|
|
149
|
+
// highlight moves and which options it steps over.
|
|
150
|
+
const move = (key: string) => {
|
|
151
|
+
const target = rovingIndex(filtered.length, activeIndex, key, {
|
|
152
|
+
horizontal: false,
|
|
153
|
+
enabled: (at) => !filtered[at].disabled,
|
|
154
|
+
});
|
|
155
|
+
if (target >= 0) setActiveIndex(target);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const choose = (option: SelectOption | undefined) => {
|
|
159
|
+
if (!option || option.disabled || disabled) return;
|
|
160
|
+
if (multiple) {
|
|
161
|
+
setSelectedValues(
|
|
162
|
+
selectedValues.includes(option.value)
|
|
163
|
+
? selectedValues.filter((entry) => entry !== option.value)
|
|
164
|
+
: [...selectedValues, option.value],
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setSelectedValue(option.value);
|
|
169
|
+
close(true);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
173
|
+
if (disabled) return;
|
|
174
|
+
if (event.key === "Escape") {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
close(true);
|
|
177
|
+
} else if (event.key === "Tab") {
|
|
178
|
+
close();
|
|
179
|
+
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
if (!open) setOpen(true);
|
|
182
|
+
else move(event.key);
|
|
183
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
184
|
+
if (event.currentTarget === searchRef.current && event.key === " ") return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
if (!open) setOpen(true);
|
|
187
|
+
else choose(active);
|
|
188
|
+
} else if (event.key === "Home" && open) {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
setActiveIndex(filtered.findIndex((option) => !option.disabled));
|
|
191
|
+
} else if (event.key === "End" && open) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
for (let index = filtered.length - 1; index >= 0; index -= 1) {
|
|
194
|
+
if (!filtered[index].disabled) {
|
|
195
|
+
setActiveIndex(index);
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div
|
|
204
|
+
ref={rootRef}
|
|
205
|
+
className={cx("ml-select-root", className)}
|
|
206
|
+
data-disabled={disabled ? "" : undefined}
|
|
207
|
+
data-invalid={error ? "" : undefined}
|
|
208
|
+
>
|
|
209
|
+
{label ? <span id={labelId} className={hideLabel ? "ml-visually-hidden" : "ml-select-label"}>{label}</span> : null}
|
|
210
|
+
<div ref={controlRef} className="ml-select-control">
|
|
211
|
+
<button
|
|
212
|
+
ref={triggerRef}
|
|
213
|
+
type="button"
|
|
214
|
+
role="combobox"
|
|
215
|
+
aria-haspopup="listbox"
|
|
216
|
+
aria-expanded={open}
|
|
217
|
+
aria-controls={open ? listId : undefined}
|
|
218
|
+
aria-labelledby={label ? labelId : undefined}
|
|
219
|
+
aria-label={label ? undefined : placeholder}
|
|
220
|
+
aria-activedescendant={open && active ? `${id}-option-${activeIndex}` : undefined}
|
|
221
|
+
aria-invalid={error ? true : undefined}
|
|
222
|
+
aria-describedby={error ? errorId : undefined}
|
|
223
|
+
disabled={disabled}
|
|
224
|
+
data-size={size}
|
|
225
|
+
data-state={open ? "open" : "closed"}
|
|
226
|
+
className="ml-select"
|
|
227
|
+
onClick={() => setOpen((current) => !current)}
|
|
228
|
+
onKeyDown={onKeyDown}
|
|
229
|
+
>
|
|
230
|
+
{multiple ? (
|
|
231
|
+
<span className="ml-select-value" data-placeholder={chosen.length ? undefined : ""}>
|
|
232
|
+
{chosen.length ? (
|
|
233
|
+
<span className="ml-select-chips">
|
|
234
|
+
{chosen.slice(0, maxVisibleChips).map((option) => (
|
|
235
|
+
<span key={option.value} className="ml-badge" data-size="sm">
|
|
236
|
+
{option.label}
|
|
237
|
+
</span>
|
|
238
|
+
))}
|
|
239
|
+
{chosen.length > maxVisibleChips ? (
|
|
240
|
+
<span className="ml-select-chip-overflow">+{chosen.length - maxVisibleChips}</span>
|
|
241
|
+
) : null}
|
|
242
|
+
</span>
|
|
243
|
+
) : (
|
|
244
|
+
placeholder
|
|
245
|
+
)}
|
|
246
|
+
</span>
|
|
247
|
+
) : (
|
|
248
|
+
<span className="ml-select-value" data-placeholder={selected ? undefined : ""}>
|
|
249
|
+
{selected?.leading ? <span aria-hidden="true" className="ml-select-option-leading">{selected.leading}</span> : null}
|
|
250
|
+
{selected?.label ?? placeholder}
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
<IconChevronDown aria-hidden="true" className="ml-select-chevron" size="1.125em" />
|
|
254
|
+
</button>
|
|
255
|
+
{clearable && (multiple ? chosen.length > 0 : Boolean(selected)) && !disabled ? (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
aria-label={`Clear ${label ?? "selection"}`}
|
|
259
|
+
className="ml-select-clear"
|
|
260
|
+
onClick={() => {
|
|
261
|
+
if (multiple) setSelectedValues([]);
|
|
262
|
+
else setSelectedValue("");
|
|
263
|
+
close(true);
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
<IconX aria-hidden="true" size="0.875em" />
|
|
267
|
+
</button>
|
|
268
|
+
) : null}
|
|
269
|
+
</div>
|
|
270
|
+
{open && !disabled && portal ? createPortal(
|
|
271
|
+
<div ref={popoverRef} className="ml-select-popover" data-state="open" data-side="bottom">
|
|
272
|
+
{searchable ? (
|
|
273
|
+
<div className="ml-select-search-wrap">
|
|
274
|
+
<input
|
|
275
|
+
ref={searchRef}
|
|
276
|
+
className="ml-select-search"
|
|
277
|
+
value={query}
|
|
278
|
+
onChange={(event) => {
|
|
279
|
+
setQuery(event.target.value);
|
|
280
|
+
setActiveIndex(-1);
|
|
281
|
+
}}
|
|
282
|
+
onKeyDown={onKeyDown}
|
|
283
|
+
aria-label="Search options"
|
|
284
|
+
placeholder="Search options…"
|
|
285
|
+
aria-controls={listId}
|
|
286
|
+
aria-activedescendant={active ? `${id}-option-${activeIndex}` : undefined}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
) : null}
|
|
290
|
+
<ul id={listId} role="listbox" aria-multiselectable={multiple || undefined} aria-labelledby={label ? labelId : undefined} className="ml-select-list">
|
|
291
|
+
{filtered.length ? filtered.map((option, index) => (
|
|
292
|
+
<React.Fragment key={option.value}>
|
|
293
|
+
{(index === 0 || filtered[index - 1]?.group !== option.group) && option.group ? (
|
|
294
|
+
<li role="presentation" className="ml-select-group">{option.group}</li>
|
|
295
|
+
) : null}
|
|
296
|
+
<li
|
|
297
|
+
id={`${id}-option-${index}`}
|
|
298
|
+
role="option"
|
|
299
|
+
aria-selected={isChosen(option)}
|
|
300
|
+
aria-disabled={option.disabled || undefined}
|
|
301
|
+
data-state={isChosen(option) ? "checked" : "unchecked"}
|
|
302
|
+
data-highlighted={index === activeIndex ? "" : undefined}
|
|
303
|
+
data-disabled={option.disabled ? "" : undefined}
|
|
304
|
+
aria-label={option.description ? option.label : undefined}
|
|
305
|
+
aria-describedby={option.description ? `${id}-option-${index}-description` : undefined}
|
|
306
|
+
className="ml-select-option"
|
|
307
|
+
onPointerMove={() => !option.disabled && setActiveIndex(index)}
|
|
308
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
309
|
+
onClick={() => choose(option)}
|
|
310
|
+
>
|
|
311
|
+
{option.leading ? <span aria-hidden="true" className="ml-select-option-leading">{option.leading}</span> : null}
|
|
312
|
+
{option.description ? (
|
|
313
|
+
<span className="ml-select-option-text">
|
|
314
|
+
<span className="ml-select-option-label">{option.label}</span>
|
|
315
|
+
<span id={`${id}-option-${index}-description`} className="ml-select-option-description">{option.description}</span>
|
|
316
|
+
</span>
|
|
317
|
+
) : (
|
|
318
|
+
<span className="ml-select-option-label">{option.label}</span>
|
|
319
|
+
)}
|
|
320
|
+
{option.trailing ? <span aria-hidden="true" className="ml-select-option-trailing">{option.trailing}</span> : null}
|
|
321
|
+
{isChosen(option) ? <IconCheck aria-hidden="true" className="ml-select-check" size="1em" /> : null}
|
|
322
|
+
</li>
|
|
323
|
+
</React.Fragment>
|
|
324
|
+
)) : <li role="presentation" className="ml-select-empty">No results found</li>}
|
|
325
|
+
</ul>
|
|
326
|
+
</div>,
|
|
327
|
+
portal,
|
|
328
|
+
) : null}
|
|
329
|
+
{error ? <p id={errorId} role="alert" className="ml-select-error">{error}</p> : null}
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useDialogLayer } from "../_internal/dialog";
|
|
6
|
+
import { cx } from "../_internal/react";
|
|
7
|
+
import { IconX } from "@mlola-ui/icons";
|
|
8
|
+
|
|
9
|
+
export type SheetSide = "left" | "right" | "top" | "bottom";
|
|
10
|
+
export type SheetSize = "sm" | "md" | "lg";
|
|
11
|
+
|
|
12
|
+
interface SheetProps {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
side?: SheetSide;
|
|
16
|
+
size?: SheetSize;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
closeOnBackdrop?: boolean;
|
|
20
|
+
closeOnEscape?: boolean;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Sheet({
|
|
26
|
+
open,
|
|
27
|
+
onClose,
|
|
28
|
+
side = "right",
|
|
29
|
+
size = "md",
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
closeOnBackdrop = true,
|
|
33
|
+
closeOnEscape = true,
|
|
34
|
+
children,
|
|
35
|
+
className,
|
|
36
|
+
}: SheetProps) {
|
|
37
|
+
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
38
|
+
const portal = useDialogLayer({ open, onClose, closeOnEscape, panelRef });
|
|
39
|
+
const descriptionId = React.useId();
|
|
40
|
+
if (!portal || !open) return null;
|
|
41
|
+
return createPortal(
|
|
42
|
+
<div className="ml-sheet-layer" data-state="open">
|
|
43
|
+
<div
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
className="ml-sheet-overlay"
|
|
46
|
+
onMouseDown={() => closeOnBackdrop && onClose()}
|
|
47
|
+
/>
|
|
48
|
+
<div
|
|
49
|
+
ref={panelRef}
|
|
50
|
+
role="dialog"
|
|
51
|
+
aria-modal="true"
|
|
52
|
+
aria-label={title}
|
|
53
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
54
|
+
tabIndex={-1}
|
|
55
|
+
data-side={side}
|
|
56
|
+
data-size={size}
|
|
57
|
+
data-state="open"
|
|
58
|
+
className={cx("ml-sheet-panel", className)}
|
|
59
|
+
>
|
|
60
|
+
{description ? <p id={descriptionId} className="ml-visually-hidden">{description}</p> : null}
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</div>,
|
|
64
|
+
portal
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SheetHeaderProps {
|
|
69
|
+
title: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
onClose?: () => void;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function SheetHeader({ title, description, onClose, className }: SheetHeaderProps) {
|
|
76
|
+
return (
|
|
77
|
+
<div className={cx("ml-sheet-header", className)}>
|
|
78
|
+
<div className="ml-sheet-heading">
|
|
79
|
+
<h2 className="ml-sheet-title">{title}</h2>
|
|
80
|
+
{description ? <p className="ml-sheet-description">{description}</p> : null}
|
|
81
|
+
</div>
|
|
82
|
+
{onClose ? (
|
|
83
|
+
<button type="button" onClick={onClose} aria-label="Close panel" className="ml-sheet-close">
|
|
84
|
+
<IconX aria-hidden="true" />
|
|
85
|
+
</button>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SheetBodyProps {
|
|
92
|
+
children: React.ReactNode;
|
|
93
|
+
className?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function SheetBody({ children, className }: SheetBodyProps) {
|
|
97
|
+
return <div className={cx("ml-sheet-body", className)}>{children}</div>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface SheetFooterProps {
|
|
101
|
+
children: React.ReactNode;
|
|
102
|
+
className?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function SheetFooter({ children, className }: SheetFooterProps) {
|
|
106
|
+
return <div className={cx("ml-sheet-footer", className)}>{children}</div>;
|
|
107
|
+
}
|
|
108
|
+
export { Sheet, SheetHeader, SheetBody, SheetFooter };
|
|
109
|
+
export type { SheetProps, SheetHeaderProps, SheetBodyProps, SheetFooterProps };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cx } from "../_internal/react";
|
|
3
|
+
|
|
4
|
+
export interface SkeletonProps {
|
|
5
|
+
width?: string | number;
|
|
6
|
+
height?: string | number;
|
|
7
|
+
/** Rounded corners (the default), square with `false`, or any radius. */
|
|
8
|
+
rounded?: boolean | string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A placeholder shape. Decorative: wrap a loading area in SkeletonRegion to announce it. */
|
|
13
|
+
export function Skeleton({ width, height, rounded = true, className }: SkeletonProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
data-rounded={rounded === false ? "none" : typeof rounded === "string" ? undefined : ""}
|
|
18
|
+
className={cx("ml-skeleton", className)}
|
|
19
|
+
style={{ width, height, borderRadius: typeof rounded === "string" ? rounded : undefined }}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SkeletonTextProps {
|
|
25
|
+
lines?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lines that occupy exactly the space of the text they stand in for: one
|
|
31
|
+
* line-height each, inheriting the surrounding font size, so the layout does
|
|
32
|
+
* not move when the real text arrives.
|
|
33
|
+
*/
|
|
34
|
+
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
|
|
35
|
+
const count = Number.isFinite(lines) ? Math.max(1, Math.floor(lines)) : 3;
|
|
36
|
+
return (
|
|
37
|
+
<div aria-hidden="true" className={cx("ml-skeleton-text", className)}>
|
|
38
|
+
{Array.from({ length: count }, (_, index) => (
|
|
39
|
+
<div key={index} className="ml-skeleton ml-skeleton-line" data-rounded="" />
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SkeletonAvatarProps {
|
|
46
|
+
size?: number | string;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function SkeletonAvatar({ size = 40, className }: SkeletonAvatarProps) {
|
|
51
|
+
return <Skeleton width={size} height={size} rounded="50%" className={cx("ml-skeleton-avatar", className)} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SkeletonCardProps {
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SkeletonCard({ className }: SkeletonCardProps) {
|
|
59
|
+
return (
|
|
60
|
+
<div aria-hidden="true" className={cx("ml-skeleton-card", className)}>
|
|
61
|
+
<Skeleton className="ml-skeleton-card-media" />
|
|
62
|
+
<Skeleton className="ml-skeleton-card-title" />
|
|
63
|
+
<SkeletonText lines={2} />
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SkeletonRegionProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
69
|
+
/** What is loading, announced once to assistive technology. */
|
|
70
|
+
label?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The accessible wrapper for placeholders: announces that something is
|
|
75
|
+
* loading while the shapes themselves stay silent.
|
|
76
|
+
*/
|
|
77
|
+
export function SkeletonRegion({ label = "Loading", className, children, ...props }: SkeletonRegionProps) {
|
|
78
|
+
return (
|
|
79
|
+
<div role="status" aria-busy="true" className={cx("ml-skeleton-region", className)} {...props}>
|
|
80
|
+
<span className="ml-visually-hidden">{label}</span>
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { clampToStep, percentOf, sliderValueForKey, valueFromRatio } from "@mlola-ui/behavior/logic";
|
|
5
|
+
import { cx, useControllableState } from "../_internal/react";
|
|
6
|
+
|
|
7
|
+
type SliderSize = "sm" | "md";
|
|
8
|
+
interface SliderProps {
|
|
9
|
+
value?: number;
|
|
10
|
+
defaultValue?: number;
|
|
11
|
+
onValueChange?: (value: number) => void;
|
|
12
|
+
min?: number;
|
|
13
|
+
max?: number;
|
|
14
|
+
step?: number;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
label?: string;
|
|
17
|
+
showValue?: boolean;
|
|
18
|
+
size?: SliderSize;
|
|
19
|
+
className?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|
23
|
+
({ value, defaultValue = 50, onValueChange, min = 0, max = 100, step = 1, disabled = false, label, showValue = false, size = "md", className, id }, ref) => {
|
|
24
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
25
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
26
|
+
const increment = Number.isFinite(step) && step > 0 ? step : 1;
|
|
27
|
+
// Shared with the framework-free runtime, so both round identically.
|
|
28
|
+
const bounds = React.useMemo(() => ({ min: low, max: high, step: increment }), [low, high, increment]);
|
|
29
|
+
const normalize = React.useCallback((raw: number) => clampToStep(raw, bounds), [bounds]);
|
|
30
|
+
const [currentRaw, setCurrent] = useControllableState({ value, defaultValue: normalize(defaultValue), onChange: onValueChange });
|
|
31
|
+
const current = normalize(currentRaw);
|
|
32
|
+
const trackRef = React.useRef<HTMLDivElement>(null);
|
|
33
|
+
const dragging = React.useRef(false);
|
|
34
|
+
const generated = React.useId();
|
|
35
|
+
const sliderId = id ?? generated;
|
|
36
|
+
const labelId = label ? `${sliderId}-label` : undefined;
|
|
37
|
+
const valueId = showValue ? `${sliderId}-value` : undefined;
|
|
38
|
+
const percent = percentOf(current, bounds);
|
|
39
|
+
const fromPointer = (clientX: number) => {
|
|
40
|
+
const rect = trackRef.current?.getBoundingClientRect();
|
|
41
|
+
if (!rect?.width) return current;
|
|
42
|
+
return valueFromRatio((clientX - rect.left) / rect.width, bounds);
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<div className={cx("ml-slider-field", className)} data-disabled={disabled ? "" : undefined} data-size={size}>
|
|
46
|
+
{label || showValue ? (
|
|
47
|
+
<div className="ml-slider-header">
|
|
48
|
+
{label ? <span id={labelId} className="ml-slider-label">{label}</span> : <span />}
|
|
49
|
+
{showValue ? <output id={valueId} htmlFor={sliderId} className="ml-slider-output">{current}</output> : null}
|
|
50
|
+
</div>
|
|
51
|
+
) : null}
|
|
52
|
+
<div
|
|
53
|
+
ref={ref}
|
|
54
|
+
id={sliderId}
|
|
55
|
+
role="slider"
|
|
56
|
+
tabIndex={disabled ? -1 : 0}
|
|
57
|
+
aria-valuemin={low}
|
|
58
|
+
aria-valuemax={high}
|
|
59
|
+
aria-valuenow={current}
|
|
60
|
+
aria-valuetext={`${current} of ${high}`}
|
|
61
|
+
aria-labelledby={labelId}
|
|
62
|
+
aria-label={label ? undefined : "Value"}
|
|
63
|
+
aria-describedby={valueId}
|
|
64
|
+
aria-disabled={disabled || undefined}
|
|
65
|
+
className="ml-slider"
|
|
66
|
+
onKeyDown={(event) => {
|
|
67
|
+
if (disabled) return;
|
|
68
|
+
const next = sliderValueForKey(event.key, current, bounds);
|
|
69
|
+
if (next === undefined) return;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
setCurrent(next);
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<div
|
|
75
|
+
ref={trackRef}
|
|
76
|
+
className="ml-slider-track"
|
|
77
|
+
onPointerDown={(event) => {
|
|
78
|
+
if (disabled) return;
|
|
79
|
+
dragging.current = true;
|
|
80
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
81
|
+
setCurrent(normalize(fromPointer(event.clientX)));
|
|
82
|
+
}}
|
|
83
|
+
onPointerMove={(event) => {
|
|
84
|
+
if (!disabled && dragging.current) setCurrent(normalize(fromPointer(event.clientX)));
|
|
85
|
+
}}
|
|
86
|
+
onPointerUp={() => { dragging.current = false; }}
|
|
87
|
+
onPointerCancel={() => { dragging.current = false; }}
|
|
88
|
+
>
|
|
89
|
+
<span aria-hidden="true" className="ml-slider-range" style={{ width: `${percent}%` }} />
|
|
90
|
+
<span aria-hidden="true" className="ml-slider-thumb" style={{ left: `${percent}%` }} />
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
Slider.displayName = "Slider";
|
|
98
|
+
export { Slider };
|
|
99
|
+
export type { SliderProps, SliderSize };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Work statuses and their names, kept apart from the icon so plain logic can use them. */
|
|
2
|
+
export type WorkStatus = "backlog" | "todo" | "in-progress" | "in-review" | "done" | "canceled";
|
|
3
|
+
|
|
4
|
+
/** Every status in workflow order, for menus and grouping. */
|
|
5
|
+
export const WORK_STATUSES: WorkStatus[] = ["backlog", "todo", "in-progress", "in-review", "done", "canceled"];
|
|
6
|
+
|
|
7
|
+
export const STATUS_LABELS: Record<WorkStatus, string> = {
|
|
8
|
+
backlog: "Backlog",
|
|
9
|
+
todo: "Todo",
|
|
10
|
+
"in-progress": "In progress",
|
|
11
|
+
"in-review": "In review",
|
|
12
|
+
done: "Done",
|
|
13
|
+
canceled: "Canceled",
|
|
14
|
+
};
|