@mlola-ui/registry 1.0.12 → 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 (151) 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 +6 -6
  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 +5 -5
  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 +5 -5
  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 +9 -7
  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 +11 -3
  53. package/components/tour.json +12 -6
  54. package/components/virtual-list.json +2 -2
  55. package/generated/catalog.js +124 -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 +231 -142
  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 +6 -6
  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 +5 -5
  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 +5 -5
  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 +9 -7
  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 +11 -3
  112. package/snapshot/items/components/tour.json +12 -6
  113. package/snapshot/items/components/virtual-list.json +2 -2
  114. package/snapshot/source/packages/components/_internal/anchor.ts +1 -1
  115. package/snapshot/source/packages/components/_internal/dialog.ts +30 -35
  116. package/snapshot/source/packages/components/_internal/dom.ts +14 -0
  117. package/snapshot/source/packages/components/_internal/floating.ts +13 -16
  118. package/snapshot/source/packages/components/_internal/media.ts +34 -0
  119. package/snapshot/source/packages/components/_internal/react.ts +43 -0
  120. package/snapshot/source/packages/components/accordion/accordion.tsx +11 -7
  121. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +2 -1
  122. package/snapshot/source/packages/components/alert/alert.tsx +2 -1
  123. package/snapshot/source/packages/components/app-shell/app-shell.tsx +8 -16
  124. package/snapshot/source/packages/components/avatar/avatar.tsx +4 -3
  125. package/snapshot/source/packages/components/button/button.tsx +4 -1
  126. package/snapshot/source/packages/components/card/card.tsx +8 -4
  127. package/snapshot/source/packages/components/carousel/carousel.tsx +2 -9
  128. package/snapshot/source/packages/components/color-picker/color-picker.tsx +15 -12
  129. package/snapshot/source/packages/components/combobox/combobox.tsx +18 -13
  130. package/snapshot/source/packages/components/command-menu/command-menu.tsx +5 -5
  131. package/snapshot/source/packages/components/context-menu/context-menu.tsx +1 -1
  132. package/snapshot/source/packages/components/date-picker/date-picker.tsx +12 -9
  133. package/snapshot/source/packages/components/empty-state/empty-state.tsx +5 -3
  134. package/snapshot/source/packages/components/hover-card/hover-card.tsx +3 -7
  135. package/snapshot/source/packages/components/input/input.tsx +43 -5
  136. package/snapshot/source/packages/components/modal/modal.tsx +21 -9
  137. package/snapshot/source/packages/components/number-input/number-input.tsx +11 -7
  138. package/snapshot/source/packages/components/otp-input/otp-input.tsx +19 -10
  139. package/snapshot/source/packages/components/popover/popover.tsx +2 -1
  140. package/snapshot/source/packages/components/progress/progress.tsx +14 -11
  141. package/snapshot/source/packages/components/select/select.tsx +34 -11
  142. package/snapshot/source/packages/components/sheet/sheet.tsx +22 -11
  143. package/snapshot/source/packages/components/slider/slider.tsx +8 -8
  144. package/snapshot/source/packages/components/tabs/tabs.tsx +8 -7
  145. package/snapshot/source/packages/components/tag-input/tag-input.tsx +11 -7
  146. package/snapshot/source/packages/components/textarea/textarea.tsx +5 -4
  147. package/snapshot/source/packages/components/time-picker/time-picker.tsx +12 -9
  148. package/snapshot/source/packages/components/tooltip/tooltip.tsx +30 -5
  149. package/snapshot/source/packages/components/tour/tour.tsx +6 -8
  150. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +12 -15
  151. package/snapshot/source-manifest.json +37 -35
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cx } from "../_internal/react";
4
+ import { usePrefersReducedMotion } from "../_internal/media";
5
+ import { cx, splitAria } from "../_internal/react";
5
6
 
6
7
  /** The meaning of the fill, from the theme's color roles. */
7
8
  export type ProgressTone = "primary" | "info" | "success" | "warning" | "danger";
8
9
  type ProgressSize = "sm" | "md" | "lg";
