@mlola-ui/registry 1.0.13 → 1.1.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/components/accordion.json +4 -4
- package/components/activity-heatmap.json +2 -2
- package/components/alert.json +9 -3
- package/components/app-shell.json +9 -3
- package/components/avatar.json +2 -2
- package/components/badge.json +2 -2
- package/components/breadcrumb.json +2 -2
- package/components/button.json +12 -3
- package/components/calendar.json +2 -2
- package/components/card.json +2 -2
- package/components/carousel.json +10 -4
- package/components/checkbox.json +2 -2
- package/components/color-picker.json +3 -3
- package/components/combobox.json +3 -3
- package/components/command-menu.json +3 -3
- package/components/context-menu.json +5 -5
- package/components/copy-button.json +10 -2
- package/components/date-picker.json +3 -3
- package/components/dropdown-menu.json +3 -3
- package/components/dropzone.json +1 -1
- package/components/empty-state.json +2 -2
- package/components/hover-card.json +4 -4
- package/components/input.json +2 -2
- package/components/kbd.json +1 -1
- package/components/modal.json +17 -5
- package/components/number-input.json +3 -3
- package/components/otp-input.json +2 -2
- package/components/pagination.json +2 -2
- package/components/password-input.json +2 -2
- package/components/popover.json +4 -4
- package/components/priority-icon.json +1 -1
- package/components/progress.json +8 -2
- package/components/radio-group.json +1 -1
- package/components/rating.json +1 -1
- package/components/resizable.json +1 -1
- package/components/segmented-control.json +2 -2
- package/components/select.json +8 -6
- package/components/sheet.json +17 -5
- package/components/skeleton.json +1 -1
- package/components/slider.json +6 -4
- package/components/spinner.json +1 -1
- package/components/status-icon.json +1 -1
- package/components/stepper.json +2 -2
- package/components/table.json +1 -1
- package/components/tabs.json +3 -3
- package/components/tag-input.json +3 -3
- package/components/textarea.json +8 -2
- package/components/time-picker.json +3 -3
- package/components/timeline.json +2 -2
- package/components/toast.json +2 -2
- package/components/toggle.json +1 -1
- package/components/tooltip.json +3 -3
- package/components/tour.json +11 -5
- package/components/virtual-list.json +2 -2
- package/generated/catalog.js +117 -46
- package/generated/examples.json +29 -17
- package/index.json +1 -1
- package/package.json +1 -1
- package/snapshot/index.json +218 -137
- package/snapshot/items/components/accordion.json +4 -4
- package/snapshot/items/components/activity-heatmap.json +2 -2
- package/snapshot/items/components/alert.json +9 -3
- package/snapshot/items/components/app-shell.json +9 -3
- package/snapshot/items/components/avatar.json +2 -2
- package/snapshot/items/components/badge.json +2 -2
- package/snapshot/items/components/breadcrumb.json +2 -2
- package/snapshot/items/components/button.json +12 -3
- package/snapshot/items/components/calendar.json +2 -2
- package/snapshot/items/components/card.json +2 -2
- package/snapshot/items/components/carousel.json +10 -4
- package/snapshot/items/components/checkbox.json +2 -2
- package/snapshot/items/components/color-picker.json +3 -3
- package/snapshot/items/components/combobox.json +3 -3
- package/snapshot/items/components/command-menu.json +3 -3
- package/snapshot/items/components/context-menu.json +5 -5
- package/snapshot/items/components/copy-button.json +10 -2
- package/snapshot/items/components/date-picker.json +3 -3
- package/snapshot/items/components/dropdown-menu.json +3 -3
- package/snapshot/items/components/dropzone.json +1 -1
- package/snapshot/items/components/empty-state.json +2 -2
- package/snapshot/items/components/hover-card.json +4 -4
- package/snapshot/items/components/input.json +2 -2
- package/snapshot/items/components/kbd.json +1 -1
- package/snapshot/items/components/modal.json +17 -5
- package/snapshot/items/components/number-input.json +3 -3
- package/snapshot/items/components/otp-input.json +2 -2
- package/snapshot/items/components/pagination.json +2 -2
- package/snapshot/items/components/password-input.json +2 -2
- package/snapshot/items/components/popover.json +4 -4
- package/snapshot/items/components/priority-icon.json +1 -1
- package/snapshot/items/components/progress.json +8 -2
- package/snapshot/items/components/radio-group.json +1 -1
- package/snapshot/items/components/rating.json +1 -1
- package/snapshot/items/components/resizable.json +1 -1
- package/snapshot/items/components/segmented-control.json +2 -2
- package/snapshot/items/components/select.json +8 -6
- package/snapshot/items/components/sheet.json +17 -5
- package/snapshot/items/components/skeleton.json +1 -1
- package/snapshot/items/components/slider.json +6 -4
- package/snapshot/items/components/spinner.json +1 -1
- package/snapshot/items/components/status-icon.json +1 -1
- package/snapshot/items/components/stepper.json +2 -2
- package/snapshot/items/components/table.json +1 -1
- package/snapshot/items/components/tabs.json +3 -3
- package/snapshot/items/components/tag-input.json +3 -3
- package/snapshot/items/components/textarea.json +8 -2
- package/snapshot/items/components/time-picker.json +3 -3
- package/snapshot/items/components/timeline.json +2 -2
- package/snapshot/items/components/toast.json +2 -2
- package/snapshot/items/components/toggle.json +1 -1
- package/snapshot/items/components/tooltip.json +3 -3
- package/snapshot/items/components/tour.json +11 -5
- package/snapshot/items/components/virtual-list.json +2 -2
- package/snapshot/source/packages/components/_internal/dialog.ts +30 -35
- package/snapshot/source/packages/components/_internal/dom.ts +14 -0
- package/snapshot/source/packages/components/_internal/floating.ts +13 -16
- package/snapshot/source/packages/components/_internal/media.ts +34 -0
- package/snapshot/source/packages/components/_internal/react.ts +43 -0
- package/snapshot/source/packages/components/accordion/accordion.tsx +11 -7
- package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +2 -1
- package/snapshot/source/packages/components/alert/alert.tsx +2 -1
- package/snapshot/source/packages/components/app-shell/app-shell.tsx +8 -16
- package/snapshot/source/packages/components/avatar/avatar.tsx +4 -3
- package/snapshot/source/packages/components/button/button.tsx +4 -1
- package/snapshot/source/packages/components/card/card.tsx +8 -4
- package/snapshot/source/packages/components/carousel/carousel.tsx +2 -9
- package/snapshot/source/packages/components/color-picker/color-picker.tsx +15 -12
- package/snapshot/source/packages/components/combobox/combobox.tsx +18 -13
- package/snapshot/source/packages/components/command-menu/command-menu.tsx +5 -5
- package/snapshot/source/packages/components/context-menu/context-menu.tsx +1 -1
- package/snapshot/source/packages/components/date-picker/date-picker.tsx +12 -9
- package/snapshot/source/packages/components/empty-state/empty-state.tsx +5 -3
- package/snapshot/source/packages/components/hover-card/hover-card.tsx +3 -7
- package/snapshot/source/packages/components/input/input.tsx +43 -5
- package/snapshot/source/packages/components/modal/modal.tsx +21 -9
- package/snapshot/source/packages/components/number-input/number-input.tsx +11 -7
- package/snapshot/source/packages/components/otp-input/otp-input.tsx +19 -10
- package/snapshot/source/packages/components/popover/popover.tsx +2 -1
- package/snapshot/source/packages/components/progress/progress.tsx +14 -11
- package/snapshot/source/packages/components/select/select.tsx +34 -11
- package/snapshot/source/packages/components/sheet/sheet.tsx +22 -11
- package/snapshot/source/packages/components/slider/slider.tsx +8 -8
- package/snapshot/source/packages/components/tabs/tabs.tsx +8 -7
- package/snapshot/source/packages/components/tag-input/tag-input.tsx +11 -7
- package/snapshot/source/packages/components/textarea/textarea.tsx +5 -4
- package/snapshot/source/packages/components/time-picker/time-picker.tsx +12 -9
- package/snapshot/source/packages/components/tooltip/tooltip.tsx +4 -3
- package/snapshot/source/packages/components/tour/tour.tsx +6 -8
- package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +12 -15
- package/snapshot/source-manifest.json +36 -34
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { IconCheck, IconChevronDown, IconPlus, IconX } from "@mlola-ui/icons";
|
|
5
|
-
import { Field, fieldDescription } from "../input/input";
|
|
5
|
+
import { Field, fieldDescription, type FormControlProps } from "../input/input";
|
|
6
6
|
import { cx } from "../_internal/react";
|
|
7
7
|
import { matchCommand } from "../_internal/match";
|
|
8
8
|
import { revealIn } from "../_internal/scroll";
|
|
@@ -54,13 +54,11 @@ export function ComboboxList({ id, options, active, onActiveChange, onPick, isSe
|
|
|
54
54
|
React.useEffect(() => {
|
|
55
55
|
revealIn(list.current, list.current?.querySelector<HTMLElement>("[data-highlighted]") ?? null);
|
|
56
56
|
}, [active, options]);
|
|
57
|
-
let lastGroup: string | undefined;
|
|
58
57
|
return (
|
|
59
58
|
<div className="ml-combobox-popover" data-side={side} onMouseDown={(event) => event.preventDefault()}>
|
|
60
59
|
<ul ref={list} id={id} role="listbox" aria-label={label} className="ml-combobox-list">
|
|
61
60
|
{options.map((option, index) => {
|
|
62
|
-
const heading = grouped && option.group && option.group !==
|
|
63
|
-
lastGroup = option.group;
|
|
61
|
+
const heading = grouped && option.group && option.group !== options[index - 1]?.group ? option.group : null;
|
|
64
62
|
const selected = isSelected?.(option) ?? false;
|
|
65
63
|
return (
|
|
66
64
|
<React.Fragment key={option.value}>
|
|
@@ -148,7 +146,7 @@ export function sideFor(element: HTMLElement | null, room = 280): "top" | "botto
|
|
|
148
146
|
return side;
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
export interface ComboboxProps {
|
|
149
|
+
export interface ComboboxProps extends FormControlProps {
|
|
152
150
|
options: ComboboxOption[];
|
|
153
151
|
value?: string | null;
|
|
154
152
|
defaultValue?: string | null;
|
|
@@ -162,9 +160,6 @@ export interface ComboboxProps {
|
|
|
162
160
|
/** Shown when nothing matches. */
|
|
163
161
|
emptyMessage?: React.ReactNode;
|
|
164
162
|
clearable?: boolean;
|
|
165
|
-
disabled?: boolean;
|
|
166
|
-
required?: boolean;
|
|
167
|
-
id?: string;
|
|
168
163
|
className?: string;
|
|
169
164
|
}
|
|
170
165
|
|
|
@@ -188,6 +183,11 @@ export const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(functi
|
|
|
188
183
|
disabled,
|
|
189
184
|
required,
|
|
190
185
|
id,
|
|
186
|
+
name,
|
|
187
|
+
form,
|
|
188
|
+
"aria-label": ariaLabel,
|
|
189
|
+
"aria-labelledby": labelledBy,
|
|
190
|
+
"aria-describedby": describedBy,
|
|
191
191
|
className,
|
|
192
192
|
}: ComboboxProps, ref) {
|
|
193
193
|
const autoId = React.useId();
|
|
@@ -204,9 +204,12 @@ export const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(functi
|
|
|
204
204
|
const typed = React.useRef(false);
|
|
205
205
|
|
|
206
206
|
// Show the chosen label whenever the value changes from outside or the list closes.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
const chosenLabel = chosen?.label ?? "";
|
|
208
|
+
const [synced, setSynced] = React.useState({ label: chosenLabel, open });
|
|
209
|
+
if (synced.label !== chosenLabel || synced.open !== open) {
|
|
210
|
+
setSynced({ label: chosenLabel, open });
|
|
211
|
+
if (!open) setQuery(chosenLabel);
|
|
212
|
+
}
|
|
210
213
|
|
|
211
214
|
const shown = typed.current ? filterOptions(options, query) : options;
|
|
212
215
|
const exact = options.some((option) => option.label.toLowerCase() === query.trim().toLowerCase());
|
|
@@ -270,7 +273,7 @@ export const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(functi
|
|
|
270
273
|
};
|
|
271
274
|
|
|
272
275
|
return (
|
|
273
|
-
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-combobox", className)}>
|
|
276
|
+
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} name={name} form={form} value={current} className={cx("ml-combobox", className)}>
|
|
274
277
|
<div ref={control} className="ml-combobox-control" data-open={open || undefined}>
|
|
275
278
|
{chosen?.leading && !typed.current ? (
|
|
276
279
|
<span className="ml-combobox-value-leading" aria-hidden="true">
|
|
@@ -289,7 +292,9 @@ export const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(functi
|
|
|
289
292
|
aria-autocomplete="list"
|
|
290
293
|
aria-activedescendant={open && count ? `${listId}-${active}` : undefined}
|
|
291
294
|
aria-invalid={error ? true : undefined}
|
|
292
|
-
aria-
|
|
295
|
+
aria-label={ariaLabel}
|
|
296
|
+
aria-labelledby={labelledBy}
|
|
297
|
+
aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
|
|
293
298
|
value={query}
|
|
294
299
|
placeholder={placeholder}
|
|
295
300
|
disabled={disabled}
|
|
@@ -112,7 +112,8 @@ export function CommandMenu({ groups, open, defaultOpen = false, onOpenChange, h
|
|
|
112
112
|
item.onSelect();
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// Where each group's rows start in the flat list the arrows move through.
|
|
116
|
+
const starts = results.map((_, group) => results.slice(0, group).reduce((count, previous) => count + previous.items.length, 0));
|
|
116
117
|
return (
|
|
117
118
|
<Modal open={isOpen} onClose={close} label={label} size="md" className="ml-command">
|
|
118
119
|
<div className="ml-command-search">
|
|
@@ -144,14 +145,13 @@ export function CommandMenu({ groups, open, defaultOpen = false, onOpenChange, h
|
|
|
144
145
|
/>
|
|
145
146
|
</div>
|
|
146
147
|
<div id={listId} role="listbox" aria-label="Commands" className="ml-command-list">
|
|
147
|
-
{results.map((group) => (
|
|
148
|
+
{results.map((group, groupIndex) => (
|
|
148
149
|
<div key={group.heading} role="group" aria-label={group.heading} className="ml-command-group">
|
|
149
150
|
<p className="ml-command-heading" aria-hidden="true">
|
|
150
151
|
{group.heading}
|
|
151
152
|
</p>
|
|
152
|
-
{group.items.map(({ item, match }) => {
|
|
153
|
-
|
|
154
|
-
const position = index;
|
|
153
|
+
{group.items.map(({ item, match }, itemIndex) => {
|
|
154
|
+
const position = starts[groupIndex] + itemIndex;
|
|
155
155
|
return (
|
|
156
156
|
<div
|
|
157
157
|
key={item.id}
|
|
@@ -159,7 +159,7 @@ export function ContextMenu({ children, items, label = "Actions", disabled, clas
|
|
|
159
159
|
{children}
|
|
160
160
|
{state && portal
|
|
161
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 }}>
|
|
162
|
+
<div ref={menu} data-ml-portal="" className="ml-context-menu" role="menu" aria-label={label} tabIndex={-1} onKeyDown={onMenuKey} style={{ left: state.x, top: state.y }}>
|
|
163
163
|
{state.items.map((item, index) => (
|
|
164
164
|
<React.Fragment key={`${item.label}-${index}`}>
|
|
165
165
|
{item.separatorBefore ? <div className="ml-context-menu-separator" role="separator" /> : null}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { IconCalendar, IconX } from "@mlola-ui/icons";
|
|
5
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";
|
|
6
|
+
import { Field, fieldDescription, type FormControlProps } from "../input/input";
|
|
7
7
|
import { Popover } from "../popover/popover";
|
|
8
8
|
import { cx } from "../_internal/react";
|
|
9
9
|
import { startOfMonth, toTime } from "../calendar/dates";
|
|
@@ -15,7 +15,7 @@ export interface DatePreset {
|
|
|
15
15
|
value: ISODate | DateRange;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
interface BaseProps {
|
|
18
|
+
interface BaseProps extends FormControlProps {
|
|
19
19
|
label?: React.ReactNode;
|
|
20
20
|
hint?: React.ReactNode;
|
|
21
21
|
error?: React.ReactNode;
|
|
@@ -33,9 +33,6 @@ interface BaseProps {
|
|
|
33
33
|
locale?: string;
|
|
34
34
|
/** Show a button that empties the field. */
|
|
35
35
|
clearable?: boolean;
|
|
36
|
-
disabled?: boolean;
|
|
37
|
-
required?: boolean;
|
|
38
|
-
id?: string;
|
|
39
36
|
/** Today's date, for server rendering. */
|
|
40
37
|
today?: ISODate;
|
|
41
38
|
className?: string;
|
|
@@ -63,6 +60,9 @@ interface RangeProps {
|
|
|
63
60
|
|
|
64
61
|
export type DatePickerProps = BaseProps & (SingleProps | RangeProps);
|
|
65
62
|
|
|
63
|
+
/** What the form submits: the date, or a range as an ISO 8601 interval ("2026-09-01/2026-09-07"). */
|
|
64
|
+
const submitted = (value: ISODate | DateRange | null) => (value === null ? "" : typeof value === "string" ? value : `${value.from ?? ""}/${value.to ?? ""}`);
|
|
65
|
+
|
|
66
66
|
/** Ranges people reach for, relative to today. */
|
|
67
67
|
export function rangePresets(today = todayOf()): DatePreset[] {
|
|
68
68
|
return [
|
|
@@ -79,7 +79,8 @@ export function rangePresets(today = todayOf()): DatePreset[] {
|
|
|
79
79
|
* the value stays an ISO date, so it round-trips through forms and APIs.
|
|
80
80
|
*/
|
|
81
81
|
export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(function DatePicker(props: DatePickerProps, ref) {
|
|
82
|
-
const { label, hint, error, placeholder, min, max, disabledDates, enabledDates, isDisabled, presets, weekStart, locale = "en", clearable = true, disabled, required, id, today, className } = props;
|
|
82
|
+
const { label, hint, error, placeholder, min, max, disabledDates, enabledDates, isDisabled, presets, weekStart, locale = "en", clearable = true, disabled, required, id, name, form, today, className } = props;
|
|
83
|
+
const { "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": describedBy } = props;
|
|
83
84
|
const mode = props.mode ?? "single";
|
|
84
85
|
const autoId = React.useId();
|
|
85
86
|
const fieldId = id ?? autoId;
|
|
@@ -117,8 +118,10 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
|
|
|
117
118
|
className="ml-input ml-date-picker-trigger"
|
|
118
119
|
data-empty={text ? undefined : ""}
|
|
119
120
|
disabled={disabled}
|
|
120
|
-
|
|
121
|
-
aria-
|
|
121
|
+
data-invalid={error ? "" : undefined}
|
|
122
|
+
aria-label={ariaLabel}
|
|
123
|
+
aria-labelledby={labelledBy}
|
|
124
|
+
aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
|
|
122
125
|
>
|
|
123
126
|
<IconCalendar aria-hidden="true" size="1em" className="ml-date-picker-icon" />
|
|
124
127
|
<span className="ml-date-picker-value">{text ?? placeholder ?? (mode === "range" ? "Pick a range" : "Pick a date")}</span>
|
|
@@ -126,7 +129,7 @@ export const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(f
|
|
|
126
129
|
);
|
|
127
130
|
|
|
128
131
|
return (
|
|
129
|
-
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-date-picker", className)}>
|
|
132
|
+
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} name={name} form={form} value={submitted(value)} className={cx("ml-date-picker", className)}>
|
|
130
133
|
<div className="ml-date-picker-control">
|
|
131
134
|
<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">
|
|
132
135
|
<div className="ml-date-picker-panel" data-presets={shownPresets.length ? "" : undefined}>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { cx } from "../_internal/react";
|
|
2
|
+
import { cx, Heading, type HeadingLevel } from "../_internal/react";
|
|
3
3
|
|
|
4
4
|
export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
5
5
|
/** An icon or small illustration. */
|
|
@@ -10,13 +10,15 @@ export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLElement>,
|
|
|
10
10
|
actions?: React.ReactNode;
|
|
11
11
|
/** "page" centers in a large area; "inline" sits inside a card or table. */
|
|
12
12
|
size?: "inline" | "page";
|
|
13
|
+
/** The level of the title's heading, so the empty state fits the page's outline. */
|
|
14
|
+
headingLevel?: HeadingLevel;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* What a screen says when there is nothing to show yet: why, and what to do
|
|
17
19
|
* next. Never a blank area and never an apology without a way forward.
|
|
18
20
|
*/
|
|
19
|
-
export function EmptyState({ icon, title, description, actions, size = "inline", className, ...props }: EmptyStateProps) {
|
|
21
|
+
export function EmptyState({ icon, title, description, actions, size = "inline", headingLevel, className, ...props }: EmptyStateProps) {
|
|
20
22
|
return (
|
|
21
23
|
<section className={cx("ml-empty", className)} data-size={size} {...props}>
|
|
22
24
|
{icon ? (
|
|
@@ -24,7 +26,7 @@ export function EmptyState({ icon, title, description, actions, size = "inline",
|
|
|
24
26
|
{icon}
|
|
25
27
|
</span>
|
|
26
28
|
) : null}
|
|
27
|
-
<
|
|
29
|
+
<Heading level={headingLevel} className="ml-empty-title">{title}</Heading>
|
|
28
30
|
{description ? <p className="ml-empty-description">{description}</p> : null}
|
|
29
31
|
{actions ? <div className="ml-empty-actions">{actions}</div> : null}
|
|
30
32
|
</section>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
|
-
import { cx } from "../_internal/react";
|
|
5
|
+
import { composeRefs, cx } from "../_internal/react";
|
|
6
6
|
import { useFloating, usePortalNode, type Align, type Side } from "../_internal/floating";
|
|
7
7
|
|
|
8
8
|
export interface HoverCardProps {
|
|
@@ -52,12 +52,7 @@ export function HoverCard({ children, content, side = "bottom", align = "start",
|
|
|
52
52
|
const child = children.props as Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
|
|
53
53
|
const call = (name: string, event: unknown) => (child[name] as ((value: unknown) => void) | undefined)?.(event);
|
|
54
54
|
const trigger = React.cloneElement(children, {
|
|
55
|
-
ref: (node: HTMLElement | null) =>
|
|
56
|
-
anchor.current = node;
|
|
57
|
-
const own = child.ref;
|
|
58
|
-
if (typeof own === "function") own(node);
|
|
59
|
-
else if (own && typeof own === "object") (own as React.MutableRefObject<HTMLElement | null>).current = node;
|
|
60
|
-
},
|
|
55
|
+
ref: (node: HTMLElement | null) => composeRefs(anchor, child.ref)(node),
|
|
61
56
|
"aria-describedby": open ? id : child["aria-describedby"],
|
|
62
57
|
onPointerEnter: (event: React.PointerEvent) => {
|
|
63
58
|
call("onPointerEnter", event);
|
|
@@ -84,6 +79,7 @@ export function HoverCard({ children, content, side = "bottom", align = "start",
|
|
|
84
79
|
? createPortal(
|
|
85
80
|
<div
|
|
86
81
|
ref={layer}
|
|
82
|
+
data-ml-portal=""
|
|
87
83
|
id={id}
|
|
88
84
|
role="tooltip"
|
|
89
85
|
className={cx("ml-hover-card", className)}
|
|
@@ -6,9 +6,9 @@ import { cx } from "../_internal/react";
|
|
|
6
6
|
export type InputVariant = "default" | "filled" | "subtle";
|
|
7
7
|
type InputSize = "sm" | "md" | "lg";
|
|
8
8
|
export type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> & {
|
|
9
|
-
label?:
|
|
10
|
-
hint?:
|
|
11
|
-
error?:
|
|
9
|
+
label?: React.ReactNode;
|
|
10
|
+
hint?: React.ReactNode;
|
|
11
|
+
error?: React.ReactNode;
|
|
12
12
|
variant?: InputVariant;
|
|
13
13
|
size?: InputSize;
|
|
14
14
|
containerClassName?: string;
|
|
@@ -18,6 +18,28 @@ export type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size
|
|
|
18
18
|
trailing?: React.ReactNode;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* What every form control built from more than a native input accepts, so
|
|
23
|
+
* it works like a native control: an outside `<label htmlFor>` names it,
|
|
24
|
+
* a form submits its value, and a caller can name and describe it.
|
|
25
|
+
*/
|
|
26
|
+
export interface FormControlProps {
|
|
27
|
+
/** The control's id, for an outside `<label htmlFor>`; the label, hint and error are tied to it. */
|
|
28
|
+
id?: string;
|
|
29
|
+
/** Submits the value with the form, as a native control would. */
|
|
30
|
+
name?: string;
|
|
31
|
+
/** The id of a form elsewhere on the page to submit the value with. */
|
|
32
|
+
form?: string;
|
|
33
|
+
required?: boolean;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** Names the control when there is no `label`. */
|
|
36
|
+
"aria-label"?: string;
|
|
37
|
+
/** Names the control by other elements, such as a heading above a group. */
|
|
38
|
+
"aria-labelledby"?: string;
|
|
39
|
+
/** More description, read with the hint and the error. */
|
|
40
|
+
"aria-describedby"?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export interface FieldProps {
|
|
22
44
|
/** The control's id; the label, hint and error are tied to it. */
|
|
23
45
|
id: string;
|
|
@@ -26,6 +48,10 @@ export interface FieldProps {
|
|
|
26
48
|
error?: React.ReactNode;
|
|
27
49
|
required?: boolean;
|
|
28
50
|
disabled?: boolean;
|
|
51
|
+
/** With `name`, the value the form submits: one entry per value, like a native control. */
|
|
52
|
+
name?: string;
|
|
53
|
+
form?: string;
|
|
54
|
+
value?: string | readonly string[] | null;
|
|
29
55
|
className?: string;
|
|
30
56
|
children: React.ReactNode;
|
|
31
57
|
}
|
|
@@ -40,22 +66,33 @@ export function fieldDescription(id: string, { hint, error, describedBy }: { hin
|
|
|
40
66
|
* error announced when it appears. Input, Textarea, OTP Input and Dropzone
|
|
41
67
|
* all use it, so labels and errors behave identically everywhere.
|
|
42
68
|
*/
|
|
43
|
-
export function Field({ id, label, hint, error, required, disabled, className, children }: FieldProps) {
|
|
69
|
+
export function Field({ id, label, hint, error, required, disabled, name, form, value, className, children }: FieldProps) {
|
|
44
70
|
return (
|
|
45
71
|
<div className={cx("ml-input-field", className)} data-disabled={disabled ? "" : undefined} data-invalid={error ? "" : undefined}>
|
|
46
72
|
{label ? (
|
|
47
|
-
<label htmlFor={id} className="ml-input-label">
|
|
73
|
+
<label id={`${id}-label`} htmlFor={id} className="ml-input-label">
|
|
48
74
|
{label}
|
|
49
75
|
{required ? <span aria-hidden="true" className="ml-required-mark">*</span> : null}
|
|
50
76
|
</label>
|
|
51
77
|
) : null}
|
|
52
78
|
{children}
|
|
79
|
+
<FieldValue name={name} form={form} value={value} disabled={disabled} />
|
|
53
80
|
{hint ? <p id={`${id}-hint`} className="ml-input-hint">{hint}</p> : null}
|
|
54
81
|
{error ? <p id={`${id}-error`} role="alert" className="ml-input-error">{error}</p> : null}
|
|
55
82
|
</div>
|
|
56
83
|
);
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What a control that is not a native input submits with its form: a hidden
|
|
88
|
+
* input per value, disabled with the control so a disabled field is not sent.
|
|
89
|
+
*/
|
|
90
|
+
export function FieldValue({ name, form, value, disabled }: { name?: string; form?: string; value?: string | readonly string[] | null; disabled?: boolean }) {
|
|
91
|
+
if (!name) return null;
|
|
92
|
+
const values = typeof value === "string" ? [value] : (value ?? [""]);
|
|
93
|
+
return values.map((entry, index) => <input key={index} type="hidden" name={name} form={form} value={entry} disabled={disabled} />);
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
60
97
|
({ label, hint, error, variant = "default", size = "md", containerClassName, leading, trailing, className, id, disabled, required, "aria-describedby": describedBy, ...props }, ref) => {
|
|
61
98
|
const generated = React.useId();
|
|
@@ -72,6 +109,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
72
109
|
for (const affix of affixes) element.style.setProperty(affix.classList.contains("ml-input-leading") ? "--ml-input-leading" : "--ml-input-trailing", `${affix.offsetWidth}px`);
|
|
73
110
|
};
|
|
74
111
|
measure();
|
|
112
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
75
113
|
const observer = new ResizeObserver(measure);
|
|
76
114
|
affixes.forEach((affix) => observer.observe(affix));
|
|
77
115
|
return () => observer.disconnect();
|
|
@@ -8,7 +8,7 @@ import { IconX } from "@mlola-ui/icons";
|
|
|
8
8
|
|
|
9
9
|
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
10
10
|
|
|
11
|
-
export interface ModalProps {
|
|
11
|
+
export interface ModalProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "role" | "children"> {
|
|
12
12
|
open: boolean;
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
size?: ModalSize;
|
|
@@ -16,12 +16,16 @@ export interface ModalProps {
|
|
|
16
16
|
role?: "dialog" | "alertdialog";
|
|
17
17
|
closeOnBackdrop?: boolean;
|
|
18
18
|
closeOnEscape?: boolean;
|
|
19
|
+
/** Names the dialog when it has no `ModalHeader`; a header's title names it otherwise. */
|
|
19
20
|
label?: string;
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
/** Read after the name when the dialog opens; not shown. */
|
|
22
|
+
description?: React.ReactNode;
|
|
22
23
|
children: React.ReactNode;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** The id a `ModalHeader` gives its title, so the dialog is named by what it shows. */
|
|
27
|
+
const TitleId = React.createContext<string | undefined>(undefined);
|
|
28
|
+
|
|
25
29
|
export function Modal({
|
|
26
30
|
open,
|
|
27
31
|
onClose,
|
|
@@ -29,17 +33,21 @@ export function Modal({
|
|
|
29
33
|
role = "dialog",
|
|
30
34
|
closeOnBackdrop = role !== "alertdialog",
|
|
31
35
|
closeOnEscape = true,
|
|
32
|
-
label
|
|
36
|
+
label,
|
|
33
37
|
description,
|
|
34
38
|
className,
|
|
35
39
|
children,
|
|
40
|
+
...props
|
|
36
41
|
}: ModalProps) {
|
|
37
42
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
38
43
|
const portal = useDialogLayer({ open, onClose, closeOnEscape, panelRef });
|
|
44
|
+
const titleId = React.useId();
|
|
39
45
|
const descriptionId = React.useId();
|
|
40
46
|
if (!portal || !open) return null;
|
|
47
|
+
const named = label ?? props["aria-label"] ?? props["aria-labelledby"];
|
|
41
48
|
return createPortal(
|
|
42
49
|
<div
|
|
50
|
+
data-ml-portal=""
|
|
43
51
|
className="ml-modal-overlay"
|
|
44
52
|
data-state="open"
|
|
45
53
|
onMouseDown={(event) => {
|
|
@@ -47,18 +55,20 @@ export function Modal({
|
|
|
47
55
|
}}
|
|
48
56
|
>
|
|
49
57
|
<div
|
|
58
|
+
aria-labelledby={named ? undefined : titleId}
|
|
59
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
60
|
+
{...props}
|
|
50
61
|
ref={panelRef}
|
|
51
62
|
role={role}
|
|
52
63
|
aria-modal="true"
|
|
53
|
-
aria-label={label}
|
|
54
|
-
aria-describedby={description ? descriptionId : undefined}
|
|
64
|
+
aria-label={label ?? props["aria-label"]}
|
|
55
65
|
tabIndex={-1}
|
|
56
66
|
data-size={size}
|
|
57
67
|
data-state="open"
|
|
58
68
|
className={cx("ml-modal", className)}
|
|
59
69
|
>
|
|
60
70
|
{description ? <p id={descriptionId} className="ml-visually-hidden">{description}</p> : null}
|
|
61
|
-
{children}
|
|
71
|
+
<TitleId.Provider value={titleId}>{children}</TitleId.Provider>
|
|
62
72
|
</div>
|
|
63
73
|
</div>,
|
|
64
74
|
portal
|
|
@@ -66,15 +76,17 @@ export function Modal({
|
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
export interface ModalHeaderProps {
|
|
69
|
-
title
|
|
79
|
+
/** The dialog's visible title, which also names it for assistive technology. */
|
|
80
|
+
title: React.ReactNode;
|
|
70
81
|
onClose?: () => void;
|
|
71
82
|
className?: string;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
export function ModalHeader({ title, onClose, className }: ModalHeaderProps) {
|
|
86
|
+
const titleId = React.useContext(TitleId);
|
|
75
87
|
return (
|
|
76
88
|
<div className={cx("ml-modal-header", className)}>
|
|
77
|
-
<h2 className="ml-modal-title">{title}</h2>
|
|
89
|
+
<h2 id={titleId} className="ml-modal-title">{title}</h2>
|
|
78
90
|
{onClose ? (
|
|
79
91
|
<button type="button" onClick={onClose} aria-label="Close dialog" className="ml-modal-close">
|
|
80
92
|
<IconX aria-hidden="true" />
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { IconMinus, IconPlus } from "@mlola-ui/icons";
|
|
5
|
-
import { Field, fieldDescription } from "../input/input";
|
|
5
|
+
import { Field, fieldDescription, type FormControlProps } from "../input/input";
|
|
6
6
|
import { composeRefs, cx } from "../_internal/react";
|
|
7
7
|
import { parseNumber, settle, stepValue } from "./number";
|
|
8
8
|
|
|
9
9
|
export { parseNumber, settle, stepValue } from "./number";
|
|
10
10
|
|
|
11
|
-
export interface NumberInputProps {
|
|
11
|
+
export interface NumberInputProps extends FormControlProps {
|
|
12
12
|
value?: number | null;
|
|
13
13
|
defaultValue?: number | null;
|
|
14
14
|
onValueChange?: (value: number | null) => void;
|
|
@@ -28,9 +28,6 @@ export interface NumberInputProps {
|
|
|
28
28
|
locale?: string;
|
|
29
29
|
/** Show − and + beside the value. */
|
|
30
30
|
buttons?: boolean;
|
|
31
|
-
disabled?: boolean;
|
|
32
|
-
required?: boolean;
|
|
33
|
-
id?: string;
|
|
34
31
|
className?: string;
|
|
35
32
|
}
|
|
36
33
|
|
|
@@ -59,6 +56,11 @@ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
59
56
|
disabled,
|
|
60
57
|
required,
|
|
61
58
|
id,
|
|
59
|
+
name,
|
|
60
|
+
form,
|
|
61
|
+
"aria-label": ariaLabel,
|
|
62
|
+
"aria-labelledby": labelledBy,
|
|
63
|
+
"aria-describedby": describedBy,
|
|
62
64
|
className,
|
|
63
65
|
}: NumberInputProps, ref) {
|
|
64
66
|
const autoId = React.useId();
|
|
@@ -141,7 +143,7 @@ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
141
143
|
const atMax = current !== null && max !== undefined && current >= max;
|
|
142
144
|
|
|
143
145
|
return (
|
|
144
|
-
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-number-input", className)}>
|
|
146
|
+
<Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} name={name} form={form} value={current === null ? "" : String(current)} className={cx("ml-number-input", className)}>
|
|
145
147
|
<div className="ml-number-input-control" data-disabled={disabled || undefined} data-invalid={error ? "" : undefined}>
|
|
146
148
|
<input
|
|
147
149
|
ref={composeRefs(input, ref)}
|
|
@@ -155,7 +157,9 @@ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
155
157
|
aria-valuemax={max}
|
|
156
158
|
aria-valuetext={current === null ? undefined : display.format(current)}
|
|
157
159
|
aria-invalid={error ? true : undefined}
|
|
158
|
-
aria-
|
|
160
|
+
aria-label={ariaLabel}
|
|
161
|
+
aria-labelledby={labelledBy}
|
|
162
|
+
aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
|
|
159
163
|
value={shown}
|
|
160
164
|
placeholder={placeholder}
|
|
161
165
|
disabled={disabled}
|
|
@@ -2,24 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { composeRefs, useControllableState } from "../_internal/react";
|
|
5
|
-
import { Field, fieldDescription } from "../input/input";
|
|
5
|
+
import { Field, fieldDescription, type FormControlProps } from "../input/input";
|
|
6
6
|
|
|
7
|
-
export interface OtpInputProps {
|
|
7
|
+
export interface OtpInputProps extends Omit<FormControlProps, "required"> {
|
|
8
8
|
length?: number;
|
|
9
9
|
value?: string;
|
|
10
10
|
defaultValue?: string;
|
|
11
11
|
onValueChange?: (value: string) => void;
|
|
12
12
|
/** Called once every digit is filled. */
|
|
13
13
|
onComplete?: (value: string) => void;
|
|
14
|
-
label?:
|
|
15
|
-
hint?:
|
|
16
|
-
error?:
|
|
14
|
+
label?: React.ReactNode;
|
|
15
|
+
hint?: React.ReactNode;
|
|
16
|
+
error?: React.ReactNode;
|
|
17
17
|
/** "numeric" (default) or "alphanumeric". */
|
|
18
18
|
mode?: "numeric" | "alphanumeric";
|
|
19
|
-
disabled?: boolean;
|
|
20
19
|
/** Split the boxes into groups, e.g. 3 for "123 456". */
|
|
21
20
|
groupSize?: number;
|
|
22
|
-
id?: string;
|
|
23
21
|
className?: string;
|
|
24
22
|
}
|
|
25
23
|
|
|
@@ -41,6 +39,11 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
|
|
|
41
39
|
disabled,
|
|
42
40
|
groupSize,
|
|
43
41
|
id,
|
|
42
|
+
name,
|
|
43
|
+
form,
|
|
44
|
+
"aria-label": ariaLabel,
|
|
45
|
+
"aria-labelledby": labelledBy,
|
|
46
|
+
"aria-describedby": describedBy,
|
|
44
47
|
className,
|
|
45
48
|
}: OtpInputProps, ref) {
|
|
46
49
|
const [code, setCode] = useControllableState({ value, defaultValue, onChange: onValueChange });
|
|
@@ -67,8 +70,14 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
|
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
return (
|
|
70
|
-
<Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} className={className}>
|
|
71
|
-
<div
|
|
73
|
+
<Field id={fieldId} label={label} hint={hint} error={error} disabled={disabled} name={name} form={form} value={code} className={className}>
|
|
74
|
+
<div
|
|
75
|
+
className="ml-otp"
|
|
76
|
+
role="group"
|
|
77
|
+
aria-labelledby={labelledBy ?? (label ? `${fieldId}-label` : undefined)}
|
|
78
|
+
aria-label={labelledBy || label ? undefined : (ariaLabel ?? `${length}-character code`)}
|
|
79
|
+
data-invalid={error ? "" : undefined}
|
|
80
|
+
>
|
|
72
81
|
{Array.from({ length }, (_, index) => (
|
|
73
82
|
<React.Fragment key={index}>
|
|
74
83
|
{groupSize && index > 0 && index % groupSize === 0 ? <span aria-hidden="true" className="ml-otp-separator" /> : null}
|
|
@@ -83,7 +92,7 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
|
|
|
83
92
|
autoComplete={index === 0 ? "one-time-code" : "off"}
|
|
84
93
|
aria-label={`Digit ${index + 1} of ${length}`}
|
|
85
94
|
aria-invalid={error ? true : undefined}
|
|
86
|
-
aria-describedby={index === 0 ? fieldDescription(fieldId, { hint, error }) : undefined}
|
|
95
|
+
aria-describedby={index === 0 ? fieldDescription(fieldId, { hint, error, describedBy }) : undefined}
|
|
87
96
|
maxLength={index === 0 ? length : 1}
|
|
88
97
|
disabled={disabled}
|
|
89
98
|
value={(code[index] ?? "").trim()}
|
|
@@ -69,7 +69,7 @@ export function Popover({ trigger, children, label, side = "bottom", align = "st
|
|
|
69
69
|
|
|
70
70
|
const triggerProps = trigger.props as { onClick?: React.MouseEventHandler; ref?: React.Ref<HTMLElement> };
|
|
71
71
|
const anchored = React.cloneElement(trigger, {
|
|
72
|
-
ref: composeRefs(anchor, triggerProps.ref as React.Ref<HTMLElement> | undefined),
|
|
72
|
+
ref: (node: HTMLElement | null) => composeRefs(anchor, triggerProps.ref as React.Ref<HTMLElement> | undefined)(node),
|
|
73
73
|
"aria-haspopup": "dialog",
|
|
74
74
|
"aria-expanded": isOpen,
|
|
75
75
|
"aria-controls": isOpen ? id : undefined,
|
|
@@ -86,6 +86,7 @@ export function Popover({ trigger, children, label, side = "bottom", align = "st
|
|
|
86
86
|
? createPortal(
|
|
87
87
|
<div
|
|
88
88
|
ref={layer}
|
|
89
|
+
data-ml-portal=""
|
|
89
90
|
id={id}
|
|
90
91
|
role="dialog"
|
|
91
92
|
aria-label={label}
|