@recursica/mui-adapter 0.28.0 → 0.30.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 (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.d.ts +105 -49
  3. package/dist/mui-adapter.cjs +62 -62
  4. package/dist/mui-adapter.cjs.map +1 -1
  5. package/dist/mui-adapter.css +1 -1
  6. package/dist/mui-adapter.js +6693 -6674
  7. package/dist/mui-adapter.js.map +1 -1
  8. package/package.json +3 -3
  9. package/src/components/Accordion/Accordion.tsx +6 -1
  10. package/src/components/Checkbox/CheckboxGroup.tsx +5 -3
  11. package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +3 -3
  12. package/src/components/Chip/Chip.module.css +5 -5
  13. package/src/components/Chip/Chip.stories.tsx +2 -2
  14. package/src/components/Chip/Chip.tsx +20 -19
  15. package/src/components/Chip/USAGE.md +4 -4
  16. package/src/components/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +5 -3
  17. package/src/components/FileInput/FileInput.stories.tsx +3 -0
  18. package/src/components/FileInput/FileInput.tsx +11 -10
  19. package/src/components/FileInput/USAGE.md +1 -0
  20. package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +6 -6
  21. package/src/components/FileUpload/FileUpload.tsx +9 -9
  22. package/src/components/Link/Link.module.css +4 -0
  23. package/src/components/Radio/RadioGroup.stories.tsx +4 -4
  24. package/src/components/Radio/RadioGroup.tsx +1 -12
  25. package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +8 -0
  26. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +17 -28
  27. package/src/components/SegmentedControl/SegmentedControl.tsx +15 -17
  28. package/src/components/SegmentedControl/USAGE.md +13 -0
  29. package/src/components/Switch/SwitchGroup.tsx +5 -1
  30. package/src/components/Table/TABLE_IMPLEMENTATION_NOTES.md +64 -0
  31. package/src/components/Table/Table.module.css +443 -2
  32. package/src/components/Table/Table.stories.tsx +105 -0
  33. package/src/components/Table/Table.tsx +62 -5
  34. package/src/components/Table/USAGE.md +55 -16
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mui-adapter"
15
15
  },
16
- "version": "0.28.0",
16
+ "version": "0.30.0",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -102,8 +102,8 @@
102
102
  "vitest": "^3.2.4"
103
103
  },
104
104
  "dependencies": {
105
- "@recursica/adapter-common": "*",
106
- "@recursica/official-release": "*",
105
+ "@recursica/adapter-common": "^0.21.0",
106
+ "@recursica/official-release": "^2.8.0",
107
107
  "dayjs": "^1.11.21"
108
108
  },