9
- export interface ProgressProps {
10
+ export interface ProgressProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
10
11
  value?: number;
11
12
  max?: number;
12
13
  tone?: ProgressTone;
@@ -27,12 +28,12 @@ const metrics = (value: number, max: number) => {
27
28
 
28
29
  /** The shown percentage eases towards the real one, so the number moves with the bar. */
29
30
  function useCountUp(target: number) {
31
+ const reduced = usePrefersReducedMotion();
30
32
  const [shown, setShown] = React.useState(0);
31
33
  const current = React.useRef(0);
32
34
  React.useEffect(() => {
33
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
35
+ if (reduced) {
34
36
  current.current = target;
35
- setShown(target);
36
37
  return;
37
38
  }
38
39
  const from = current.current;
@@ -47,8 +48,8 @@ function useCountUp(target: number) {
47
48
  };
48
49
  frame = requestAnimationFrame(step);
49
50
  return () => cancelAnimationFrame(frame);
50
- }, [target]);
51
- return shown;
51
+ }, [target, reduced]);
52
+ return reduced ? target : shown;
52
53
  }
53
54
 
54
55
  /**
@@ -56,12 +57,13 @@ function useCountUp(target: number) {
56
57
  * between values with the percentage counting alongside, sweeps a light
57
58
  * along the fill while `active`, and pulses once when it reaches the end.
58
59
  */
