@instruments/materia-ui 0.1.0 → 0.2.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 (30) hide show
  1. package/agent-docs.md +4 -3
  2. package/dist/components/button/button-variants.js +53 -8
  3. package/dist/components/button-group/button-group-root.js +8 -6
  4. package/dist/components/calendar/calendar-root.js +39 -13
  5. package/dist/components/chart/chart-tooltip.js +7 -5
  6. package/dist/components/empty/empty-media.js +5 -5
  7. package/dist/components/input-group/input-group-textarea.d.ts +3 -2
  8. package/dist/components/input-group/input-group-textarea.js +8 -4
  9. package/dist/components/pill/pill-variants.js +21 -10
  10. package/dist/components/progress/progress-root.d.ts +7 -5
  11. package/dist/components/progress/progress-root.js +26 -5
  12. package/dist/components/radio-group/radio-group-root.d.ts +7 -3
  13. package/dist/components/radio-group/radio-group-root.js +14 -4
  14. package/dist/components/textarea/textarea-root.d.ts +1 -1
  15. package/dist/components/textarea/textarea-root.js +6 -9
  16. package/dist/components/textarea/use-textarea-rows.d.ts +22 -0
  17. package/dist/components/textarea/use-textarea-rows.js +43 -0
  18. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.d.ts +8 -6
  19. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.js +11 -20
  20. package/dist/compositions/product-card/product-card-color-count.js +26 -26
  21. package/dist/compositions/product-card/product-card-compact-arm.js +2 -2
  22. package/dist/compositions/product-card/product-card-content-types.d.ts +5 -0
  23. package/dist/compositions/product-card/product-card-full-arm.js +2 -2
  24. package/dist/compositions/product-card/product-card-provider-types.d.ts +10 -0
  25. package/dist/compositions/product-card/product-card-provider.js +1 -1
  26. package/dist/compositions/product-card/product-card-swatch-selector.js +4 -3
  27. package/dist/data/docs-manifest.js +24 -24
  28. package/dist/styles/index.css +17 -6
  29. package/lint/tokens.mjs +1 -0
  30. package/package.json +1 -1
package/agent-docs.md CHANGED
@@ -1259,10 +1259,11 @@ The component renders only the field stack - no card, heading, or CTA chrome. Co
1259
1259
 
1260
1260
  `@instruments/materia-ui/components/progress`
1261
1261
 
1262
- Linear progress bar showing completion from 0 to 100; pass `value={null}` for an indeterminate (empty) track. Built on Base UI Progress for accessible semantics.
1262
+ Linear progress bar showing completion from 0 to 100. Pass `value={null}` when the amount of work is unknown: a short bar slides across the track on a loop, and under reduced motion it holds still in the middle. Built on Base UI Progress for accessible semantics.
1263
1263
 
1264
- - Use `Progress` for determinate 0–100 completion (`value={null}` for an indeterminate track); position in a stepped flow is `ProgressDots`.
1264
+ - Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
1265
1265
  - For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
1266
+ - Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
1266
1267
 
1267
1268
  ## ProgressDots
1268
1269
 
