@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.
Files changed (150) hide show
  1. package/components/accordion.json +4 -4
  2. package/components/activity-heatmap.json +2 -2
  3. package/components/alert.json +9 -3
  4. package/components/app-shell.json +9 -3
  5. package/components/avatar.json +2 -2
  6. package/components/badge.json +2 -2
  7. package/components/breadcrumb.json +2 -2
  8. package/components/button.json +12 -3
  9. package/components/calendar.json +2 -2
  10. package/components/card.json +2 -2
  11. package/components/carousel.json +10 -4
  12. package/components/checkbox.json +2 -2
  13. package/components/color-picker.json +3 -3
  14. package/components/combobox.json +3 -3
  15. package/components/command-menu.json +3 -3
  16. package/components/context-menu.json +5 -5
  17. package/components/copy-button.json +10 -2
  18. package/components/date-picker.json +3 -3
  19. package/components/dropdown-menu.json +3 -3
  20. package/components/dropzone.json +1 -1
  21. package/components/empty-state.json +2 -2
  22. package/components/hover-card.json +4 -4
  23. package/components/input.json +2 -2
  24. package/components/kbd.json +1 -1
  25. package/components/modal.json +17 -5
  26. package/components/number-input.json +3 -3
  27. package/components/otp-input.json +2 -2
  28. package/components/pagination.json +2 -2
  29. package/components/password-input.json +2 -2
  30. package/components/popover.json +4 -4
  31. package/components/priority-icon.json +1 -1
  32. package/components/progress.json +8 -2
  33. package/components/radio-group.json +1 -1
  34. package/components/rating.json +1 -1
  35. package/components/resizable.json +1 -1
  36. package/components/segmented-control.json +2 -2
  37. package/components/select.json +8 -6
  38. package/components/sheet.json +17 -5
  39. package/components/skeleton.json +1 -1
  40. package/components/slider.json +6 -4
  41. package/components/spinner.json +1 -1
  42. package/components/status-icon.json +1 -1
  43. package/components/stepper.json +2 -2
  44. package/components/table.json +1 -1
  45. package/components/tabs.json +3 -3
  46. package/components/tag-input.json +3 -3
  47. package/components/textarea.json +8 -2
  48. package/components/time-picker.json +3 -3
  49. package/components/timeline.json +2 -2
  50. package/components/toast.json +2 -2
  51. package/components/toggle.json +1 -1
  52. package/components/tooltip.json +3 -3
  53. package/components/tour.json +11 -5
  54. package/components/virtual-list.json +2 -2
  55. package/generated/catalog.js +117 -46
  56. package/generated/examples.json +29 -17
  57. package/index.json +1 -1
  58. package/package.json +1 -1
  59. package/snapshot/index.json +218 -137
  60. package/snapshot/items/components/accordion.json +4 -4
  61. package/snapshot/items/components/activity-heatmap.json +2 -2
  62. package/snapshot/items/components/alert.json +9 -3
  63. package/snapshot/items/components/app-shell.json +9 -3
  64. package/snapshot/items/components/avatar.json +2 -2
  65. package/snapshot/items/components/badge.json +2 -2
  66. package/snapshot/items/components/breadcrumb.json +2 -2
  67. package/snapshot/items/components/button.json +12 -3
  68. package/snapshot/items/components/calendar.json +2 -2
  69. package/snapshot/items/components/card.json +2 -2
  70. package/snapshot/items/components/carousel.json +10 -4
  71. package/snapshot/items/components/checkbox.json +2 -2
  72. package/snapshot/items/components/color-picker.json +3 -3
  73. package/snapshot/items/components/combobox.json +3 -3
  74. package/snapshot/items/components/command-menu.json +3 -3
  75. package/snapshot/items/components/context-menu.json +5 -5
  76. package/snapshot/items/components/copy-button.json +10 -2
  77. package/snapshot/items/components/date-picker.json +3 -3
  78. package/snapshot/items/components/dropdown-menu.json +3 -3
  79. package/snapshot/items/components/dropzone.json +1 -1
  80. package/snapshot/items/components/empty-state.json +2 -2
  81. package/snapshot/items/components/hover-card.json +4 -4
  82. package/snapshot/items/components/input.json +2 -2
  83. package/snapshot/items/components/kbd.json +1 -1
  84. package/snapshot/items/components/modal.json +17 -5
  85. package/snapshot/items/components/number-input.json +3 -3
  86. package/snapshot/items/components/otp-input.json +2 -2
  87. package/snapshot/items/components/pagination.json +2 -2
  88. package/snapshot/items/components/password-input.json +2 -2
  89. package/snapshot/items/components/popover.json +4 -4
  90. package/snapshot/items/components/priority-icon.json +1 -1
  91. package/snapshot/items/components/progress.json +8 -2
  92. package/snapshot/items/components/radio-group.json +1 -1
  93. package/snapshot/items/components/rating.json +1 -1
  94. package/snapshot/items/components/resizable.json +1 -1
  95. package/snapshot/items/components/segmented-control.json +2 -2
  96. package/snapshot/items/components/select.json +8 -6
  97. package/snapshot/items/components/sheet.json +17 -5
  98. package/snapshot/items/components/skeleton.json +1 -1
  99. package/snapshot/items/components/slider.json +6 -4
  100. package/snapshot/items/components/spinner.json +1 -1
  101. package/snapshot/items/components/status-icon.json +1 -1
  102. package/snapshot/items/components/stepper.json +2 -2
  103. package/snapshot/items/components/table.json +1 -1
  104. package/snapshot/items/components/tabs.json +3 -3
  105. package/snapshot/items/components/tag-input.json +3 -3
  106. package/snapshot/items/components/textarea.json +8 -2
  107. package/snapshot/items/components/time-picker.json +3 -3
  108. package/snapshot/items/components/timeline.json +2 -2
  109. package/snapshot/items/components/toast.json +2 -2
  110. package/snapshot/items/components/toggle.json +1 -1
  111. package/snapshot/items/components/tooltip.json +3 -3
  112. package/snapshot/items/components/tour.json +11 -5
  113. package/snapshot/items/components/virtual-list.json +2 -2
  114. package/snapshot/source/packages/components/_internal/dialog.ts +30 -35
  115. package/snapshot/source/packages/components/_internal/dom.ts +14 -0
  116. package/snapshot/source/packages/components/_internal/floating.ts +13 -16
  117. package/snapshot/source/packages/components/_internal/media.ts +34 -0
  118. package/snapshot/source/packages/components/_internal/react.ts +43 -0
  119. package/snapshot/source/packages/components/accordion/accordion.tsx +11 -7
  120. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +2 -1
  121. package/snapshot/source/packages/components/alert/alert.tsx +2 -1
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +8 -16
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +4 -3
  124. package/snapshot/source/packages/components/button/button.tsx +4 -1
  125. package/snapshot/source/packages/components/card/card.tsx +8 -4
  126. package/snapshot/source/packages/components/carousel/carousel.tsx +2 -9
  127. package/snapshot/source/packages/components/color-picker/color-picker.tsx +15 -12
  128. package/snapshot/source/packages/components/combobox/combobox.tsx +18 -13
  129. package/snapshot/source/packages/components/command-menu/command-menu.tsx +5 -5
  130. package/snapshot/source/packages/components/context-menu/context-menu.tsx +1 -1
  131. package/snapshot/source/packages/components/date-picker/date-picker.tsx +12 -9
  132. package/snapshot/source/packages/components/empty-state/empty-state.tsx +5 -3
  133. package/snapshot/source/packages/components/hover-card/hover-card.tsx +3 -7
  134. package/snapshot/source/packages/components/input/input.tsx +43 -5
  135. package/snapshot/source/packages/components/modal/modal.tsx +21 -9
  136. package/snapshot/source/packages/components/number-input/number-input.tsx +11 -7
  137. package/snapshot/source/packages/components/otp-input/otp-input.tsx +19 -10
  138. package/snapshot/source/packages/components/popover/popover.tsx +2 -1
  139. package/snapshot/source/packages/components/progress/progress.tsx +14 -11
  140. package/snapshot/source/packages/components/select/select.tsx +34 -11
  141. package/snapshot/source/packages/components/sheet/sheet.tsx +22 -11
  142. package/snapshot/source/packages/components/slider/slider.tsx +8 -8
  143. package/snapshot/source/packages/components/tabs/tabs.tsx +8 -7
  144. package/snapshot/source/packages/components/tag-input/tag-input.tsx +11 -7
  145. package/snapshot/source/packages/components/textarea/textarea.tsx +5 -4
  146. package/snapshot/source/packages/components/time-picker/time-picker.tsx +12 -9
  147. package/snapshot/source/packages/components/tooltip/tooltip.tsx +4 -3
  148. package/snapshot/source/packages/components/tour/tour.tsx +6 -8
  149. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +12 -15
  150. 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 !== lastGroup ? option.group : null;
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
- React.useEffect(() => {
208
- if (!open) setQuery(chosen?.label ?? "");
209
- }, [chosen?.label, open]);
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-describedby={fieldDescription(fieldId, { hint, error })}
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
- let index = -1;
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
- index += 1;
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
- aria-invalid={error ? true : undefined}
121
- aria-describedby={fieldDescription(fieldId, { hint, error })}
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
- <h3 className="ml-empty-title">{title}</h3>
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?: string;
10
- hint?: string;
11
- error?: string;
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
- description?: string;
21
- className?: string;
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 = "Dialog",
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: string;
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-describedby={fieldDescription(fieldId, { hint, error })}
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?: string;
15
- hint?: string;
16
- error?: string;
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 className="ml-otp" role="group" aria-label={label ?? `${length}-character code`} data-invalid={error ? "" : undefined}>
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}