59
- export function Progress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, active = false, className }: ProgressProps) {
60
+ export function Progress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, active = false, className, ...props }: ProgressProps) {
61
+ const [aria, rest] = splitAria(props);
60
62
  const { safeMax, safeValue, percent } = metrics(value, max);
61
63
  const shown = useCountUp(percent);
62
64
  const complete = !indeterminate && percent >= 100;
63
65
  return (
64
- <div className={cx("ml-progress-root", className)} data-tone={tone} data-size={size} data-state={indeterminate ? "indeterminate" : complete ? "complete" : "determinate"} data-active={(active && !complete && !indeterminate) || undefined}>
66
+ <div {...rest} className={cx("ml-progress-root", className)} data-tone={tone} data-size={size} data-state={indeterminate ? "indeterminate" : complete ? "complete" : "determinate"} data-active={(active && !complete && !indeterminate) || undefined}>
65
67
  {showLabel ? (
66
68
  <div className="ml-progress-label">
67
69
  <span>{label}</span>
@@ -70,21 +72,22 @@ export function Progress({ value = 0, max = 100, tone = "primary", size = "md",
70
72
  </span>
71
73
  </div>
72
74
  ) : null}
73
- <div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={safeMax} aria-valuenow={indeterminate ? undefined : safeValue} aria-valuetext={indeterminate ? "Loading" : `${percent}%`} className="ml-progress-track">
75
+ <div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={safeMax} aria-valuenow={indeterminate ? undefined : safeValue} aria-valuetext={indeterminate ? "Loading" : `${percent}%`} {...aria} className="ml-progress-track">
74
76
  <div aria-hidden="true" className="ml-progress-fill" style={indeterminate ? undefined : { width: `${percent}%` }} />
75
77
  </div>
76
78
  </div>
77
79
  );
78
80
  }
79
81
 
80
- export interface CircularProgressProps extends ProgressProps {}
82
+ export type CircularProgressProps = Omit<ProgressProps, "active">;
81
83
  const CIRCUMFERENCE = 2 * Math.PI * 44;
82
- export function CircularProgress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, className }: CircularProgressProps) {
84
+ export function CircularProgress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, className, ...props }: CircularProgressProps) {
83
85
  const { safeMax, safeValue, percent } = metrics(value, max);
84
86
  const shown = useCountUp(percent);
85
87
  const filled = indeterminate ? CIRCUMFERENCE * 0.28 : (CIRCUMFERENCE * percent) / 100;
86
88
  return (
87
89
  <div
90
+ {...props}
88
91
  role="progressbar"
89
92
  aria-label={label}
90
93
  aria-valuemin={0}
@@ -4,7 +4,8 @@ import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { rovingIndex } from "@mlola-ui/behavior/logic";
6
6
  import { useFloating, usePortalNode } from "../_internal/floating";
7
- import { composeRefs, cx, useControllableState } from "../_internal/react";
7
+ import { composeRefs, cx, splitAria, useControllableState } from "../_internal/react";
8
+ import { FieldValue, type FormControlProps } from "../input/input";
8
9
  import { IconCheck, IconChevronDown, IconX } from "@mlola-ui/icons";
9
10
 
10
11
  export interface SelectOption {
@@ -23,17 +24,18 @@ export interface SelectOption {
23
24
 
24
25
  export type SelectSize = "sm" | "md" | "lg";
25
26
 
26
- interface SelectBaseProps {
27
+ interface SelectBaseProps
28
+ extends FormControlProps,
29
+ Omit<React.HTMLAttributes<HTMLDivElement>, keyof FormControlProps | "defaultValue" | "onChange" | "children" | "placeholder"> {
27
30
  options: SelectOption[];
28
31
  /** Cap the chips rendered before collapsing into a counter. */
29
32
  maxVisibleChips?: number;
30
33
  placeholder?: string;
31
34
  searchable?: boolean;
32
35
  clearable?: boolean;
33
- disabled?: boolean;
34
36
  size?: SelectSize;
35
- error?: string;
36
- label?: string;
37
+ error?: React.ReactNode;
38
+ label?: React.ReactNode;
37
39
  /** Keep the label for assistive technology but do not show it (toolbars). */
38
40
  hideLabel?: boolean;
39
41
  className?: string;
@@ -56,6 +58,13 @@ interface SelectMultipleProps {
56
58
 
57
59
  export type SelectProps = SelectBaseProps & (SelectSingleProps | SelectMultipleProps);
58
60
 
61
+ const OWN = new Set(["options", "maxVisibleChips", "placeholder", "searchable", "clearable", "disabled", "size", "error", "label", "hideLabel", "className", "id", "name", "form", "required", "aria-label", "aria-labelledby", "aria-describedby", "multiple", "value", "defaultValue", "onValueChange"]);
62
+
63
+ /** The attributes a caller passed that Select does not read itself. */
64
+ function attributesOf(props: SelectProps) {
65
+ return Object.fromEntries(Object.entries(props).filter(([key]) => !OWN.has(key))) as React.HTMLAttributes<HTMLDivElement>;
66
+ }
67
+
59
68
  export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function Select(props: SelectProps, ref) {
60
69
  const {
61
70
  options,
@@ -69,8 +78,17 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
69
78
  label,
70
79
  hideLabel = false,
71
80
  className,
81
+ id: triggerId,
82
+ name,
83
+ form,
84
+ required,
85
+ "aria-label": ariaLabel,
86
+ "aria-labelledby": labelledBy,
87
+ "aria-describedby": describedBy,
72
88
  } = props;
73
89
  const multiple = props.multiple === true;
90
+ // Everything else a caller passes: ARIA to the trigger, the rest (data-*, style, handlers) to the root.
91
+ const [triggerAria, rootAttributes] = splitAria(attributesOf(props));
74
92
  const [selectedValue, setSelectedValue] = useControllableState<string>({
75
93
  value: props.multiple ? undefined : props.value,
76
94
  defaultValue: props.multiple ? "" : (props.defaultValue ?? ""),
@@ -201,25 +219,30 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
201
219
 
202
220
  return (
203
221
  <div
222
+ {...rootAttributes}
204
223
  ref={rootRef}
205
224
  className={cx("ml-select-root", className)}
206
225
  data-disabled={disabled ? "" : undefined}
207
226
  data-invalid={error ? "" : undefined}
208
227
  >
209
228
  {label ? <span id={labelId} className={hideLabel ? "ml-visually-hidden" : "ml-select-label"}>{label}</span> : null}
229
+ <FieldValue name={name} form={form} value={multiple ? selectedValues : selectedValue} disabled={disabled} />
210
230
  <div ref={controlRef} className="ml-select-control">
211
231
  <button
232
+ {...triggerAria}
212
233
  ref={composeRefs(triggerRef, ref)}
234
+ id={triggerId}
213
235
  type="button"
214
236
  role="combobox"
215
237
  aria-haspopup="listbox"
216
238
  aria-expanded={open}
217
239
  aria-controls={open ? listId : undefined}
218
- aria-labelledby={label ? labelId : undefined}
219
- aria-label={label ? undefined : placeholder}
240
+ aria-labelledby={labelledBy ?? (label ? labelId : undefined)}
241
+ aria-label={labelledBy || label ? undefined : (ariaLabel ?? (triggerId ? undefined : placeholder))}
220
242
  aria-activedescendant={open && active ? `${id}-option-${activeIndex}` : undefined}
221
243
  aria-invalid={error ? true : undefined}
222
- aria-describedby={error ? errorId : undefined}
244
+ aria-required={required || undefined}
245
+ aria-describedby={[describedBy, error ? errorId : null].filter(Boolean).join(" ") || undefined}
223
246
  disabled={disabled}
224
247
  data-size={size}
225
248
  data-state={open ? "open" : "closed"}
@@ -255,7 +278,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
255
278
  {clearable && (multiple ? chosen.length > 0 : Boolean(selected)) && !disabled ? (
256
279
  <button
257
280
  type="button"
258
- aria-label={`Clear ${label ?? "selection"}`}
281
+ aria-label={`Clear ${typeof label === "string" ? label : (ariaLabel ?? "selection")}`}
259
282
  className="ml-select-clear" data-hit="expand"
260
283
  onClick={() => {
261
284
  if (multiple) setSelectedValues([]);
@@ -268,7 +291,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
268
291
  ) : null}
269
292
  </div>
270
293
  {open && !disabled && portal ? createPortal(
271
- <div ref={popoverRef} className="ml-select-popover" data-state="open" data-side="bottom">
294
+ <div ref={popoverRef} data-ml-portal="" className="ml-select-popover" data-state="open" data-side="bottom">
272
295
  {searchable ? (
273
296
  <div className="ml-select-search-wrap">
274
297
  <input
@@ -287,7 +310,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
287
310
  />
288
311
  </div>
289
312
  ) : null}
290
- <ul id={listId} role="listbox" aria-multiselectable={multiple || undefined} aria-labelledby={label ? labelId : undefined} className="ml-select-list">
313
+ <ul id={listId} role="listbox" aria-multiselectable={multiple || undefined} aria-labelledby={labelledBy ?? (label ? labelId : undefined)} aria-label={labelledBy || label ? undefined : ariaLabel} className="ml-select-list">
291
314
  {filtered.length ? filtered.map((option, index) => (
292
315
  <React.Fragment key={option.value}>
293
316
  {(index === 0 || filtered[index - 1]?.group !== option.group) && option.group ? (
@@ -9,19 +9,23 @@ import { IconX } from "@mlola-ui/icons";
9
9
  export type SheetSide = "left" | "right" | "top" | "bottom";
10
10
  export type SheetSize = "sm" | "md" | "lg";
11
11
 
12
- interface SheetProps {
12
+ interface SheetProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "role" | "children" | "title"> {
13
13
  open: boolean;
14
14
  onClose: () => void;
15
15
  side?: SheetSide;
16
16
  size?: SheetSize;
17
- title: string;
18
- description?: string;
17
+ /** Names the panel when it has no `SheetHeader`; a header's title names it otherwise. */
18
+ title?: string;
19
+ /** Read after the name when the panel opens; not shown. */
20
+ description?: React.ReactNode;
19
21
  closeOnBackdrop?: boolean;
20
22
  closeOnEscape?: boolean;
21
23
  children: React.ReactNode;
22
- className?: string;
23
24
  }
24
25
 
26
+ /** The id a `SheetHeader` gives its title, so the panel is named by what it shows. */
27
+ const TitleId = React.createContext<string | undefined>(undefined);
28
+
25
29
  function Sheet({
26
30
  open,
27
31
  onClose,
@@ -33,24 +37,29 @@ function Sheet({
33
37
  closeOnEscape = true,
34
38
  children,
35
39
  className,
40
+ ...props
36
41
  }: SheetProps) {
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 = title ?? props["aria-label"] ?? props["aria-labelledby"];
41
48
  return createPortal(
42
- <div className="ml-sheet-layer" data-state="open">
49
+ <div data-ml-portal="" className="ml-sheet-layer" data-state="open">
43
50
  <div
44
51
  aria-hidden="true"
45
52
  className="ml-sheet-overlay"
46
53
  onMouseDown={() => closeOnBackdrop && onClose()}
47
54
  />
48
55
  <div
56
+ aria-labelledby={named ? undefined : titleId}
57
+ aria-describedby={description ? descriptionId : undefined}
58
+ {...props}
49
59
  ref={panelRef}
50
60
  role="dialog"
51
61
  aria-modal="true"
52
- aria-label={title}
53
- aria-describedby={description ? descriptionId : undefined}
62
+ aria-label={title ?? props["aria-label"]}
54
63
  tabIndex={-1}
55
64
  data-side={side}
56
65
  data-size={size}
@@ -58,7 +67,7 @@ function Sheet({
58
67
  className={cx("ml-sheet-panel", className)}
59
68
  >
60
69
  {description ? <p id={descriptionId} className="ml-visually-hidden">{description}</p> : null}
61
- {children}
70
+ <TitleId.Provider value={titleId}>{children}</TitleId.Provider>
62
71
  </div>
63
72
  </div>,
64
73
  portal
@@ -66,17 +75,19 @@ function Sheet({
66
75
  }
67
76
 
68
77
  interface SheetHeaderProps {
69
- title: string;
70
- description?: string;
78
+ /** The panel's visible title, which also names it for assistive technology. */
79
+ title: React.ReactNode;
80
+ description?: React.ReactNode;
71
81
  onClose?: () => void;
72
82
  className?: string;
73
83
  }
74
84
 
75
85
  function SheetHeader({ title, description, onClose, className }: SheetHeaderProps) {
86
+ const titleId = React.useContext(TitleId);
76
87
  return (
77
88
  <div className={cx("ml-sheet-header", className)}>
78
89
  <div className="ml-sheet-heading">
79
- <h2 className="ml-sheet-title">{title}</h2>
90
+ <h2 id={titleId} className="ml-sheet-title">{title}</h2>
80
91
  {description ? <p className="ml-sheet-description">{description}</p> : null}
81
92
  </div>
82
93
  {onClose ? (
@@ -3,24 +3,23 @@
3
3
  import * as React from "react";
4
4
  import { clampToStep, percentOf, sliderValueForKey, valueFromRatio } from "@mlola-ui/behavior/logic";
5
5
  import { cx, useControllableState } from "../_internal/react";
6
+ import { FieldValue, type FormControlProps } from "../input/input";
6
7
 
7
8
  type SliderSize = "sm" | "md";
8
- interface SliderProps {
9
+ interface SliderProps extends Omit<FormControlProps, "required"> {
9
10
  value?: number;
10
11
  defaultValue?: number;
11
12
  onValueChange?: (value: number) => void;
12
13
  min?: number;
13
14
  max?: number;
14
15
  step?: number;
15
- disabled?: boolean;
16
- label?: string;
16
+ label?: React.ReactNode;
17
17
  showValue?: boolean;
18
18
  size?: SliderSize;
19
19
  className?: string;
20
- id?: string;
21
20
  }
22
21
  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) => {
22
+ ({ value, defaultValue = 50, onValueChange, min = 0, max = 100, step = 1, disabled = false, label, showValue = false, size = "md", className, id, name, form, "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": describedBy }, ref) => {
24
23
  const low = Number.isFinite(min) ? min : 0;
25
24
  const high = Number.isFinite(max) && max > low ? max : low + 100;
26
25
  const increment = Number.isFinite(step) && step > 0 ? step : 1;
@@ -58,9 +57,9 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
58
57
  aria-valuemax={high}
59
58
  aria-valuenow={current}
60
59
  aria-valuetext={`${current} of ${high}`}
61
- aria-labelledby={labelId}
62
- aria-label={label ? undefined : "Value"}
63
- aria-describedby={valueId}
60
+ aria-labelledby={labelledBy ?? labelId}
61
+ aria-label={labelledBy || label ? undefined : (ariaLabel ?? "Value")}
62
+ aria-describedby={[describedBy, valueId].filter(Boolean).join(" ") || undefined}
64
63
  aria-disabled={disabled || undefined}
65
64
  className="ml-slider"
66
65
  onKeyDown={(event) => {
@@ -90,6 +89,7 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
90
89
  <span aria-hidden="true" className="ml-slider-thumb" style={{ left: `${percent}%` }} />
91
90
  </div>
92
91
  </div>
92
+ <FieldValue name={name} form={form} value={String(current)} disabled={disabled} />
93
93
  </div>
94
94
  );
95
95
  }
@@ -13,7 +13,7 @@ interface TabsContextValue {
13
13
  orientation: TabsOrientation;
14
14
  prefix: string;
15
15
  select: (value: string) => void;
16
- triggers: React.MutableRefObject<Map<string, HTMLButtonElement>>;
16
+ triggersRef: React.MutableRefObject<Map<string, HTMLButtonElement>>;
17
17
  }
18
18
 
19
19
  const TabsContext = React.createContext<TabsContextValue | null>(null);
@@ -43,10 +43,10 @@ export function Tabs({
43
43
  className,
44
44
  }: TabsProps) {
45
45
  const [current, select] = useControllableState({ value, defaultValue, onChange: onValueChange });
46
- const triggers = React.useRef(new Map<string, HTMLButtonElement>());
46
+ const triggersRef = React.useRef(new Map<string, HTMLButtonElement>());
47
47
  const prefix = `tabs-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
48
48
  return (
49
- <TabsContext.Provider value={{ value: current, variant, orientation, prefix, select, triggers }}>
49
+ <TabsContext.Provider value={{ value: current, variant, orientation, prefix, select, triggersRef }}>
50
50
  <div className={cx("ml-tabs", className)} data-variant={variant} data-orientation={orientation}>
51
51
  {children}
52
52
  </div>
@@ -79,13 +79,14 @@ export interface TabsTriggerProps {
79
79
  export function TabsTrigger({ value, disabled = false, children, className }: TabsTriggerProps) {
80
80
  const context = useTabs();
81
81
  const active = context.value === value;
82
+ const { triggersRef } = context;
82
83
  const register = React.useCallback((node: HTMLButtonElement | null) => {
83
- if (node) context.triggers.current.set(value, node);
84
- else context.triggers.current.delete(value);
85
- }, [context.triggers, value]);
84
+ if (node) triggersRef.current.set(value, node);
85
+ else triggersRef.current.delete(value);
86
+ }, [triggersRef, value]);
86
87
 
87
88
  const ordered = () =>
88
- Array.from(context.triggers.current.entries()).sort((left, right) =>
89
+ Array.from(triggersRef.current.entries()).sort((left, right) =>
89
90
  left[1].compareDocumentPosition(right[1]) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
90
91
  );
91
92
 
@@ -3,10 +3,10 @@
3
3
  import * as React from "react";
4
4
  import { IconX } from "@mlola-ui/icons";
5
5
  import { ComboboxList, filterOptions, sideFor, type ComboboxOption } from "../combobox/combobox";
6
- import { Field, fieldDescription } from "../input/input";
6
+ import { Field, fieldDescription, type FormControlProps } from "../input/input";
7
7
  import { composeRefs, cx } from "../_internal/react";
8
8
 
9
- export interface TagInputProps {
9
+ export interface TagInputProps extends FormControlProps {
10
10
  value?: string[];
11
11
  defaultValue?: string[];
12
12
  onValueChange?: (tags: string[]) => void;
@@ -23,9 +23,6 @@ export interface TagInputProps {
23
23
  /** Shape each tag before it is added, such as trimming or lowercasing. */
24
24
  transform?: (tag: string) => string;
25
25
  max?: number;
26
- disabled?: boolean;
27
- required?: boolean;
28
- id?: string;
29
26
  className?: string;
30
27
  }
31
28
 
@@ -59,6 +56,11 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
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
  }: TagInputProps, ref) {
64
66
  const autoId = React.useId();
@@ -176,7 +178,7 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
176
178
  const message = problem ?? error;
177
179
 
178
180
  return (
179
- <Field id={fieldId} label={label} hint={hint} error={message ?? undefined} required={required} disabled={disabled} className={cx("ml-tag-input", className)}>
181
+ <Field id={fieldId} label={label} hint={hint} error={message ?? undefined} required={required} disabled={disabled} name={name} form={form} value={tags} className={cx("ml-tag-input", className)}>
180
182
  <div
181
183
  ref={control}
182
184
  className="ml-tag-input-control"
@@ -213,7 +215,9 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
213
215
  aria-autocomplete={suggestions ? "list" : undefined}
214
216
  aria-activedescendant={open && shown.length ? `${listId}-${active}` : undefined}
215
217
  aria-invalid={message ? true : undefined}
216
- aria-describedby={fieldDescription(fieldId, { hint, error: message })}
218
+ aria-label={ariaLabel}
219
+ aria-labelledby={labelledBy}
220
+ aria-describedby={fieldDescription(fieldId, { hint, error: message, describedBy })}
217
221
  value={text}
218
222
  placeholder={full ? "" : placeholder}
219
223
  disabled={disabled || full}
@@ -3,11 +3,12 @@
3
3
  import * as React from "react";
4
4
  import { composeRefs, cx } from "../_internal/react";
5
5
  import { Field, fieldDescription } from "../input/input";
6
+ import { supportsCss } from "../_internal/dom";
6
7
 
7
8
  export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
8
- label?: string;
9
- hint?: string;
10
- error?: string;
9
+ label?: React.ReactNode;
10
+ hint?: React.ReactNode;
11
+ error?: React.ReactNode;
11
12
  /** Grow with the text up to this many rows, then scroll. */
12
13
  maxRows?: number;
13
14
  /** Show a live character count against `maxLength`. */
@@ -29,7 +30,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
29
30
 
30
31
  const resize = React.useCallback(() => {
31
32
  const element = node.current;
32
- if (!element || CSS.supports?.("field-sizing", "content")) return;
33
+ if (!element || supportsCss("field-sizing", "content")) return;
33
34
  element.style.height = "auto";
34
35
  element.style.height = `${element.scrollHeight}px`;
35
36
  }, []);
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { IconClock } from "@mlola-ui/icons";
5
- import { Field, fieldDescription } from "../input/input";
5
+ import { Field, fieldDescription, type FormControlProps } from "../input/input";
6
6
  import { Popover } from "../popover/popover";
7
7
  import { composeRefs, cx } from "../_internal/react";
8
8
  import { fromMinutes, isTimeAllowed, nearestAllowed, nextAllowed, toMinutes, type TimeMatchers, type TimeRules } from "./times";
@@ -10,7 +10,7 @@ import { fromMinutes, isTimeAllowed, nearestAllowed, nextAllowed, toMinutes, typ
10
10
  export type { TimeMatcher, TimeMatchers } from "./times";
11
11
  export { isTimeAllowed, matchesTime } from "./times";
12
12
 
13
- export interface TimePickerProps {
13
+ export interface TimePickerProps extends FormControlProps {
14
14
  label?: React.ReactNode;
15
15
  hint?: React.ReactNode;
16
16
  error?: React.ReactNode;
@@ -35,9 +35,6 @@ export interface TimePickerProps {
35
35
  /** When given, only these times can be picked, such as bookable slots; same forms as `disabledTimes`. */
36
36
  enabledTimes?: TimeMatchers;
37
37
  locale?: string;
38
- disabled?: boolean;
39
- required?: boolean;
40
- id?: string;
41
38
  className?: string;
42
39
  }
43
40
 
@@ -69,6 +66,11 @@ export const TimePicker = React.forwardRef<HTMLSpanElement, TimePickerProps>(fun
69
66
  disabled,
70
67
  required,
71
68
  id,
69
+ name,
70
+ form,
71
+ "aria-label": ariaLabel,
72
+ "aria-labelledby": labelledBy,
73
+ "aria-describedby": describedBy,
72
74
  className,
73
75
  }: TimePickerProps, ref) {
74
76
  const autoId = React.useId();
@@ -222,14 +224,15 @@ export const TimePicker = React.forwardRef<HTMLSpanElement, TimePickerProps>(fun
222
224
  });
223
225
 
224
226
  return (
225
- <Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} className={cx("ml-time-picker", className)}>
227
+ <Field id={fieldId} label={label} hint={hint} error={error} required={required} disabled={disabled} name={name} form={form} value={current} className={cx("ml-time-picker", className)}>
226
228
  <div
227
229
  className="ml-input ml-time-picker-control"
228
230
  role="group"
229
231
  id={fieldId}
230
- aria-label={typeof label === "string" ? label : "Time"}
231
- aria-describedby={fieldDescription(fieldId, { hint, error })}
232
- aria-invalid={error || unavailable ? true : undefined}
232
+ aria-labelledby={labelledBy ?? (label ? `${fieldId}-label` : undefined)}
233
+ aria-label={labelledBy || label ? undefined : (ariaLabel ?? "Time")}
234
+ aria-describedby={fieldDescription(fieldId, { hint, error, describedBy })}
235
+ data-invalid={error || unavailable ? "" : undefined}
233
236
  data-disabled={disabled || undefined}
234
237
  data-unavailable={unavailable || undefined}
235
238
  onBlur={(event) => {
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cx } from "../_internal/react";
4
+ import { fitTooltip, type Side } from "../_internal/anchor";
5
+ import { cx, useLatest } from "../_internal/react";
5
6
 
6
7
  export type TooltipPlacement = "top" | "bottom" | "left" | "right";
7
8
  interface TooltipProps {
@@ -14,6 +15,10 @@ interface TooltipProps {
14
15
 
15
16
  function Tooltip({ content, placement = "top", delay = 200, children, className }: TooltipProps) {
16
17
  const [open, setOpen] = React.useState(false);
18
+ // Where it actually opens: the asked side, flipped or slid to stay on screen.
19
+ const [fit, setFit] = React.useState<{ side: Side; shift: number }>({ side: placement, shift: 0 });
20
+ const root = React.useRef<HTMLSpanElement>(null);
21
+ const tip = React.useRef<HTMLSpanElement>(null);
17
22
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
18
23
  const id = React.useId();
19
24
  const clear = () => {
@@ -28,15 +33,27 @@ function Tooltip({ content, placement = "top", delay = 200, children, className
28
33
  clear();
29
34
  setOpen(false);
30
35
  };
36
+ const hideRef = useLatest(hide);
31
37
  React.useEffect(() => clear, []);
38
+ React.useLayoutEffect(() => {
39
+ const anchor = root.current?.firstElementChild ?? root.current;
40
+ if (!open || !anchor || !tip.current) {
41
+ setFit({ side: placement, shift: 0 });
42
+ return;
43
+ }
44
+ const a = anchor.getBoundingClientRect();
45
+ // Layout size, not the painted box: the pop-in animation starts scaled down.
46
+ const t = { width: tip.current.offsetWidth, height: tip.current.offsetHeight };
47
+ setFit(fitTooltip({ x: a.left, y: a.top, width: a.width, height: a.height }, t, { width: document.documentElement.clientWidth, height: window.innerHeight }, placement));
48
+ }, [open, placement]);
32
49
  React.useEffect(() => {
33
50
  if (!open) return;
34
51
  const onKeyDown = (event: KeyboardEvent) => {
35
- if (event.key === "Escape") hide();
52
+ if (event.key === "Escape") hideRef.current();
36
53
  };
37
54
  document.addEventListener("keydown", onKeyDown);
38
55
  return () => document.removeEventListener("keydown", onKeyDown);
39
- }, [open]);
56
+ }, [open, hideRef]);
40
57
  type Handlers = {
41
58
  onMouseEnter?: React.MouseEventHandler;
42
59
  onMouseLeave?: React.MouseEventHandler;
@@ -55,10 +72,18 @@ function Tooltip({ content, placement = "top", delay = 200, children, className
55
72
  : childProps["aria-describedby"],
56
73
  } as Record<string, unknown>);
57
74
  return (
58
- <span className="ml-tooltip-root" onMouseEnter={clear} onMouseLeave={hide}>
75
+ <span ref={root} className="ml-tooltip-root" onMouseEnter={clear} onMouseLeave={hide}>
59
76
  {trigger}
60
77
  {open ? (
61
- <span id={id} role="tooltip" data-side={placement} data-state="open" className={cx("ml-tooltip", className)}>
78
+ <span
79
+ ref={tip}
80
+ id={id}
81
+ role="tooltip"
82
+ data-side={fit.side}
83
+ data-state="open"
84
+ className={cx("ml-tooltip", className)}
85
+ style={{ "--ml-tooltip-shift": `${fit.shift}px` } as React.CSSProperties}
86
+ >
62
87
  {content}
63
88
  <span aria-hidden="true" className="ml-tooltip-arrow" />
64
89
  </span>
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { IconX } from "@mlola-ui/icons";
6
+ import { prefersReducedMotion } from "../_internal/media";
6
7
  import { cx } from "../_internal/react";
7
8
  import { usePortalNode, type Side } from "../_internal/floating";
8
9
  import { placeFloating } from "../_internal/anchor";
@@ -78,18 +79,15 @@ export function Tour({ steps, open, onOpenChange, step: stepProp, defaultStep =
78
79
  React.useEffect(() => {
79
80
  if (!open) return;
80
81
  const element = resolve(current?.target);
81
- if (!element) {
82
- setRect(null);
83
- return;
84
- }
85
- const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
86
- element.scrollIntoView({ block: "center", inline: "nearest", behavior: reduce ? "auto" : "smooth" });
87
82
  let frame = 0;
83
+ // A step without an element to point at shows its card in the middle.
88
84
  const measure = () => {
89
85
  cancelAnimationFrame(frame);
90
- frame = requestAnimationFrame(() => setRect(element.getBoundingClientRect()));
86
+ frame = requestAnimationFrame(() => setRect(element ? element.getBoundingClientRect() : null));
91
87
  };
92
88
  measure();
89
+ if (!element) return () => cancelAnimationFrame(frame);
90
+ element.scrollIntoView({ block: "center", inline: "nearest", behavior: prefersReducedMotion() ? "auto" : "smooth" });
93
91
  window.addEventListener("scroll", measure, true);
94
92
  window.addEventListener("resize", measure);
95
93
  const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
@@ -116,7 +114,7 @@ export function Tour({ steps, open, onOpenChange, step: stepProp, defaultStep =
116
114
  if (!open || !portal || !current) return null;
117
115
 
118
116
  return createPortal(
119
- <div className={cx("ml-tour", className)}>
117
+ <div data-ml-portal="" className={cx("ml-tour", className)}>
120
118
  <div className="ml-tour-scrim" data-spotlight={rect ? "" : undefined} aria-hidden="true" />
121
119
  {rect ? (
122
120
  <div