@@ -1945,7 +1946,7 @@ The typography primitive for body copy - renders a `<p>` (or any element via `as
1945
1946
  Renders a textarea element for multi-line text input (Base UI `Field.Control` rendering a `<textarea>`). Inside a Field it is the field's control: the FieldLabel targets it, FieldDescription/FieldError describe it, and it inherits the field's invalid state.
1946
1947
 
1947
1948
  - Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
1948
- - It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.
1949
+ - It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
1949
1950
  - 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.
1950
1951
 
1951
1952
  ## ThinkingLoader
@@ -114,18 +114,37 @@ const NEUTRAL_SOFT = "border-border bg-secondary text-secondary-foreground";
114
114
  const NEUTRAL_SOFT_GRADIENT = "before:bg-sheen-bright hover:before:bg-sheen-hover active:before:bg-none disabled:before:bg-none data-disabled:before:bg-none data-[hovered=true]:before:bg-sheen-hover data-[pressed=true]:before:bg-none dark:before:bg-sheen-dark";
115
115
  const NEUTRAL_OUTLINE = "border-border bg-background text-foreground hover:bg-muted hover:text-accent-foreground data-[hovered=true]:bg-muted data-[hovered=true]:text-accent-foreground";
116
116
  const NEUTRAL_GHOST = "border-transparent bg-transparent text-foreground shadow-none hover:bg-muted hover:text-accent-foreground data-[hovered=true]:bg-muted data-[hovered=true]:text-accent-foreground";
117
- const NEUTRAL_LINK = "border-transparent bg-transparent text-foreground underline-offset-4 shadow-none hover:underline data-[hovered=true]:underline";
118
- const NEUTRAL_LINK_FLAT = "shadow-none before:hidden active:scale-100 active:underline-offset-5 data-[pressed=true]:scale-100 data-[pressed=true]:underline-offset-5";
117
+ /* ── Link, shared by every tone: no fill at rest or on hover, so each tone
118
+ * only picks a text colour that reads on the page. ─────────────────── */
119
+ const LINK = "border-transparent bg-transparent underline-offset-4 shadow-none hover:bg-transparent hover:underline data-[hovered=true]:bg-transparent data-[hovered=true]:underline";
120
+ const LINK_FLAT = "shadow-none before:hidden active:scale-100 active:underline-offset-5 data-[pressed=true]:scale-100 data-[pressed=true]:underline-offset-5";
121
+ const NEUTRAL_LINK = `${LINK} text-foreground`;
119
122
  const NEUTRAL_TRANSPARENT = "transform-gpu border-transparent bg-primary/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-primary/60 data-[hovered=true]:bg-primary/60";
120
123
  /* ── Danger tone (was `variant="destructive"`) modifiers ────────────── */
124
+ // Built like neutral soft. The hover pins the fill, since the danger tone
125
+ // darkens its solid fill on hover.
126
+ const DANGER_SOFT = "border-error-border bg-error-soft text-error-foreground hover:bg-error-soft data-[hovered=true]:bg-error-soft";
121
127
  const DANGER_OUTLINE = "border-destructive text-destructive hover:bg-destructive/5 data-[hovered=true]:bg-destructive/5";
122
128
  const DANGER_GHOST = "text-destructive hover:bg-destructive/10 hover:text-destructive data-[hovered=true]:bg-destructive/10 data-[hovered=true]:text-destructive";
123
- const DANGER_TRANSPARENT = "transform-gpu border-transparent bg-destructive/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-destructive/60 data-[hovered=true]:bg-destructive/60";
129
+ const DANGER_LINK = `${LINK} text-destructive`;
130
+ // Half the danger fill over a light page is a pale red, so light mode takes
131
+ // dark text; over graphite it is mid-toned, so dark mode takes white, as
132
+ // Pill's status tones do.
133
+ const DANGER_TRANSPARENT = "transform-gpu border-transparent bg-destructive/50 text-foreground backdrop-blur-md will-change-[filter,background-color] hover:bg-destructive/60 data-[hovered=true]:bg-destructive/60 dark:text-white/90";
124
134
  /* ── Brand tone (was `variant="brand"`) modifiers ───────────────────── */
125
135
  const BRAND_SOLID_GRADIENT = "shadow-button-sheen before:bg-sheen-strong hover:before:bg-sheen-hover active:shadow-none active:before:bg-none disabled:shadow-none disabled:before:bg-none data-disabled:shadow-none data-disabled:before:bg-none data-[hovered=true]:before:bg-sheen-hover data-[pressed=true]:shadow-none data-[pressed=true]:before:bg-none";
126
- const BRAND_OUTLINE = "border-brand text-brand hover:bg-brand/5 data-[hovered=true]:bg-brand/5";
127
- const BRAND_GHOST = "text-brand hover:bg-brand/10 hover:text-brand data-[hovered=true]:bg-brand/10 data-[hovered=true]:text-brand";
128
- const BRAND_TRANSPARENT = "transform-gpu border-transparent bg-brand/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-brand/60 data-[hovered=true]:bg-brand/60";
136
+ // Built like neutral soft, with Pill's brand soft colours. Dark brand soft is
137
+ // graphite, so the text there takes the brand accent.
138
+ const BRAND_SOFT = "bg-brand-soft text-brand-foreground dark:text-brand-accent";
139
+ // Without a fill, brand yellow is unreadable on the page, so the text takes
140
+ // `brand-strong`; the outline border can stay yellow.
141
+ const BRAND_OUTLINE = "border-brand text-brand-strong hover:bg-brand/5 data-[hovered=true]:bg-brand/5";
142
+ const BRAND_GHOST = "text-brand-strong hover:bg-brand/10 hover:text-brand-strong data-[hovered=true]:bg-brand/10 data-[hovered=true]:text-brand-strong";
143
+ const BRAND_LINK = `${LINK} text-brand-strong`;
144
+ // Over graphite, half the brand fill turns olive and the brand foreground
145
+ // stops reading, so dark mode thickens the fill rather than swapping the text,
146
+ // which keeps the brand and its foreground overridable as a pair.
147
+ const BRAND_TRANSPARENT = "transform-gpu border-transparent bg-brand/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-brand/60 data-[hovered=true]:bg-brand/60 dark:bg-brand/70 dark:hover:bg-brand/80 dark:data-[hovered=true]:bg-brand/80";
129
148
  /**
130
149
  * buttonVariants - CVA helper for Button styling.
131
150
  *
@@ -191,7 +210,7 @@ const buttonVariants = cva([
191
210
  },
192
211
  {
193
212
  asInput: false,
194
- class: `${NEUTRAL_LINK} ${SOLID_SURFACE} ${NEUTRAL_LINK_FLAT}`,
213
+ class: `${NEUTRAL_LINK} ${SOLID_SURFACE} ${LINK_FLAT}`,
195
214
  emphasis: "link",
196
215
  tone: "neutral",
197
216
  },
@@ -208,6 +227,12 @@ const buttonVariants = cva([
208
227
  emphasis: "solid",
209
228
  tone: "danger",
210
229
  },
230
+ {
231
+ asInput: false,
232
+ class: `${DANGER_SOFT} ${SOLID_SURFACE} ${SOLID_SHADOW} ${NEUTRAL_SOFT_GRADIENT}`,
233
+ emphasis: "soft",
234
+ tone: "danger",
235
+ },
211
236
  {
212
237
  asInput: false,
213
238
  class: `${SOLID_SHADOW} ${DANGER_OUTLINE}`,
@@ -226,6 +251,12 @@ const buttonVariants = cva([
226
251
  emphasis: "transparent",
227
252
  tone: "danger",
228
253
  },
254
+ {
255
+ asInput: false,
256
+ class: `${DANGER_LINK} ${LINK_FLAT}`,
257
+ emphasis: "link",
258
+ tone: "danger",
259
+ },
229
260
  /* ── Brand tone (base colours from variants.tone) ─────── */
230
261
  {
231
262
  asInput: false,
@@ -233,6 +264,12 @@ const buttonVariants = cva([
233
264
  emphasis: "solid",
234
265
  tone: "brand",
235
266
  },
267
+ {
268
+ asInput: false,
269
+ class: `${BRAND_SOFT} ${SOLID_SURFACE} ${SOLID_SHADOW} ${NEUTRAL_SOFT_GRADIENT}`,
270
+ emphasis: "soft",
271
+ tone: "brand",
272
+ },
236
273
  {
237
274
  asInput: false,
238
275
  class: `${SOLID_SHADOW} ${BRAND_OUTLINE}`,
@@ -251,6 +288,12 @@ const buttonVariants = cva([
251
288
  emphasis: "transparent",
252
289
  tone: "brand",
253
290
  },
291
+ {
292
+ asInput: false,
293
+ class: `${BRAND_LINK} ${LINK_FLAT}`,
294
+ emphasis: "link",
295
+ tone: "brand",
296
+ },
254
297
  // Input chips carry the solid drop-shadow (was `variant="default"`).
255
298
  // asInput replaces every tone/emphasis surface (gated `asInput: false`).
256
299
  { asInput: true, class: SOLID_SHADOW },
@@ -281,10 +324,12 @@ const buttonVariants = cva([
281
324
  // brand|danger>, emphasis is the full Emphasis plus Button's `link`
282
325
  // extension. The `satisfies Record<…>` fails to compile if these keys
283
326
  // drift from ButtonTone/ButtonEmphasis (declared below).
327
+ // Danger fades only to /95 on hover: a lighter red under the light
328
+ // foreground and the sheen drops below 4.5:1 at /90.
284
329
  tone: {
285
330
  neutral: "",
286
331
  brand: "border-brand-accent/40 bg-brand text-brand-foreground focus-visible:ring-brand/25 data-[focused=true]:ring-brand/25",
287
- danger: "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90 data-[hovered=true]:bg-destructive/90 focus-visible:ring-red-400/50 data-[focused=true]:ring-red-400/50",
332
+ danger: "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/95 data-[hovered=true]:bg-destructive/95 focus-visible:ring-red-400/50 data-[focused=true]:ring-red-400/50",
288
333
  },
289
334
  emphasis: {
290
335
  solid: "",
@@ -40,16 +40,18 @@ const buttonGroupVariants = cva("flex w-fit items-stretch *:focus-visible:relati
40
40
  dividers: false,
41
41
  variant: "segmented",
42
42
  },
43
- // Remove overlapping borders when dividers are disabled (joined variant behaviour)
43
+ // A joined group keeps each child's own border and drops every trailing
44
+ // edge but the last, so neighbours share one line and an outline group
45
+ // reads as one continuous outline. Dividers recolour that shared edge.
44
46
  {
45
- class: "[&>[data-slot=button]]:border-0",
46
- dividers: false,
47
+ class: "[&>*:not(:last-child)]:border-r-0",
47
48
  orientation: "horizontal",
49
+ variant: "joined",
48
50
  },
49
51
  {
50
- class: "[&>[data-slot=button]]:border-0",
51
- dividers: false,
52
+ class: "[&>*:not(:last-child)]:border-b-0",
52
53
  orientation: "vertical",
54
+ variant: "joined",
53
55
  },
54
56
  // Optional radius overrides (applies to non-separator items)
55
57
  {
@@ -90,7 +92,7 @@ const buttonGroupVariants = cva("flex w-fit items-stretch *:focus-visible:relati
90
92
  variant: {
91
93
  /**
92
94
  * Default "joined" behaviour - relies on each child for borders/background,
93
- * and simply removes overlapping borders + inner radii.
95
+ * and collapses the borders and inner radii where neighbours meet.
94
96
  */
95
97
  joined: "",
96
98
  /**
@@ -2,16 +2,42 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@instruments/materia-ui/utils/cn";
4
4
  import { ChevronLeft, ChevronRight } from "lucide-react";
5
- import { DayButton, DayPicker } from "react-day-picker";
5
+ import { Day, DayButton, DayPicker } from "react-day-picker";
6
6
  import { buttonVariants } from "../button.js";
7
7
  import { Icon } from "../icon.js";
8
8
  const CalendarChevron = ({ orientation }) => (_jsx(Icon, { icon: orientation === "left" ? ChevronLeft : ChevronRight, size: "xs" }));
9
9
  /**
10
- * DayPicker marks selection on the grid cell, not the button inside it, so the
11
- * button carries its own copy to paint the filled day. A range's middle days
12
- * are selected too, but sit on the cell's accent band instead of a fill.
10
+ * The accent band behind a selected range, painted on the grid cell because
11
+ * only the cells meet edge to edge. Middle days fill their cell, rounded where
12
+ * a week starts or ends; the first and last days fill the half that faces the
13
+ * range, so the band runs into their filled button. A range of one day has no
14
+ * band.
13
15
  */
14
- const CalendarDayButton = ({ modifiers, ...props }) => (_jsx(DayButton, { "data-selected": modifiers.selected === true && modifiers.range_middle !== true, modifiers: modifiers, ...props }));
16
+ const rangeBandClasses = (modifiers) => {
17
+ const start = modifiers.range_start === true;
18
+ const end = modifiers.range_end === true;
19
+ if (modifiers.range_middle === true) {
20
+ return "bg-accent text-accent-foreground first:rounded-l-md last:rounded-r-md";
21
+ }
22
+ if (start && !end) {
23
+ return "bg-linear-to-r from-transparent from-50% to-accent to-50% last:rounded-r-md";
24
+ }
25
+ if (end && !start) {
26
+ return "bg-linear-to-l from-transparent from-50% to-accent to-50% first:rounded-l-md";
27
+ }
28
+ return undefined;
29
+ };
30
+ const CalendarDay = ({ className, modifiers, ...props }) => (_jsx(Day, { className: cn(className, rangeBandClasses(modifiers)), modifiers: modifiers, ...props }));
31
+ /**
32
+ * DayPicker marks state on the grid cell, not the button inside it, so the
33
+ * button carries its own copy to paint the day. A range's middle days are
34
+ * selected too, but sit on the cell's band instead of a fill; today is tinted
35
+ * unless it is filled.
36
+ */
37
+ const CalendarDayButton = ({ modifiers, ...props }) => {
38
+ const filled = modifiers.selected === true && modifiers.range_middle !== true;
39
+ return (_jsx(DayButton, { "data-selected": filled, "data-today": modifiers.today === true && !filled, modifiers: modifiers, ...props }));
40
+ };
15
41
  /**
16
42
  * Date picker built on react-day-picker, styled with the shared button variants;
17
43
  * supports `single`, `multiple`, and `range` selection via the `mode` prop.
@@ -26,9 +52,8 @@ const Calendar = ({ className, classNames, showOutsideDays = true, ...props }) =
26
52
  button_next: cn(buttonVariants({ emphasis: "ghost", tone: "neutral" }), "text-muted-foreground hover:bg-accent hover:text-foreground size-8 p-0"),
27
53
  button_previous: cn(buttonVariants({ emphasis: "ghost", tone: "neutral" }), "text-muted-foreground hover:bg-accent hover:text-foreground size-8 p-0"),
28
54
  caption_label: "text-paragraph-sm font-medium",
29
- day: cn("text-paragraph-sm relative w-9 p-0 text-center focus-within:relative focus-within:z-20", props.mode === "range" &&
30
- "[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md [&:has([aria-selected].day-range-end)]:rounded-r-md"),
31
- day_button: cn(buttonVariants({ emphasis: "ghost" }), "data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground size-8 rounded-md p-0 font-normal"),
55
+ day: "text-paragraph-sm relative w-9 p-0 text-center focus-within:relative focus-within:z-20",
56
+ day_button: cn(buttonVariants({ emphasis: "ghost" }), "data-[today=true]:bg-accent data-[today=true]:text-accent-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground size-8 rounded-md p-0 font-normal"),
32
57
  disabled: "text-muted-foreground opacity-50",
33
58
  hidden: "invisible",
34
59
  month: "flex flex-col gap-4",
@@ -36,18 +61,19 @@ const Calendar = ({ className, classNames, showOutsideDays = true, ...props }) =
36
61
  month_grid: "w-full border-collapse space-y-1",
37
62
  months: "relative flex flex-col gap-2 sm:flex-row",
38
63
  nav: "absolute inset-x-0 top-0 z-10 flex h-9 items-center justify-between px-1",
39
- outside: "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
40
- range_end: "day-range-end",
41
- range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
42
- range_start: "day-range-start",
64
+ outside: "text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
65
+ range_end: "",
66
+ range_middle: "",
67
+ range_start: "",
43
68
  selected: "",
44
- today: "bg-accent text-accent-foreground",
69
+ today: "",
45
70
  week: "mt-2 flex w-full",
46
71
  weekday: "w-9 rounded-md text-calendar font-normal text-muted-foreground",
47
72
  weekdays: "flex",
48
73
  ...classNames,
49
74
  }, components: {
50
75
  Chevron: CalendarChevron,
76
+ Day: CalendarDay,
51
77
  DayButton: CalendarDayButton,
52
78
  }, showOutsideDays: showOutsideDays, ...props }));
53
79
  Calendar.displayName = "Calendar";
@@ -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 };
@@ -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
  */