109
109
  "peerDependencies": {
@@ -43,7 +43,12 @@ const AccordionContext = createContext<{
43
43
 
44
44
  // ==== ACCORDION CONTAINER ====
45
45
  export type AccordionProps = RecursicaOverStyled<
46
- React.HTMLAttributes<HTMLDivElement> & RecursicaAccordionProps
46
+ React.HTMLAttributes<HTMLDivElement> &
47
+ RecursicaAccordionProps & {
48
+ // MUI has no native multi-panel accordion group to match (its own `Accordion` is a
49
+ // single controlled item) — this signature is Recursica's own, same as TransferList.
50
+ onChange?: (value: string | string[] | null) => void;
51
+ }
47
52
  >;
48
53
 
49
54
  const AccordionBase = forwardRef<HTMLDivElement, AccordionProps>(
@@ -36,7 +36,11 @@ export interface RecursicaCheckboxGroupProps
36
36
  | "defaultValue"
37
37
  >,
38
38
  ReadOnlyControlProps,
39
- BaseRecursicaCheckboxGroupProps {}
39
+ BaseRecursicaCheckboxGroupProps {
40
+ // MUI has no native checkbox-group concept to match (its own `FormGroup` is layout-only,
41
+ // no value/onChange) — this signature is Recursica's own, same as TransferList/Accordion.
42
+ onChange?: (value: unknown[]) => void;
43
+ }
40
44
 
41
45
  export type CheckboxGroupProps =
42
46
  RecursicaOverStyled<RecursicaCheckboxGroupProps>;
@@ -72,7 +76,6 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
72
76
  value,
73
77
  defaultValue,
74
78
  onChange,
75
- row,
76
79
  ...rest
77
80
  } = props;
78
81
  // NOTE: this component's props surface (HTMLAttributes<HTMLDivElement>-based) has no native
@@ -125,7 +128,6 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
125
128
  >
126
129
  <MuiFormGroup
127
130
  ref={ref}
128
- row={row}
129
131
  {...(sanitizedProps as any)}
130
132
  className={`${styles.groupRoot} ${(sanitizedProps as any).className || ""}`.trim()}
131
133
  data-layout={formLayout}
@@ -44,7 +44,7 @@ a child span directly (which is exactly what happens once you `.focus()` the rem
44
44
  imperatively, as `FileUpload`'s roving-tabindex group does below). A plain `<span>` also gets no
45
45
  native Enter/Space-triggers-click behavior the way a real `<button>` would. Fixed by adding
46
46
  `role="button"`, `aria-label={removeLabel}`, and an explicit `onKeyDown` for `Enter`/`Space` that
47
- calls `onRemove` directly — matching the mantine-adapter `Chip`'s remove icon, which already did all
47
+ calls `onDelete` directly — matching the mantine-adapter `Chip`'s remove icon, which already did all
48
48
  three.
49
49
 
50
50
  ### Roving tabindex support for chip groups (Matt Massey, 2026-08-17)
@@ -83,11 +83,11 @@ string size variants (`sm`/`md`/`lg`), so `size` is omitted from `RecursicaChipP
83
83
 
84
84
  ### A non-interactive chip still looked clickable, and had a phantom Tab stop (Matt Massey, 2026-08-18)
85
85
 
86
- A `Chip` with no `onRemove`/`onClick`/`onChange` (e.g. `FileUpload`'s `readOnly` file list) still
86
+ A `Chip` with no `onDelete`/`onClick`/`onChange` (e.g. `FileUpload`'s `readOnly` file list) still
87
87
  showed a pointer cursor on hover — `.root.root` hardcoded `cursor: pointer` unconditionally, with
88
88
  no notion of whether the chip actually did anything. Added an `isInteractive` check (mirroring the
89
89
  one added to mantine-adapter's `Chip`, see its `CHIP_IMPLEMENTATION_NOTES.md`) based on
90
- `onRemove`/`onClick`/`onChange` — this adapter's `Chip` has no `checked`-driven native-input case to
90
+ `onDelete`/`onClick`/`onChange` — this adapter's `Chip` has no `checked`-driven native-input case to
91
91
  misread the way Mantine's did, since MUI's `Chip` has no real underlying form control. A new
92
92
  `data-interactive` attribute (set from `isInteractive`) gates `cursor: pointer` in CSS; without it,
93
93
  the chip falls back to whatever MUI's own non-clickable `Chip` renders as (no cursor override, no
@@ -63,7 +63,7 @@
63
63
  transition: all 0.2s ease;
64
64
  }
65
65
 
66
- /* Only a chip with a real handler (onRemove/onClick/onChange — see Chip.tsx's `isInteractive`)
66
+ /* Only a chip with a real handler (onDelete/onClick/onChange — see Chip.tsx's `isInteractive`)
67
67
  gets the pointer cursor. A display-only chip (e.g. a read-only FileUpload file list) has
68
68
  nothing for a click to do, so it shouldn't look clickable. */
69
69
  .root.root[data-interactive] {
@@ -235,11 +235,11 @@
235
235
 
236
236
  /* Doubled selector for specificity parity with MUI's own compound
237
237
  `.MuiChip-root .MuiChip-deleteIcon` rule (same reasoning as .leadingIcon). */
238
- .removeIconWrapper.removeIconWrapper {
238
+ .deleteIconWrapper.deleteIconWrapper {
239
239
  display: inline-flex;
240
240
  align-items: center;
241
241
  justify-content: center;
242
- flex-shrink: 0; /* HARDCODE: keep the remove icon visible when the label truncates instead */
242
+ flex-shrink: 0; /* HARDCODE: keep the delete icon visible when the label truncates instead */
243
243
  color: var(--chip-close);
244
244
  width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
245
245
  height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
@@ -250,7 +250,7 @@
250
250
  margin: 0;
251
251
  }
252
252
 
253
- .removeIconWrapper.removeIconWrapper:focus-visible {
253
+ .deleteIconWrapper.deleteIconWrapper:focus-visible {
254
254
  box-shadow:
255
255
  0 0 0 var(--recursica_brand_states_focus_border-size)
256
256
  var(--recursica_brand_states_focus_color),
@@ -259,7 +259,7 @@
259
259
  var(--recursica_brand_states_focus_color);
260
260
  }
261
261
 
262
- .removeIconWrapper svg {
262
+ .deleteIconWrapper svg {
263
263
  width: 100%;
264
264
  height: 100%;
265
265
  }
@@ -12,7 +12,7 @@ const meta: Meta<ChipStoryProps> = {
12
12
  docs: {
13
13
  description: {
14
14
  component:
15
- "The Chip component is used to represent interactive selections, descriptive tags, or dynamic filters natively bounded to Recursica variables. It can be used as a toggleable input, can render a custom leading `icon`, and handles close constraints automatically via the `onRemove` property.",
15
+ "The Chip component is used to represent interactive selections, descriptive tags, or dynamic filters natively bounded to Recursica variables. It can be used as a toggleable input, can render a custom leading `icon`, and handles close constraints automatically via the `onDelete` property.",
16
16
  },
17
17
  },
18
18
  },
@@ -86,7 +86,7 @@ export const Removable: Story = {
86
86
  args: {
87
87
  children: "Dismissible",
88
88
  checked: false,
89
- onRemove: () => console.log("Removal Action Triggered"),
89
+ onDelete: () => console.log("Removal Action Triggered"),
90
90
  },
91
91
  render: (args: ChipStoryProps) => <Chip {...args} />,
92
92
  };
@@ -62,10 +62,10 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
62
62
  {
63
63
  error = false,
64
64
  icon,
65
- onRemove,
66
- removeLabel = "Remove",
67
- removeTabIndex,
68
- removeIconRef,
65
+ onDelete,
66
+ deleteLabel = "Delete",
67
+ deleteTabIndex,
68
+ deleteIconRef,
69
69
  children,
70
70
  checked,
71
71
  overStyled = false,
@@ -83,7 +83,7 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
83
83
  root: styles.root,
84
84
  label: styles.label,
85
85
  icon: styles.leadingIcon,
86
- deleteIcon: styles.removeIconWrapper,
86
+ deleteIcon: styles.deleteIconWrapper,
87
87
  },
88
88
  restRecord.classes as Partial<Record<string, string>> | undefined,
89
89
  );
@@ -96,13 +96,13 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
96
96
  // Determine state
97
97
  const dataError = error ? "" : undefined;
98
98
  const dataChecked = checked ? "" : undefined;
99
- const isIconOnly = !children && (!!icon || !!onRemove);
99
+ const isIconOnly = !children && (!!icon || !!onDelete);
100
100
  // A chip only counts as interactive when something actually responds to it — merely passing a
101
101
  // `checked` value (e.g. to pin a display-only chip to a fixed visual state, as FileUpload's
102
102
  // read-only file list does) isn't itself an interaction, since clicking it with no onChange/
103
103
  // onClick wired does nothing observable.
104
104
  const isInteractive =
105
- onRemove !== undefined ||
105
+ onDelete !== undefined ||
106
106
  restRecord.onClick !== undefined ||
107
107
  restRecord.onChange !== undefined;
108
108
 
@@ -127,25 +127,26 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
127
127
  </span>
128
128
  ) : undefined
129
129
  }
130
- onDelete={onRemove}
130
+ onDelete={onDelete}
131
131
  deleteIcon={
132
- onRemove ? (
132
+ onDelete ? (
133
133
  <span
134
- ref={removeIconRef}
134
+ ref={deleteIconRef}
135
135
  role="button"
136
- className={styles.removeIconWrapper}
137
- aria-label={removeLabel}
138
- tabIndex={removeTabIndex ?? 0}
136
+ className={styles.deleteIconWrapper}
137
+ aria-label={deleteLabel}
138
+ tabIndex={deleteTabIndex ?? 0}
139
139
  onKeyDown={(e) => {
140
- // MUI's own `onDelete` wiring only reacts to Backspace/Delete, and only when this
141
- // span itself is both the event's target and currentTarget — neither holds once a
142
- // parent (e.g. FileUpload's roving-tabindex group) moves real focus onto this span
143
- // directly. A plain `<span>` also gets no native Enter/Space-triggers-click behavior
144
- // the way a real `<button>` would, so it's handled explicitly here instead.
140
+ // MUI's native `onDelete` wiring (distinct from our own `onDelete` prop above) only
141
+ // reacts to Backspace/Delete, and only when this span itself is both the event's
142
+ // target and currentTarget — neither holds once a parent (e.g. FileUpload's
143
+ // roving-tabindex group) moves real focus onto this span directly. A plain `<span>`
144
+ // also gets no native Enter/Space-triggers-click behavior the way a real `<button>`
145
+ // would, so it's handled explicitly here instead.
145
146
  if (e.key === "Enter" || e.key === " ") {
146
147
  e.preventDefault();
147
148
  e.stopPropagation();
148
- onRemove(
149
+ onDelete(
149
150
  e as unknown as React.MouseEvent<HTMLSpanElement, MouseEvent>,
150
151
  );
151
152
  }
@@ -45,11 +45,11 @@ Chip does not support a `size` prop; chips render at a fixed size.
45
45
 
46
46
  ### Building a keyboard-navigable chip group
47
47
 
48
- `removeTabIndex` and `removeIconRef` are optional escape hatches for composing a _group_ of chips
48
+ `deleteTabIndex` and `deleteIconRef` are optional escape hatches for composing a _group_ of chips
49
49
  with roving-tabindex keyboard navigation (Tab reaches one chip at a time, arrow keys move between
50
50
  them) — see `FileUpload`'s file list for a working example. Ignore both for a standalone chip; they
51
- default to a normal `tabIndex={0}` remove icon with no ref.
51
+ default to a normal `tabIndex={0}` delete icon with no ref.
52
52
 
53
53
  Also pass a plain `tabIndex={-1}` on the `<Chip>` itself in a group like this — MUI's `Chip` root
54
- becomes a focusable element on its own whenever `onRemove` is set, which would otherwise be a
55
- second, unwanted tab stop ahead of the remove icon (see `CHIP_IMPLEMENTATION_NOTES.md`).
54
+ becomes a focusable element on its own whenever `onDelete` is set, which would otherwise be a
55
+ second, unwanted tab stop ahead of the delete icon (see `CHIP_IMPLEMENTATION_NOTES.md`).
@@ -20,7 +20,7 @@ The control is one clickable, focusable `<div role="button">` containing:
20
20
  single- and multiple-file mode render identically here; only the selection/replace behavior
21
21
  differs (see "`multiple` defaults to `false`" below).
22
22
  3. **A trailing "clear" `Button`** — shown whenever a file is selected; clears the entire current
23
- selection (see "Trailing clear button" below).
23
+ selection, icon overridable via `clearIcon` (see "Trailing clear button" below).
24
24
  4. **A hidden `<input type="file">`**, triggered programmatically — same approach as
25
25
  `FileUpload`'s dropzone, not the "invisible overlay input" pattern some styled file inputs use
26
26
  (that pattern would need every interactive child — chip remove icons, the clear icon — to sit
@@ -107,7 +107,9 @@ color/disabled states, so `file-input`'s own `properties_colors_trailing-icon` t
107
107
  entirely. Clicking/activating it calls `onFileRemove` once per currently-selected file (reusing
108
108
  the same callback `FileUpload`'s individual chip removal uses, rather than introducing a separate
109
109
  `onClear` prop) — in single-file mode that's one call; in multiple-file mode it clears everything
110
- at once, distinct from a chip's own individual remove icon.
110
+ at once, distinct from a chip's own individual remove icon. The icon itself defaults to the
111
+ built-in X glyph and is overridable via `clearIcon` — the same pattern as the leading `icon`,
112
+ giving Forge's `leading-icon`/`trailing-icon` token pair a matching pair of override props.
111
113
 
112
114
  ## Why each chip is wrapped with its own `stopPropagation`
113
115
 
@@ -140,7 +142,7 @@ already made for `.value`'s ellipsis truncation.
140
142
 
141
143
  `readOnly` renders the same content (placeholder/filename/chip row) but the root loses its
142
144
  `role="button"` interactivity (no `tabIndex`, no click/keyboard/drag handlers), chips render with
143
- no `removeLabel`/`onRemove` (so `Chip` itself renders no remove icon, same as `FileUpload`'s
145
+ no `removeLabel`/`onDelete` (so `Chip` itself renders no remove icon, same as `FileUpload`'s
144
146
  read-only chips), and the trailing clear icon is omitted entirely. `disabled` keeps the control
145
147
  structurally the same but inert (`tabIndex={-1}`, `aria-disabled`, no handlers, native `<input>`
146
148
  disabled) — both are computed together as a single `interactive` flag used throughout, but remain
@@ -63,6 +63,9 @@ The \`FileInput\` component is a single-line, \`TextField\`-shaped control for c
63
63
  icon: {
64
64
  table: { disable: true },
65
65
  },
66
+ clearIcon: {
67
+ table: { disable: true },
68
+ },
66
69
  files: {
67
70
  table: { disable: true },
68
71
  },
@@ -124,6 +124,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
124
124
  ? `Maximum of ${maxFiles} files allowed`
125
125
  : "Only one file is allowed",
126
126
  icon,
127
+ clearIcon,
127
128
  placeholder = "Select a file...",
128
129
  browseLabel = "Choose file",
129
130
  removeFileLabel = "Remove",
@@ -234,10 +235,10 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
234
235
  };
235
236
 
236
237
  // Roving tabindex across the file chip list (single- or multiple-file mode): only the
237
- // "active" chip's remove icon is a tab stop, and Left/Right/Up/Down move it — same pattern
238
+ // "active" chip's delete icon is a tab stop, and Left/Right/Up/Down move it — same pattern
238
239
  // as FileUpload, see FILEINPUT_IMPLEMENTATION_NOTES.md.
239
240
  const [activeChipIndex, setActiveChipIndex] = useState(0);
240
- const removeIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
241
+ const deleteIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
241
242
  const prevFileCountRef = useRef(files?.length ?? 0);
242
243
 
243
244
  useEffect(() => {
@@ -245,7 +246,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
245
246
  if (count > 0 && count < prevFileCountRef.current) {
246
247
  const nextIndex = Math.min(activeChipIndex, count - 1);
247
248
  setActiveChipIndex(nextIndex);
248
- removeIconRefs.current[nextIndex]?.focus();
249
+ deleteIconRefs.current[nextIndex]?.focus();
249
250
  }
250
251
  prevFileCountRef.current = count;
251
252
  // Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
@@ -267,7 +268,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
267
268
  event.preventDefault();
268
269
  event.stopPropagation();
269
270
  setActiveChipIndex(nextIndex);
270
- removeIconRefs.current[nextIndex]?.focus();
271
+ deleteIconRefs.current[nextIndex]?.focus();
271
272
  };
272
273
 
273
274
  // The built-in `accept`/cap-mismatch message is only shown when the integrator hasn't
@@ -351,14 +352,14 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
351
352
  >
352
353
  <Chip
353
354
  tabIndex={-1}
354
- removeLabel={readOnly ? undefined : removeFileLabel}
355
- removeTabIndex={
355
+ deleteLabel={readOnly ? undefined : removeFileLabel}
356
+ deleteTabIndex={
356
357
  !readOnly && index === activeChipIndex ? 0 : -1
357
358
  }
358
- removeIconRef={(el) => {
359
- removeIconRefs.current[index] = el;
359
+ deleteIconRef={(el) => {
360
+ deleteIconRefs.current[index] = el;
360
361
  }}
361
- onRemove={
362
+ onDelete={
362
363
  readOnly || disabled
363
364
  ? undefined
364
365
  : () => onFileRemove?.(itemId)
@@ -378,7 +379,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
378
379
  overStyled
379
380
  variant="text"
380
381
  size="small"
381
- icon={<ClearIcon />}
382
+ icon={clearIcon ?? <ClearIcon />}
382
383
  aria-label={clearLabel}
383
384
  className={styles.trailingIcon}
384
385
  disabled={disabled}
@@ -57,6 +57,7 @@ rather than appending — picking a new file in single-file mode always replaces
57
57
  | `invalidFileTypeMessage` | `React.ReactNode` | Error message shown when a file is rejected for not matching `accept`. Defaults to `"File type not accepted"`. An explicit `error` prop always takes priority over this. |
58
58
  | `maxFilesMessage` | `React.ReactNode` | Error message shown when a file is rejected for exceeding the cap. Defaults to `"Maximum of {maxFiles} files allowed"` when `multiple`, or `"Only one file is allowed"` otherwise. An explicit `error` prop always wins. |
59
59
  | `icon` | `React.ReactNode` | Leading icon shown inside the control. Defaults to the built-in upload icon. |
60
+ | `clearIcon` | `React.ReactNode` | Trailing clear-all icon shown once a file is selected. Defaults to the built-in X icon. |
60
61
  | `placeholder` | `React.ReactNode` | Text shown when no file is selected. Defaults to `"Select a file..."`. |
61
62
  | `browseLabel` | `string` | Screen-reader label for the control itself (it's the sole interactive/focusable surface, there being no separate "Browse" button). Defaults to `"Choose file"`. |
62
63
  | `removeFileLabel` | `string` | Screen-reader label for a file chip's remove button. Defaults to `"Remove"`. |
@@ -10,7 +10,7 @@ core or as a separate package). The component composes:
10
10
  (`onDragOver`/`onDrop`), containing an upload icon, instructional text, and a hidden
11
11
  `<input type="file">` triggered by...
12
12
  2. **The browse button** — this adapter's own `<Button variant="outline" size="small">`.
13
- 3. **The file list** — this adapter's own `<Chip>` component (with `onRemove`), one per entry in
13
+ 3. **The file list** — this adapter's own `<Chip>` component (with `onDelete`), one per entry in
14
14
  the controlled `files` prop.
15
15
 
16
16
  This shape is **not configurable** — there is no prop to render a bare native file input without
@@ -37,7 +37,7 @@ change (the runtime already accepted arbitrary `children`). (Matt Massey, 2026-0
37
37
  (spacing between/around file entries) but **no color/border tokens of its own for the file
38
38
  entries themselves** — the visual design intentionally delegates that to the existing `Chip`
39
39
  component's own token namespace (`--recursica_ui-kit_components_chip_...`). Reusing `<Chip
40
- onRemove={...}>` directly (rather than reimplementing a similar-looking element) keeps that
40
+ onDelete={...}>` directly (rather than reimplementing a similar-looking element) keeps that
41
41
  separation intact per the canonical guide's "Component Specificity" rule — `FileUpload.module.css`
42
42
  never reaches into `chip`'s namespace, and `Chip.module.css` never reaches into `file-upload`'s.
43
43
 
@@ -105,9 +105,9 @@ list's read-only form is the same chip list, just without the ability to remove
105
105
  `readOnly` is handled directly in `FileUpload.tsx` rather than reusing `WithReadOnlyWrapper`:
106
106
 
107
107
  - The dropzone (icon, instructional text, Browse button, hidden `<input>`) is omitted entirely.
108
- - Each `Chip` is rendered with no `onRemove` (and no `removeTabIndex`/`removeIconRef`/roving
108
+ - Each `Chip` is rendered with no `onDelete` (and no `removeTabIndex`/`removeIconRef`/roving
109
109
  keyboard handlers, which only exist to manage the remove icon) — `Chip` itself already renders no
110
- `deleteIcon` at all when `onRemove` is `undefined`, so this falls out for free rather than needing
110
+ `deleteIcon` at all when `onDelete` is `undefined`, so this falls out for free rather than needing
111
111
  a separate `readOnly` prop on `Chip`. (It also means MUI's own `ButtonBase`-on-`onDelete` quirk —
112
112
  see the keyboard-navigation section below — never triggers in read-only mode either.)
113
113
  - `disabled` is independent of `readOnly` and has no effect when `readOnly` is set (there's no
@@ -225,7 +225,7 @@ triggers both.
225
225
  ## Read-only chips were still interactive (Matt Massey, 2026-08-18)
226
226
 
227
227
  The `readOnly` file list (added 2026-08-18, see USAGE.md §7) rendered each filename as a
228
- `<Chip tabIndex={-1}>` with no `onRemove`, expecting that to be fully inert. It wasn't, because of
228
+ `<Chip tabIndex={-1}>` with no `onDelete`, expecting that to be fully inert. It wasn't, because of
229
229
  a bug shared with the Mantine adapter that lives entirely in `Chip`/`Chip.module.css`, not
230
230
  `FileUpload` — see the Mantine adapter's `FILEUPLOAD_IMPLEMENTATION_NOTES.md` for the parallel
231
231
  write-up. The MUI-specific pieces:
@@ -233,7 +233,7 @@ write-up. The MUI-specific pieces:
233
233
  - **The chip's cursor still showed `pointer` on hover with no real interaction wired.** `.root.root`
234
234
  hardcoded `cursor: pointer` unconditionally — unlike Mantine, this wasn't inherited from MUI's own
235
235
  base styles, just a pre-existing hardcode here that never accounted for a non-interactive chip.
236
- Added an `isInteractive` check to `Chip.tsx` (`onRemove`/`onClick`/`onChange` — MUI's `Chip` has
236
+ Added an `isInteractive` check to `Chip.tsx` (`onDelete`/`onClick`/`onChange` — MUI's `Chip` has
237
237
  no `checked`-driven native-input case to misread, unlike Mantine's) and a `data-interactive`
238
238
  attribute that gates `cursor: pointer` in CSS; a chip with none of those handlers now falls back
239
239
  to whatever MUI's own un-clickable `Chip` renders as (no cursor override, no `ButtonBase`).
@@ -193,11 +193,11 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
193
193
  event.target.value = "";
194
194
  };
195
195
 
196
- // Roving tabindex across the file chip list: only the "active" chip's remove icon is a tab
196
+ // Roving tabindex across the file chip list: only the "active" chip's delete icon is a tab
197
197
  // stop (Tab lands on the first chip), and Left/Right/Up/Down move it — see
198
198
  // FILEUPLOAD_IMPLEMENTATION_NOTES.md.
199
199
  const [activeChipIndex, setActiveChipIndex] = useState(0);
200
- const removeIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
200
+ const deleteIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
201
201
  const prevFileCountRef = useRef(files?.length ?? 0);
202
202
 
203
203
  useEffect(() => {
@@ -207,7 +207,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
207
207
  if (count > 0 && count < prevFileCountRef.current) {
208
208
  const nextIndex = Math.min(activeChipIndex, count - 1);
209
209
  setActiveChipIndex(nextIndex);
210
- removeIconRefs.current[nextIndex]?.focus();
210
+ deleteIconRefs.current[nextIndex]?.focus();
211
211
  }
212
212
  prevFileCountRef.current = count;
213
213
  // Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
@@ -228,7 +228,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
228
228
  if (nextIndex === undefined) return;
229
229
  event.preventDefault();
230
230
  setActiveChipIndex(nextIndex);
231
- removeIconRefs.current[nextIndex]?.focus();
231
+ deleteIconRefs.current[nextIndex]?.focus();
232
232
  };
233
233
 
234
234
  // The built-in `accept`-mismatch message is only shown when the integrator hasn't supplied
@@ -328,12 +328,12 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
328
328
  <Chip
329
329
  key={itemId}
330
330
  tabIndex={-1}
331
- removeLabel={removeFileLabel}
332
- removeTabIndex={index === activeChipIndex ? 0 : -1}
333
- removeIconRef={(el) => {
334
- removeIconRefs.current[index] = el;
331
+ deleteLabel={removeFileLabel}
332
+ deleteTabIndex={index === activeChipIndex ? 0 : -1}
333
+ deleteIconRef={(el) => {
334
+ deleteIconRefs.current[index] = el;
335
335
  }}
336
- onRemove={
336
+ onDelete={
337
337
  disabled ? undefined : () => onFileRemove?.(itemId)
338
338
  }
339
339
  >
@@ -51,6 +51,10 @@ HARDCODED VALUES:
51
51
  );
52
52
  }
53
53
 
54
+ .root:hover {
55
+ text-decoration: var(--recursica_brand_states_link_decoration);
56
+ }
57
+
54
58
  /* Icon Support */
55
59
  .iconWrapper {
56
60
  /* HARDCODE: maintain intrinsic icon bounds */
@@ -44,7 +44,7 @@ export const Default: Story = {
44
44
  render: function StoryRender({ withLayer, layer, ...args }: any) {
45
45
  const [value, setValue] = useState<string>("");
46
46
  return (
47
- <RadioGroup {...args} value={value} onChange={setValue}>
47
+ <RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
48
48
  <Radio value="1" label="Option 1" />
49
49
  <Radio value="2" label="Option 2" />
50
50
  </RadioGroup>
@@ -63,7 +63,7 @@ export const StackedLayout: Story = {
63
63
  render: function StoryRender({ withLayer, layer, ...args }: any) {
64
64
  const [value, setValue] = useState<string>("aws");
65
65
  return (
66
- <RadioGroup {...args} value={value} onChange={setValue}>
66
+ <RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
67
67
  <Radio value="aws" label="Amazon Web Services" />
68
68
  <Radio
69
69
  value="gcp"
@@ -87,7 +87,7 @@ export const SideBySideLayout: Story = {
87
87
  render: function StoryRender({ withLayer, layer, ...args }: any) {
88
88
  const [value, setValue] = useState<string>("us-east");
89
89
  return (
90
- <RadioGroup {...args} value={value} onChange={setValue}>
90
+ <RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
91
91
  <Radio value="us-east" label="US East (N. Virginia)" />
92
92
  <Radio value="us-west" label="US West (Oregon)" />
93
93
  <Radio value="eu-central" label="EU Central (Frankfurt)" />
@@ -108,7 +108,7 @@ export const ReadOnly: Story = {
108
108
  render: function StoryRender({ withLayer, layer, ...args }: any) {
109
109
  const [value, setValue] = useState<string>("react");
110
110
  return (
111
- <RadioGroup {...args} value={value} onChange={setValue}>
111
+ <RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
112
112
  <Radio value="react" label="React" />
113
113
  <Radio value="vue" label="Vue" />
114
114
  </RadioGroup>
@@ -18,13 +18,7 @@ import { type RecursicaRadioGroupProps as BaseRecursicaRadioGroupProps } from "@
18
18
  export interface RecursicaRadioGroupProps
19
19
  extends Omit<
20
20
  MuiRadioGroupProps,
21
- | "size"
22
- | "labelProps"
23
- | "classes"
24
- | "ref"
25
- | "onChange"
26
- | "value"
27
- | "defaultValue"
21
+ "size" | "labelProps" | "classes" | "ref" | "value" | "defaultValue"
28
22
  >,
29
23
  Omit<
30
24
  RecursicaFormControlWrapperProps,
@@ -70,7 +64,6 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
70
64
  emptyValueComponent,
71
65
  value,
72
66
  defaultValue,
73
- onChange,
74
67
  ...rest
75
68
  } = props;
76
69
  // NOTE: MuiRadioGroupProps (via FormGroupProps) has no native "size" (or other
@@ -111,10 +104,6 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
111
104
  {...(sanitizedProps as unknown as MuiRadioGroupProps)}
112
105
  value={value}
113
106
  defaultValue={defaultValue}
114
- // MUI natively calls onChange(event, value); the Recursica contract is
115
- // single-argument (value only), matching Mantine's native RadioGroup
116
- // onChange (the cross-adapter source of truth) — normalize here.
117
- onChange={onChange ? (_event, val) => onChange(val) : undefined}
118
107
  className={styles.groupRoot}
119
108
  data-layout={formLayout}
120
109
  >
@@ -4,3 +4,11 @@
4
4
 
5
5
  - **Root cause:** `--recursica_ui-kit_components_segmented-control-item_variants_selection-states_{unselected,selected}_properties_text_text-transform` resolves to `--recursica_tokens_font_cases_original`, which has no definition in `recursica_variables_scoped.css` (only `_lowercase`/`_titlecase`/`_uppercase` are defined there). The resulting `var()` on `.label` is invalid, and since `text-transform` is an inherited property, the invalid value falls back to the inherited value from `.control` (`.MuiToggleButton-root`) — which carries MUI's own `text-transform: uppercase` button default. Mantine's control has no such native uppercase default, so the same broken token never surfaced there.
6
6
  - **Fix:** Reset `text-transform: none` on `.root .control` alongside the other MUI ToggleButton baseline resets (padding/border/etc.) already there, so nothing uppercase is left to inherit. Matches the existing `text-transform: none` MUI-baseline reset pattern in `Button.module.css`. Not a design-token value — it's a structural reset of MUI's own default, same category as the other hardcoded resets already exempted at the top of this file.
7
+
8
+ ## Per-item `icon`
9
+
10
+ `RecursicaSegmentedControlProps.data` objects accept an optional `icon`, rendered ahead of `label` inside the existing `.label` div (already a flex container with the icon-size/gap tokens wired), so no CSS changes were needed.
11
+
12
+ ## Whole-control `disabled`
13
+
14
+ `RecursicaSegmentedControlProps.disabled` was previously typed `never` and stripped at runtime. It's now a real `boolean?`, passed straight through to MUI's native `ToggleButtonGroup.disabled`, which cascades to every child `ToggleButton` via `ToggleButtonGroupContext`. Per-item `data[].disabled` now passes `undefined` (not `false`) when unset, so it doesn't mask that context cascade — an item's own explicit `disabled` still overrides the group.
@@ -21,7 +21,9 @@ const meta: Meta<typeof SegmentedControl> = {
21
21
  fullWidth: {
22
22
  control: "boolean",
23
23
  },
24
- disabled: { table: { disable: true } },
24
+ disabled: {
25
+ control: "boolean",
26
+ },
25
27
  data: { table: { disable: true } },
26
28
  defaultChecked: { table: { disable: true } },
27
29
  },
@@ -64,6 +66,17 @@ export const Vertical: Story = {
64
66
  },
65
67
  };
66
68
 
69
+ export const Disabled: Story = {
70
+ args: {
71
+ data: ["Preview", "Code", "Edit"],
72
+ disabled: true,
73
+ },
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
75
+ render: ({ withLayer, layer, ...args }: any) => {
76
+ return <SegmentedControl {...args} />;
77
+ },
78
+ };
79
+
67
80
  const CheckIcon = () => (
68
81
  <svg
69
82
  viewBox="0 0 24 24"
@@ -81,33 +94,9 @@ const CheckIcon = () => (
81
94
  export const WithIcons: Story = {
82
95
  args: {
83
96
  data: [
84
- {
85
- value: "daily",
86
- label: (
87
- <>
88
- <CheckIcon />
89
- <span>Daily</span>
90
- </>
91
- ),
92
- },
93
- {
94
- value: "weekly",
95
- label: (
96
- <>
97
- <CheckIcon />
98
- <span>Weekly</span>
99
- </>
100
- ),
101
- },
102
- {
103
- value: "monthly",
104
- label: (
105
- <>
106
- <CheckIcon />
107
- <span>Monthly</span>
108
- </>
109
- ),
110
- },
97
+ { value: "daily", label: "Daily", icon: <CheckIcon /> },
98
+ { value: "weekly", label: "Weekly", icon: <CheckIcon /> },
99
+ { value: "monthly", label: "Monthly", icon: <CheckIcon /> },
111
100
  ],
112
101
  },
113
102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars