@instruments/materia-ui 0.1.0 → 0.3.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 (55) hide show
  1. package/agent-docs.md +4 -3
  2. package/dist/components/animated-number/number-digit.d.ts +5 -3
  3. package/dist/components/animated-number/number-digit.js +39 -16
  4. package/dist/components/animated-number/number-section.d.ts +7 -5
  5. package/dist/components/animated-number/number-section.js +81 -65
  6. package/dist/components/animated-number/number-symbol.js +2 -0
  7. package/dist/components/animated-number/target-widths.d.ts +7 -0
  8. package/dist/components/animated-number/target-widths.js +13 -1
  9. package/dist/components/button/button-variants.js +53 -8
  10. package/dist/components/button-group/button-group-root.js +8 -6
  11. package/dist/components/calendar/calendar-root.js +39 -13
  12. package/dist/components/chart/chart-tooltip.js +7 -5
  13. package/dist/components/empty/empty-media.js +5 -5
  14. package/dist/components/input-group/input-group-textarea.d.ts +3 -2
  15. package/dist/components/input-group/input-group-textarea.js +8 -4
  16. package/dist/components/pill/pill-variants.js +21 -10
  17. package/dist/components/progress/progress-root.d.ts +7 -5
  18. package/dist/components/progress/progress-root.js +26 -5
  19. package/dist/components/radio-group/radio-group-root.d.ts +7 -3
  20. package/dist/components/radio-group/radio-group-root.js +14 -4
  21. package/dist/components/segmented-control/segmented-control-indicator.js +1 -1
  22. package/dist/components/segmented-control/segmented-control-item.js +1 -1
  23. package/dist/components/segmented-control/segmented-control-root.js +1 -1
  24. package/dist/components/segmented-control/segmented-control-variants.js +1 -1
  25. package/dist/components/stepper/stepper-item.js +6 -2
  26. package/dist/components/stepper/stepper-variants.d.ts +1 -0
  27. package/dist/components/stepper/stepper-variants.js +11 -1
  28. package/dist/components/tab-navigation/tab-navigation-link.js +1 -1
  29. package/dist/components/tabs/tabs-indicator.js +1 -1
  30. package/dist/components/tabs/tabs-trigger.js +1 -1
  31. package/dist/components/tabs/tabs-variants.js +1 -1
  32. package/dist/components/textarea/textarea-root.d.ts +1 -1
  33. package/dist/components/textarea/textarea-root.js +6 -9
  34. package/dist/components/textarea/use-textarea-rows.d.ts +22 -0
  35. package/dist/components/textarea/use-textarea-rows.js +43 -0
  36. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.d.ts +8 -6
  37. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.js +11 -20
  38. package/dist/compositions/product-card/product-card-color-count.js +26 -26
  39. package/dist/compositions/product-card/product-card-compact-arm.js +2 -2
  40. package/dist/compositions/product-card/product-card-content-types.d.ts +5 -0
  41. package/dist/compositions/product-card/product-card-full-arm.js +2 -2
  42. package/dist/compositions/product-card/product-card-provider-types.d.ts +10 -0
  43. package/dist/compositions/product-card/product-card-provider.js +1 -1
  44. package/dist/compositions/product-card/product-card-swatch-selector.js +4 -3
  45. package/dist/data/docs-manifest.d.ts +1 -1
  46. package/dist/data/docs-manifest.js +29 -74
  47. package/dist/styles/index.css +17 -6
  48. package/dist/{components/segmented/segmented-variants.d.ts → tokens/segmented.d.ts} +12 -1
  49. package/dist/{components/segmented/segmented-variants.js → tokens/segmented.js} +11 -0
  50. package/lint/tokens.mjs +1 -0
  51. package/package.json +5 -5
  52. package/dist/components/segmented/segmented-root.d.ts +0 -12
  53. package/dist/components/segmented/segmented-root.js +0 -12
  54. package/dist/components/segmented.d.ts +0 -1
  55. package/dist/components/segmented.js +0 -1
@@ -4,14 +4,13 @@ import { cn } from "@instruments/materia-ui/utils/cn";
4
4
  import { isNumber, isString } from "../../utils/type-guards.js";
5
5
  import { Flex } from "../flex.js";
6
6
  import { Grid } from "../grid.js";
7
- import { getPayloadConfigFromPayload, readDatumStringField, resolveConfigKey, useChart, } from "./chart-context.js";
7
+ import { getPayloadConfigFromPayload, readDatumStringField, resolveConfigKey, resolveEntryKey, useChart, } from "./chart-context.js";
8
8
  /** recharts `Tooltip` - pair it with {@link ChartTooltipContent} via `content`. */
9
9
  export { Tooltip as ChartTooltip } from "recharts";
10
10
  /** One value cell inside the tooltip: swatch (or config icon) + label + value. */
11
- const TooltipRow = ({ item, indicator, hideIndicator, nestLabel, nestedLabel, color, }) => {
11
+ const TooltipRow = ({ item, indicator, hideIndicator, nestLabel, nestedLabel, color, configKey, }) => {
12
12
  const { config } = useChart();
13
- const key = resolveConfigKey(item.name, item.dataKey);
14
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
13
+ const itemConfig = getPayloadConfigFromPayload(config, item, configKey);
15
14
  const swatchColor = color ?? readDatumStringField(item, "fill") ?? item.color;
16
15
  const swatchStyle = {
17
16
  "--color-bg": swatchColor,
@@ -58,5 +57,8 @@ export const ChartTooltipContent = ({ active, payload, className, indicator = "d
58
57
  const items = payload.filter((item) => item.type !== "none");
59
58
  const tooltipLabel = hideLabel ? null : (_jsx(TooltipLabel, { config: config, label: label, labelClassName: labelClassName, labelFormatter: labelFormatter, labelKey: labelKey, payload: payload }));
60
59
  const nestLabel = items.length === 1 && indicator !== "dot";
61
- return (_jsxs(Grid, { className: cn("bg-popover text-popover-foreground text-caption min-w-32 items-start gap-1.5 rounded-xl border px-2.5 py-1.5 shadow-md dark:shadow-none", className), columns: "none", "data-component": "chart-tooltip-content", "data-slot": "chart-tooltip-content", gap: "none", children: [nestLabel ? null : tooltipLabel, _jsx(Grid, { className: "gap-1.5", columns: "none", gap: "none", children: items.map((item) => (_jsx(TooltipRow, { color: color, hideIndicator: hideIndicator, indicator: indicator, item: item, nestedLabel: tooltipLabel, nestLabel: nestLabel }, resolveConfigKey(nameKey, item.name, item.dataKey)))) })] }));
60
+ return (_jsxs(Grid, { className: cn("bg-popover text-popover-foreground text-caption min-w-32 items-start gap-1.5 rounded-xl border px-2.5 py-1.5 shadow-md dark:shadow-none", className), columns: "none", "data-component": "chart-tooltip-content", "data-slot": "chart-tooltip-content", gap: "none", children: [nestLabel ? null : tooltipLabel, _jsx(Grid, { className: "gap-1.5", columns: "none", gap: "none", children: items.map((item) => {
61
+ const key = resolveConfigKey(nameKey, item.name, item.dataKey);
62
+ return (_jsx(TooltipRow, { color: color, configKey: key, hideIndicator: hideIndicator, indicator: indicator, item: item, nestedLabel: tooltipLabel, nestLabel: nestLabel }, resolveEntryKey(item, key)));
63
+ }) })] }));
62
64
  };
@@ -16,17 +16,17 @@ const SIZE_MEDIA_MB = {
16
16
  sm: "mb-1",
17
17
  };
18
18
  /**
19
- * Tone → text colour for the bare (plain) icon. `brand` uses the DS
20
- * brand-on-plain pairing (`text-brand-accent`, per Pill's ghost-brand
21
- * treatment) - raw `text-brand` yellow fails contrast on light surfaces.
19
+ * Tone → text colour for the bare (plain) icon, each at least 3:1 on the page.
20
+ * Brand and warning hues are too light on a light page, so they take
21
+ * `brand-strong` and the warning foreground.
22
22
  */
23
23
  const PLAIN_ICON_TONE = {
24
- brand: "text-brand-accent",
24
+ brand: "text-brand-strong",
25
25
  danger: "text-destructive",
26
26
  info: "text-info-accent",
27
27
  neutral: "text-muted-foreground",
28
28
  success: "text-affirmative-accent",
29
- warning: "text-amber-500",
29
+ warning: "text-warning-foreground",
30
30
  };
31
31
  /** Bare-icon size (one step up from the boxed icon so it reads on its own). */
32
32
  const PLAIN_ICON_SIZE = {
@@ -7,7 +7,8 @@ export type InputGroupTextareaProps = Omit<React.ComponentProps<"textarea">, "si
7
7
  * Textarea element for use inside InputGroup (Base UI `Field.Control`
8
8
  * rendering a `<textarea>`). Inherits disabled state from InputGroup context;
9
9
  * inside a Field it is the field's control, and it carries `aria-invalid`
10
- * when the group has an error.
10
+ * when the group has an error. It grows with its text, and `rows` sets its
11
+ * minimum height in lines.
11
12
  *
12
13
  * @example
13
14
  * ```tsx
@@ -19,4 +20,4 @@ export type InputGroupTextareaProps = Omit<React.ComponentProps<"textarea">, "si
19
20
  * </InputGroup>
20
21
  * ```
21
22
  */
22
- export declare const InputGroupTextarea: ({ className, testId, disabled: disabledProp, id, name, ...props }: InputGroupTextareaProps) => React.JSX.Element;
23
+ export declare const InputGroupTextarea: ({ className, testId, disabled: disabledProp, id, name, rows, style, ...props }: InputGroupTextareaProps) => React.JSX.Element;
@@ -4,11 +4,13 @@ import { Field as FieldPrimitive } from "@base-ui/react/field";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
5
  import { INPUT_CONTROL_RESET } from "../../tokens/input-shell.js";
6
6
  import { useInputGroup } from "../input-group/input-group-context.js";
7
+ import { useTextareaRows } from "../textarea/use-textarea-rows.js";
7
8
  /**
8
9
  * Textarea element for use inside InputGroup (Base UI `Field.Control`
9
10
  * rendering a `<textarea>`). Inherits disabled state from InputGroup context;
10
11
  * inside a Field it is the field's control, and it carries `aria-invalid`
11
- * when the group has an error.
12
+ * when the group has an error. It grows with its text, and `rows` sets its
13
+ * minimum height in lines.
12
14
  *
13
15
  * @example
14
16
  * ```tsx
@@ -20,10 +22,12 @@ import { useInputGroup } from "../input-group/input-group-context.js";
20
22
  * </InputGroup>
21
23
  * ```
22
24
  */
23
- export const InputGroupTextarea = ({ className, testId, disabled: disabledProp, id, name, ...props }) => {
25
+ export const InputGroupTextarea = ({ className, testId, disabled: disabledProp, id, name, rows, style, ...props }) => {
24
26
  const { disabled: contextDisabled, hasError } = useInputGroup();
25
27
  const disabled = disabledProp ?? contextDisabled;
28
+ const { className: sizingClassName, ref: sizingRef, style: sizingStyle, } = useTextareaRows(rows);
26
29
  // The control owns id/name/disabled so Field can label and validate it;
27
- // textarea-only props ride on the rendered element.
28
- return (_jsx(FieldPrimitive.Control, { disabled: disabled, render: _jsx("textarea", { className: cn(INPUT_CONTROL_RESET, "text-paragraph flex field-sizing-content min-h-16 resize-none px-0 py-2", className), "data-component": "input-group-textarea", "data-slot": "input-group-control", ...(hasError ? { "aria-invalid": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
30
+ // textarea-only props ride on the rendered element. The sizing ref sits on
31
+ // the control so a caller's own `ref` on the textarea still lands.
32
+ return (_jsx(FieldPrimitive.Control, { disabled: disabled, ref: sizingRef, render: _jsx("textarea", { className: cn(INPUT_CONTROL_RESET, "text-paragraph flex resize-none px-0 py-2", sizingClassName, className), "data-component": "input-group-textarea", "data-slot": "input-group-control", rows: rows, style: { ...sizingStyle, ...style }, ...(hasError ? { "aria-invalid": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
29
33
  };
@@ -60,15 +60,19 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
60
60
  emphasis: "solid",
61
61
  tone: "success",
62
62
  },
63
+ // The soft token is pale in light and graphite in dark, the reverse of
64
+ // the accent fill, so one class reads on it in both themes.
63
65
  {
64
- className: "text-white/90",
66
+ className: "text-info-soft",
65
67
  emphasis: "solid",
66
68
  tone: "info",
67
69
  },
70
+ // Dark mode gives the warning foreground the accent's own colour, as text
71
+ // on graphite, so the solid takes the graphite soft token instead.
68
72
  {
69
- className: "text-white/90",
73
+ className: "dark:text-warning-soft",
70
74
  emphasis: "solid",
71
- tone: "danger",
75
+ tone: "warning",
72
76
  },
73
77
  /* ── Soft (was `light`) ───────────────────────────── */
74
78
  /* neutral+soft reproduces the old secondary/solid render */
@@ -152,8 +156,10 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
152
156
  emphasis: "ghost",
153
157
  tone: "success",
154
158
  },
159
+ // Amber is too light to read on a light page, so light mode takes the
160
+ // warning foreground.
155
161
  {
156
- className: "text-warning-accent dark:text-warning-accent",
162
+ className: "text-warning-foreground dark:text-warning-accent",
157
163
  emphasis: "ghost",
158
164
  tone: "warning",
159
165
  },
@@ -167,8 +173,9 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
167
173
  emphasis: "ghost",
168
174
  tone: "danger",
169
175
  },
176
+ // Brand accent fails on a light page; graphite keeps the accent.
170
177
  {
171
- className: "text-brand-accent dark:text-brand-accent",
178
+ className: "text-brand-strong dark:text-brand-accent",
172
179
  emphasis: "ghost",
173
180
  tone: "brand",
174
181
  },
@@ -178,28 +185,32 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
178
185
  emphasis: "transparent",
179
186
  tone: "neutral",
180
187
  },
188
+ // A half status fill is pale over a light page and mid-toned over
189
+ // graphite, so status tones take dark text in light and white in dark.
181
190
  {
182
- className: "bg-affirmative-accent/50 transform-gpu border-transparent text-white/90 backdrop-blur-md will-change-[filter,background-color]",
191
+ className: "bg-affirmative-accent/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
183
192
  emphasis: "transparent",
184
193
  tone: "success",
185
194
  },
186
195
  {
187
- className: "bg-warning-accent/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
196
+ className: "bg-warning-accent/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
188
197
  emphasis: "transparent",
189
198
  tone: "warning",
190
199
  },
191
200
  {
192
- className: "bg-info-accent/50 transform-gpu border-transparent text-white/90 backdrop-blur-md will-change-[filter,background-color]",
201
+ className: "bg-info-accent/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
193
202
  emphasis: "transparent",
194
203
  tone: "info",
195
204
  },
196
205
  {
197
- className: "bg-destructive/50 transform-gpu border-transparent text-white/90 backdrop-blur-md will-change-[filter,background-color]",
206
+ className: "bg-destructive/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
198
207
  emphasis: "transparent",
199
208
  tone: "danger",
200
209
  },
210
+ // Over graphite a half brand fill turns olive, so dark mode thickens it
211
+ // and keeps the brand foreground.
201
212
  {
202
- className: "bg-brand/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
213
+ className: "bg-brand/50 dark:bg-brand/70 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
203
214
  emphasis: "transparent",
204
215
  tone: "brand",
205
216
  },
@@ -21,15 +21,17 @@ interface ProgressCircleProps {
21
21
  valueFormatter?: (value: number, max: number) => string;
22
22
  }
23
23
  /**
24
- * Linear progress bar showing completion from 0 to 100; pass `value={null}`
25
- * for an indeterminate (empty) track. Built on Base UI Progress for accessible
26
- * semantics.
24
+ * Linear progress bar showing completion from 0 to 100. Pass `value={null}`
25
+ * when the amount of work is unknown: a short bar slides across the track on a
26
+ * loop, and under reduced motion it holds still in the middle. Built on Base
27
+ * UI Progress for accessible semantics.
27
28
  *
28
- * @bestPractice Use `Progress` for determinate 0–100 completion (`value={null}` for an indeterminate track); position in a stepped flow is `ProgressDots`.
29
+ * @bestPractice Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
29
30
  * @bestPractice For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
31
+ * @bestPractice Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
30
32
  * @category feedback
31
33
  */
32
- declare const Progress: ({ className, ...props }: ProgressProps) => React.JSX.Element;
34
+ declare const Progress: ({ className, value, ...props }: ProgressProps) => React.JSX.Element;
33
35
  /**
34
36
  * Circular progress ring with an optional centred content slot; the value is
35
37
  * clamped to `0..max` and exposed through a visually hidden native `<progress>`.
@@ -2,17 +2,38 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
4
4
  import { Center } from "@instruments/materia-ui/components/center";
5
+ import { durations } from "@instruments/materia-ui/tokens/motion";
5
6
  import { cn } from "@instruments/materia-ui/utils/cn";
7
+ import { useReducedMotion } from "motion/react";
8
+ import * as m from "motion/react-m";
9
+ // The indeterminate bar is a third of the track, so translating it by its own
10
+ // width runs it from just off the left edge (-100%) to just off the right
11
+ // (300%).
12
+ const SLIDE = { x: ["-100%", "300%"] };
13
+ const SLIDE_TRANSITION = {
14
+ duration: durations.slow,
15
+ ease: "linear",
16
+ repeat: Number.POSITIVE_INFINITY,
17
+ };
6
18
  /**
7
- * Linear progress bar showing completion from 0 to 100; pass `value={null}`
8
- * for an indeterminate (empty) track. Built on Base UI Progress for accessible
9
- * semantics.
19
+ * Linear progress bar showing completion from 0 to 100. Pass `value={null}`
20
+ * when the amount of work is unknown: a short bar slides across the track on a
21
+ * loop, and under reduced motion it holds still in the middle. Built on Base
22
+ * UI Progress for accessible semantics.
10
23
  *
11
- * @bestPractice Use `Progress` for determinate 0–100 completion (`value={null}` for an indeterminate track); position in a stepped flow is `ProgressDots`.
24
+ * @bestPractice Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
12
25
  * @bestPractice For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
26
+ * @bestPractice Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
13
27
  * @category feedback
14
28
  */
15
- const Progress = ({ className, ...props }) => (_jsx(ProgressPrimitive.Root, { className: cn("bg-muted relative block h-2 w-full overflow-hidden rounded-full", className), "data-component": "progress", "data-slot": "progress", ...props, children: _jsx(ProgressPrimitive.Track, { className: "block size-full", "data-component": "progress-track", "data-slot": "progress-track", children: _jsx(ProgressPrimitive.Indicator, { className: "bg-primary transition-width block h-full data-indeterminate:hidden", "data-component": "progress-indicator", "data-slot": "progress-indicator" }) }) }));
29
+ const Progress = ({ className, value, ...props }) => {
30
+ // Gate on the preference directly: MotionConfig's reduced mode would jump
31
+ // the slide to its last keyframe, which leaves the bar off the track.
32
+ const reducedMotion = useReducedMotion() === true;
33
+ const indeterminate = value === null;
34
+ const sliding = indeterminate && !reducedMotion;
35
+ return (_jsx(ProgressPrimitive.Root, { className: cn("bg-muted relative block h-2 w-full overflow-hidden rounded-full", className), "data-component": "progress", "data-slot": "progress", value: value, ...props, children: _jsx(ProgressPrimitive.Track, { className: "block size-full", "data-component": "progress-track", "data-slot": "progress-track", children: _jsx(ProgressPrimitive.Indicator, { className: cn("bg-primary transition-width block h-full", indeterminate && "w-1/3 rounded-full", indeterminate && !sliding && "translate-x-full"), "data-component": "progress-indicator", "data-slot": "progress-indicator", render: sliding ? (_jsx(m.div, { animate: SLIDE, transition: SLIDE_TRANSITION })) : (_jsx("div", {})) }) }) }));
36
+ };
16
37
  const clamp = (value, min, max) => {
17
38
  if (Number.isNaN(value)) {
18
39
  return min;
@@ -30,13 +30,17 @@ declare const RadioGroup: ({ className, ...props }: RadioGroupPrimitive.Props) =
30
30
  export type RadioGroupItemProps = RadioProps & {
31
31
  /** Label text for the radio option */
32
32
  children: React.ReactNode;
33
+ /** Optional helper text below the label; wired to the radio via `aria-describedby`. */
34
+ description?: React.ReactNode;
33
35
  };
34
36
  /**
35
- * A radio option with an associated label. Combines Radio and Label for convenience.
37
+ * A radio option with an associated label and optional description. Combines
38
+ * Radio, Label and muted helper text, laid out like `CheckboxField`.
36
39
  *
37
40
  * @param props - The radio group item props
38
41
  * @param props.value - Unique value for this radio option (required).
39
42
  * @param props.children - Label text content.
43
+ * @param props.description - Helper text below the label, linked to the radio via `aria-describedby`.
40
44
  * @param props.size - Size using shared ComponentSize scale. Defaults to "sm".
41
45
  * @param props.disabled - Disable this radio option. Defaults to false.
42
46
  * @param props.className - Additional CSS classes for the radio input.
@@ -46,9 +50,9 @@ export type RadioGroupItemProps = RadioProps & {
46
50
  * ```tsx
47
51
  * <RadioGroup>
48
52
  * <RadioGroupItem value="a">Option A</RadioGroupItem>
49
- * <RadioGroupItem value="b" disabled>Option B</RadioGroupItem>
53
+ * <RadioGroupItem value="b" description="Arrives tomorrow" disabled>Option B</RadioGroupItem>
50
54
  * </RadioGroup>
51
55
  * ```
52
56
  */
53
- declare const RadioGroupItem: ({ children, className, id: providedId, disabled, ...props }: RadioGroupItemProps) => React.JSX.Element;
57
+ declare const RadioGroupItem: ({ children, description, className, id: providedId, disabled, "aria-describedby": ariaDescribedBy, ...props }: RadioGroupItemProps) => React.JSX.Element;
54
58
  export { RadioGroup, RadioGroupItem };
@@ -5,8 +5,10 @@ import { cn } from "@instruments/materia-ui/utils/cn";
5
5
  import { useId } from "react";
6
6
  import { CONTROL_LINE_CLASSES } from "../../tokens/control-line.js";
7
7
  import { Flex } from "../flex.js";
8
+ import { Grid } from "../grid.js";
8
9
  import { Label } from "../label.js";
9
10
  import { Radio } from "../radio.js";
11
+ import { Text } from "../text.js";
10
12
  /**
11
13
  * Container for a group of radio buttons. Manages single selection state.
12
14
  *
@@ -34,11 +36,13 @@ import { Radio } from "../radio.js";
34
36
  */
35
37
  const RadioGroup = ({ className, ...props }) => (_jsx(RadioGroupPrimitive, { className: cn("grid gap-3", className), "data-component": "radio-group", "data-slot": "radio-group", ...props }));
36
38
  /**
37
- * A radio option with an associated label. Combines Radio and Label for convenience.
39
+ * A radio option with an associated label and optional description. Combines
40
+ * Radio, Label and muted helper text, laid out like `CheckboxField`.
38
41
  *
39
42
  * @param props - The radio group item props
40
43
  * @param props.value - Unique value for this radio option (required).
41
44
  * @param props.children - Label text content.
45
+ * @param props.description - Helper text below the label, linked to the radio via `aria-describedby`.
42
46
  * @param props.size - Size using shared ComponentSize scale. Defaults to "sm".
43
47
  * @param props.disabled - Disable this radio option. Defaults to false.
44
48
  * @param props.className - Additional CSS classes for the radio input.
@@ -48,13 +52,19 @@ const RadioGroup = ({ className, ...props }) => (_jsx(RadioGroupPrimitive, { cla
48
52
  * ```tsx
49
53
  * <RadioGroup>
50
54
  * <RadioGroupItem value="a">Option A</RadioGroupItem>
51
- * <RadioGroupItem value="b" disabled>Option B</RadioGroupItem>
55
+ * <RadioGroupItem value="b" description="Arrives tomorrow" disabled>Option B</RadioGroupItem>
52
56
  * </RadioGroup>
53
57
  * ```
54
58
  */
55
- const RadioGroupItem = ({ children, className, id: providedId, disabled, ...props }) => {
59
+ const RadioGroupItem = ({ children, description, className, id: providedId, disabled, "aria-describedby": ariaDescribedBy, ...props }) => {
56
60
  const generatedId = useId();
61
+ const descriptionId = useId();
57
62
  const id = providedId ?? generatedId;
58
- return (_jsxs(Flex, { align: "flex-start", "data-component": "radio-group-item", "data-disabled": disabled, "data-slot": "radio-group-item", gap: "xs", children: [_jsx("span", { className: cn(CONTROL_LINE_CLASSES, "text-paragraph-sm"), children: _jsx(Radio, { className: className, disabled: disabled, id: id, ...props }) }), _jsx(Label, { htmlFor: id, children: children })] }));
63
+ const hasDescription = description !== undefined && description !== null;
64
+ // Keep any ids the caller passed, and add the description's own.
65
+ const describedBy = hasDescription
66
+ ? [ariaDescribedBy, descriptionId].join(" ").trim()
67
+ : ariaDescribedBy;
68
+ return (_jsxs(Flex, { align: "flex-start", "data-component": "radio-group-item", "data-disabled": disabled, "data-slot": "radio-group-item", gap: "sm", children: [_jsx("span", { className: cn(CONTROL_LINE_CLASSES, "text-paragraph-sm"), children: _jsx(Radio, { "aria-describedby": describedBy, className: className, disabled: disabled, id: id, ...props }) }), _jsxs(Grid, { columns: "none", gap: "2xs", children: [_jsx(Label, { className: cn("cursor-pointer", disabled === true ? "cursor-not-allowed opacity-60" : null), htmlFor: id, children: children }), hasDescription ? (_jsx(Text, { id: descriptionId, size: "paragraph-sm", variant: "muted", children: description })) : null] })] }));
59
69
  };
60
70
  export { RadioGroup, RadioGroupItem };
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { springs } from "@instruments/materia-ui/tokens/motion";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
5
  import * as m from "motion/react-m";
6
- import { SEGMENTED_INDICATOR_CLASS } from "../segmented/segmented-root.js";
6
+ import { SEGMENTED_INDICATOR_CLASS } from "../../tokens/segmented.js";
7
7
  import { useSegmentedControlContext } from "./segmented-control-context.js";
8
8
  /**
9
9
  * Animated active-segment indicator. A single instance is mounted under the
@@ -5,9 +5,9 @@ import { Dot } from "@instruments/materia-ui/components/dot";
5
5
  import { Icon } from "@instruments/materia-ui/components/icon";
6
6
  import { cn } from "@instruments/materia-ui/utils/cn";
7
7
  import { getResponsiveSizeOverrides } from "../../tokens/responsive.js";
8
+ import { SEGMENTED_TRIGGER_PADDING } from "../../tokens/segmented.js";
8
9
  import { warnMissingAriaLabel } from "../../utils/warn-missing-aria-label.js";
9
10
  import { Flex } from "../flex.js";
10
- import { SEGMENTED_TRIGGER_PADDING } from "../segmented/segmented-root.js";
11
11
  import { useSegmentedControlContext } from "./segmented-control-context.js";
12
12
  import { SegmentedControlIndicator } from "./segmented-control-indicator.js";
13
13
  import { segmentedControlItemVariants } from "./segmented-control-variants.js";
@@ -6,8 +6,8 @@ import { ScrollFade } from "@instruments/materia-ui/components/scroll-fade";
6
6
  import { cn } from "@instruments/materia-ui/utils/cn";
7
7
  import { useEffect, useId, useMemo, useRef, useState } from "react";
8
8
  import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
9
+ import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, } from "../../tokens/segmented.js";
9
10
  import { Flex } from "../flex.js";
10
- import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, } from "../segmented/segmented-root.js";
11
11
  import { SegmentedControlContext } from "./segmented-control-context.js";
12
12
  import { useSingleSelection } from "./use-single-selection.js";
13
13
  /**
@@ -2,8 +2,8 @@ import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
2
2
  import { cn } from "@instruments/materia-ui/utils/cn";
3
3
  import { cva } from "class-variance-authority";
4
4
  import { DISABLED } from "../../tokens/input-shell.js";
5
+ import { SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
5
6
  import { typedEntries } from "../../utils/typed-entries.js";
6
- import { SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-root.js";
7
7
  /**
8
8
  * Item padding lives in compound variants (mirroring `tabsTriggerVariants`) so a
9
9
  * single size dimension drives text (base `size` variant) and padding
@@ -58,7 +58,11 @@ export const StepperItem = ({ value, label, disabled = false, icon = Check, clas
58
58
  const circleSize = state === "completed"
59
59
  ? CIRCLE_SIZES[baseSize].completed
60
60
  : CIRCLE_SIZES[baseSize].default;
61
- return (_jsxs("div", { className: cn("relative", className), "data-slot": "stepper-item", "data-state": state, ...rest, children: [_jsx("div", { className: cn(stepperCircleContainerVariants({ size: baseSize }), getResponsiveSizeOverrides(size, STEPPER_CIRCLE_CONTAINER_SIZE_CLASSES)), children: _jsx(m.button, { "aria-current": state === "current" ? "step" : undefined, "aria-label": label !== undefined && label !== null && label !== ""
61
+ return (_jsxs("div", { className: cn("group/step relative", className), "data-slot": "stepper-item", "data-state": state, ...rest, children: [_jsx("div", { className: cn(stepperCircleContainerVariants({ size: baseSize }), getResponsiveSizeOverrides(size, STEPPER_CIRCLE_CONTAINER_SIZE_CLASSES)), children: _jsx(m.button, { "aria-current": state === "current" ? "step" : undefined, "aria-label": label !== undefined && label !== null && label !== ""
62
62
  ? `Step: ${label}`
63
- : `Step ${value + 1}`, className: cn(stepperCircleVariants({ size: baseSize, state }), isInteractive && "cursor-pointer", !isInteractive && "cursor-default"), disabled: disabled, layout: true, onClick: handleClick, onKeyDown: handleKeyDown, style: { borderRadius: 9999, height: circleSize, width: circleSize }, transition: springs.standard, type: "button", children: _jsx(m.span, { className: "flex", layout: true, children: _jsx(Icon, { className: stepperIconVariants({ size: baseSize, state }), icon: icon, size: iconSize }) }) }) }), label !== undefined && label !== null && label !== "" && (_jsx("span", { className: cn(stepperLabelVariants({ size: baseSize, state }), getResponsiveSizeOverrides(size, STEPPER_LABEL_SIZE_CLASSES)), children: label }))] }));
63
+ : `Step ${value + 1}`, className: cn(stepperCircleVariants({ size: baseSize, state }), isInteractive && "cursor-pointer", !isInteractive && "cursor-default"), disabled: disabled, layout: true, onClick: handleClick, onKeyDown: handleKeyDown, style: { borderRadius: 9999, height: circleSize, width: circleSize }, transition: springs.standard, type: "button", children: _jsx(m.span, { className: "flex", layout: true, children: _jsx(Icon, { className: stepperIconVariants({ size: baseSize, state }), icon: icon, size: iconSize }) }) }) }), label !== undefined && label !== null && label !== "" && (_jsx("span", { className: cn(stepperLabelVariants({
64
+ edge: value === 0 ? "start" : "center",
65
+ size: baseSize,
66
+ state,
67
+ }), getResponsiveSizeOverrides(size, STEPPER_LABEL_SIZE_CLASSES)), children: label }))] }));
64
68
  };
@@ -36,6 +36,7 @@ export declare const stepperIconVariants: (props?: ({
36
36
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
37
37
  /** Step label variants - absolutely positioned to not affect flex layout */
38
38
  export declare const stepperLabelVariants: (props?: ({
39
+ edge?: "center" | "start" | null | undefined;
39
40
  size?: StepperSize | null | undefined;
40
41
  state?: "completed" | "current" | "disabled" | "upcoming" | null | undefined;
41
42
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
@@ -105,12 +105,22 @@ export const stepperIconVariants = cva("transition-colors", {
105
105
  },
106
106
  });
107
107
  /** Step label variants - absolutely positioned to not affect flex layout */
108
- export const stepperLabelVariants = cva("text-caption absolute left-1/2 -translate-x-1/2 text-center whitespace-nowrap", {
108
+ // Labels centre under their circles, except at the ends: the first starts at
109
+ // its circle's left edge and the last ends at its circle's right edge, so no
110
+ // label hangs outside the stepper's box. The last step is found with CSS
111
+ // (`last-child` of the root, via the item's `group/step`) so it holds on the
112
+ // server render; the first is passed in as `edge`.
113
+ export const stepperLabelVariants = cva("text-caption absolute whitespace-nowrap group-last/step:right-0 group-last/step:left-auto group-last/step:translate-x-0 group-last/step:text-right", {
109
114
  defaultVariants: {
115
+ edge: "center",
110
116
  size: "base",
111
117
  state: "upcoming",
112
118
  },
113
119
  variants: {
120
+ edge: {
121
+ center: "left-1/2 -translate-x-1/2 text-center",
122
+ start: "left-0 text-left",
123
+ },
114
124
  size: STEPPER_LABEL_SIZE_CLASSES,
115
125
  state: {
116
126
  completed: "text-foreground",
@@ -4,9 +4,9 @@ import { mergeProps } from "@base-ui/react/merge-props";
4
4
  import { useRender } from "@base-ui/react/use-render";
5
5
  import { cn } from "@instruments/materia-ui/utils/cn";
6
6
  import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
7
+ import { SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
7
8
  import { Flex } from "../flex.js";
8
9
  import { Icon } from "../icon.js";
9
- import { SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-variants.js";
10
10
  import { useTabsContext } from "../tabs/tabs-context.js";
11
11
  import { TabsIndicator } from "../tabs/tabs-indicator.js";
12
12
  import { DEFAULT_TABS_SIZE, tabsTriggerVariants } from "../tabs/tabs-variants.js";
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { springs } from "@instruments/materia-ui/tokens/motion";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
5
  import * as m from "motion/react-m";
6
- import { SEGMENTED_INDICATOR_CLASS } from "../segmented/segmented-root.js";
6
+ import { SEGMENTED_INDICATOR_CLASS } from "../../tokens/segmented.js";
7
7
  import { useTabsContext } from "../tabs/tabs-context.js";
8
8
  const indicatorStyles = {
9
9
  line: "absolute inset-x-0 bottom-0 h-0.5 bg-primary dark:bg-gray-100",
@@ -3,10 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
5
  import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
6
+ import { SEGMENTED_DOT_SIZE, SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
6
7
  import { Dot } from "../dot.js";
7
8
  import { Flex } from "../flex.js";
8
9
  import { Icon } from "../icon.js";
9
- import { SEGMENTED_DOT_SIZE, SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-variants.js";
10
10
  import { useTabsContext } from "../tabs/tabs-context.js";
11
11
  import { TabsIndicator } from "../tabs/tabs-indicator.js";
12
12
  import { DEFAULT_TABS_SIZE, tabsTriggerVariants } from "../tabs/tabs-variants.js";
@@ -2,8 +2,8 @@ import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
2
2
  import { cn } from "@instruments/materia-ui/utils/cn";
3
3
  import { cva } from "class-variance-authority";
4
4
  import { DISABLED } from "../../tokens/input-shell.js";
5
+ import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
5
6
  import { typedEntries } from "../../utils/typed-entries.js";
6
- import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-root.js";
7
7
  const segmentedShellPaddingVariants = typedEntries(SEGMENTED_SHELL_PADDING).map(([size, className]) => ({
8
8
  className,
9
9
  size,
@@ -24,7 +24,7 @@ interface TextareaProps extends WithTestId<React.ComponentProps<"textarea">> {
24
24
  * ```
25
25
  *
26
26
  * @bestPractice Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
27
- * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.
27
+ * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
28
28
  * @bestPractice Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.
29
29
  * @category forms
30
30
  */
@@ -6,6 +6,7 @@ import { cn } from "@instruments/materia-ui/utils/cn";
6
6
  import { hasErrorInput } from "../../tokens/has-error-input.js";
7
7
  import { INPUT_SHELL } from "../../tokens/input-shell.js";
8
8
  import { useFieldContext } from "../field/field-context.js";
9
+ import { useTextareaRows } from "./use-textarea-rows.js";
9
10
  /**
10
11
  * Renders a textarea element for multi-line text input (Base UI
11
12
  * `Field.Control` rendering a `<textarea>`). Inside a Field it is the field's
@@ -26,21 +27,17 @@ import { useFieldContext } from "../field/field-context.js";
26
27
  * ```
27
28
  *
28
29
  * @bestPractice Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
29
- * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.
30
+ * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
30
31
  * @bestPractice Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.
31
32
  * @category forms
32
33
  */
33
34
  const Textarea = ({ className, testId, hasError, id, name, disabled, rows, style, ...props }) => {
34
35
  const field = useFieldContext();
35
36
  const resolvedError = hasError ?? field?.invalid ?? false;
36
- // `field-sizing-content` sizes the box to its text and ignores `rows`, so
37
- // `rows` sets the starting height instead: that many lines plus the py-2
38
- // padding and the 1px border, before the box grows with its content.
39
- const sizedStyle = rows === undefined
40
- ? style
41
- : { minHeight: `calc(${rows}lh + 1rem + 2px)`, ...style };
37
+ const { className: sizingClassName, ref: sizingRef, style: sizingStyle, } = useTextareaRows(rows);
42
38
  // The control owns id/name/disabled so Field can label and validate it;
43
- // textarea-only props ride on the rendered element.
44
- return (_jsx(FieldPrimitive.Control, { render: _jsx("textarea", { className: cn(focusInput(), `flex field-sizing-content min-h-16 w-full ${INPUT_SHELL} placeholder:text-muted-foreground transition-color-shadow text-paragraph md:text-paragraph-sm px-3 py-2 shadow-xs disabled:cursor-not-allowed disabled:opacity-50`, hasErrorInput, className), "data-component": "textarea", "data-slot": "textarea", rows: rows, style: sizedStyle, ...(hasError === true ? { "aria-invalid": true } : {}), ...(resolvedError ? { "data-error": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(disabled === undefined ? {} : { disabled }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
39
+ // textarea-only props ride on the rendered element. The sizing ref sits on
40
+ // the control so a caller's own `ref` on the textarea still lands.
41
+ return (_jsx(FieldPrimitive.Control, { ref: sizingRef, render: _jsx("textarea", { className: cn(focusInput(), `flex w-full ${INPUT_SHELL} placeholder:text-muted-foreground transition-color-shadow text-paragraph md:text-paragraph-sm px-3 py-2 shadow-xs disabled:cursor-not-allowed disabled:opacity-50`, sizingClassName, hasErrorInput, className), "data-component": "textarea", "data-slot": "textarea", rows: rows, style: { ...sizingStyle, ...style }, ...(hasError === true ? { "aria-invalid": true } : {}), ...(resolvedError ? { "data-error": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(disabled === undefined ? {} : { disabled }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
45
42
  };
46
43
  export { Textarea };
@@ -0,0 +1,22 @@
1
+ import type { CSSProperties, RefCallback } from "react";
2
+ interface TextareaRowsSizing {
3
+ /** Sizing classes: `field-sizing-content`, plus `min-h-16` when there are no `rows`. */
4
+ className: string;
5
+ /** Goes on the textarea, to read back its padding and border once mounted. */
6
+ ref: RefCallback<HTMLTextAreaElement>;
7
+ /** The `rows`-high minimum, once the edges are known. */
8
+ style: CSSProperties | undefined;
9
+ }
10
+ /**
11
+ * Lets a textarea grow with its text (`field-sizing: content`) without
12
+ * losing `rows`, which content sizing ignores in every browser.
13
+ *
14
+ * Until it mounts, a textarea with `rows` is left to the browser, which
15
+ * sizes it from `rows` with whatever padding and border it has. Once
16
+ * mounted, those edges are read back from its computed style, the minimum
17
+ * height becomes `rows` lines plus them, and content sizing takes over to
18
+ * grow it beyond that. The `lh` unit keeps the minimum in step when the line
19
+ * height changes at a breakpoint.
20
+ */
21
+ export declare const useTextareaRows: (rows: number | undefined) => TextareaRowsSizing;
22
+ export {};
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ /**
4
+ * Lets a textarea grow with its text (`field-sizing: content`) without
5
+ * losing `rows`, which content sizing ignores in every browser.
6
+ *
7
+ * Until it mounts, a textarea with `rows` is left to the browser, which
8
+ * sizes it from `rows` with whatever padding and border it has. Once
9
+ * mounted, those edges are read back from its computed style, the minimum
10
+ * height becomes `rows` lines plus them, and content sizing takes over to
11
+ * grow it beyond that. The `lh` unit keeps the minimum in step when the line
12
+ * height changes at a breakpoint.
13
+ */
14
+ export const useTextareaRows = (rows) => {
15
+ const [edges, setEdges] = useState();
16
+ const ref = useCallback((element) => {
17
+ if (element === null || rows === undefined) {
18
+ return;
19
+ }
20
+ const style = getComputedStyle(element);
21
+ setEdges([
22
+ style.paddingTop,
23
+ style.paddingBottom,
24
+ style.borderTopWidth,
25
+ style.borderBottomWidth,
26
+ ].join(" + "));
27
+ }, [rows]);
28
+ if (rows === undefined) {
29
+ return {
30
+ className: "field-sizing-content min-h-16",
31
+ ref,
32
+ style: undefined,
33
+ };
34
+ }
35
+ if (edges === undefined) {
36
+ return { className: "", ref, style: undefined };
37
+ }
38
+ return {
39
+ className: "field-sizing-content",
40
+ ref,
41
+ style: { minHeight: `calc(${rows}lh + ${edges})` },
42
+ };
43
+ };