@recursica/mui-adapter 0.21.1 → 0.23.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 (74) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/CHANGELOG.md +75 -0
  3. package/dist/index.d.ts +247 -41
  4. package/dist/mui-adapter.cjs +78 -78
  5. package/dist/mui-adapter.cjs.map +1 -1
  6. package/dist/mui-adapter.css +1 -1
  7. package/dist/mui-adapter.js +7624 -7311
  8. package/dist/mui-adapter.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +63 -0
  11. package/src/components/Accordion/Accordion.module.css +21 -2
  12. package/src/components/Accordion/Accordion.stories.tsx +38 -0
  13. package/src/components/Accordion/Accordion.tsx +27 -12
  14. package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +59 -0
  15. package/src/components/AssistiveElement/AssistiveElement.module.css +8 -1
  16. package/src/components/AssistiveElement/AssistiveElement.tsx +17 -7
  17. package/src/components/Autocomplete/Autocomplete.tsx +18 -2
  18. package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +36 -0
  19. package/src/components/Avatar/Avatar.tsx +6 -9
  20. package/src/components/Badge/Badge.module.css +0 -12
  21. package/src/components/Badge/Badge.tsx +0 -4
  22. package/src/components/Button/Button.module.css +13 -0
  23. package/src/components/Card/Card.tsx +5 -5
  24. package/src/components/Checkbox/Checkbox.module.css +17 -21
  25. package/src/components/Checkbox/Checkbox.tsx +24 -18
  26. package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +102 -0
  27. package/src/components/Chip/Chip.module.css +46 -11
  28. package/src/components/Chip/Chip.tsx +41 -5
  29. package/src/components/Chip/USAGE.md +19 -0
  30. package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +249 -0
  31. package/src/components/FileUpload/FileUpload.module.css +203 -38
  32. package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
  33. package/src/components/FileUpload/FileUpload.tsx +350 -6
  34. package/src/components/FileUpload/USAGE.md +163 -5
  35. package/src/components/Flex/Flex.tsx +1 -1
  36. package/src/components/FormControlWrapper/FORMCONTROLWRAPPER_IMPLEMENTATION_NOTES.md +35 -0
  37. package/src/components/FormControlWrapper/FormControlWrapper.tsx +24 -8
  38. package/src/components/Grid/Grid.tsx +1 -1
  39. package/src/components/Group/Group.tsx +1 -1
  40. package/src/components/HoverCard/HoverCard.tsx +1 -1
  41. package/src/components/Label/Label.module.css +15 -2
  42. package/src/components/Label/Label.tsx +7 -6
  43. package/src/components/Menu/Menu.tsx +1 -1
  44. package/src/components/Modal/Modal.tsx +1 -1
  45. package/src/components/NumberInput/NumberInput.tsx +1 -1
  46. package/src/components/Pagination/Pagination.tsx +1 -1
  47. package/src/components/Panel/Panel.tsx +1 -1
  48. package/src/components/Radio/Radio.module.css +33 -38
  49. package/src/components/Radio/Radio.tsx +58 -44
  50. package/src/components/Radio/RadioGroup.tsx +5 -0
  51. package/src/components/SegmentedControl/SegmentedControl.module.css +0 -7
  52. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -8
  53. package/src/components/Slider/Slider.tsx +1 -1
  54. package/src/components/Stack/Stack.tsx +1 -1
  55. package/src/components/Stepper/Stepper.tsx +3 -3
  56. package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +123 -0
  57. package/src/components/Switch/Switch.module.css +152 -89
  58. package/src/components/Switch/Switch.tsx +140 -33
  59. package/src/components/Switch/SwitchGroup.tsx +37 -9
  60. package/src/components/Table/Table.tsx +1 -1
  61. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +10 -0
  62. package/src/components/Tabs/Tabs.module.css +120 -43
  63. package/src/components/Tabs/Tabs.stories.tsx +5 -2
  64. package/src/components/Tabs/Tabs.tsx +1 -1
  65. package/src/components/Tabs/USAGE.md +27 -11
  66. package/src/components/TextField/TextField.tsx +1 -1
  67. package/src/components/TimePicker/TimePicker.tsx +1 -1
  68. package/src/components/Timeline/Timeline.tsx +1 -1
  69. package/src/components/Timeline/TimelineItem.tsx +2 -2
  70. package/src/components/Toast/Toast.tsx +4 -4
  71. package/src/components/Tooltip/Tooltip.tsx +1 -1
  72. package/src/components/Tree/Tree.tsx +1 -1
  73. package/src/index.ts +3 -1
  74. package/src/types/mantine.d.ts +0 -7
@@ -86,8 +86,14 @@
86
86
  );
87
87
  }
88
88
 
89
- /* Base explicit reset masking underlying framework defaults */
89
+ /* Base explicit reset masking underlying framework defaults.
90
+ display: flex + centering: the checkmark icon renders as a nested child of this box
91
+ (see Checkbox.tsx — icon/checkedIcon are combined nodes, icon inside .input), so this
92
+ box has to center it itself rather than relying on a parent's centering. */
90
93
  .input {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
91
97
  cursor: pointer; /* HARDCODE: core interaction behavior */
92
98
  appearance: none; /* HARDCODE: reset native widget */
93
99
  box-sizing: border-box; /* HARDCODE: ensures size includes borders */
@@ -199,7 +205,14 @@
199
205
  );
200
206
  }
201
207
 
208
+ /* display: flex (not the browser default block) blockifies the native inline <label>
209
+ child per the CSS Display spec's flex-item blockification rule — matches Mantine's
210
+ own labelWrapper (inline-flex), whose flex context does the same to its label.
211
+ Without it, the label stays inline and its line box picks up extra half-leading
212
+ height, visually shifting it below vertical center against the checkbox. */
202
213
  .labelWrapper {
214
+ display: flex;
215
+ flex-direction: column;
203
216
  padding: 0; /* HARDCODE: structural layout reset */
204
217
  margin: 0; /* HARDCODE: structural layout reset */
205
218
  flex: 1;
@@ -250,26 +263,9 @@
250
263
  );
251
264
  }
252
265
 
253
- .description {
254
- margin-top: 5px; /* HARDCODE: mantine default */
255
- color: #868e96; /* HARDCODE: mantine default dimmed */
256
- font-size: 12px; /* HARDCODE: mantine default xs */
257
- line-height: 1.2;
258
- }
259
-
260
- .error {
261
- margin-top: 5px; /* HARDCODE: mantine default */
262
- color: #fa5252; /* HARDCODE: mantine default error */
263
- font-size: 12px; /* HARDCODE: mantine default xs */
264
- line-height: 1.2;
265
- }
266
-
267
- .description[data-disabled="true"],
268
- .error[data-disabled="true"] {
269
- opacity: var(
270
- --recursica_ui-kit_components_checkbox-item_variants_states_disabled_properties_opacity
271
- );
272
- }
266
+ /* description/error text renders via AssistiveElement directly (see Checkbox.tsx) —
267
+ it owns its own tokenized styling. AssistiveElement has no disabled state and
268
+ must never dim. */
273
269
 
274
270
  /* CheckboxGroup Layouts */
275
271
  .groupRoot {
@@ -17,6 +17,7 @@ import {
17
17
  FormControlLayout,
18
18
  type FormControlLayoutProps,
19
19
  } from "../FormControlLayout/FormControlLayout";
20
+ import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
20
21
 
21
22
  import styles from "./Checkbox.module.css";
22
23
 
@@ -96,6 +97,12 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
96
97
  const isChecked = isGrouped
97
98
  ? (groupContext.value || []).includes(restRecord.value)
98
99
  : !!(restRecord.checked ?? restRecord.defaultChecked);
100
+ // Only force controlled mode when we actually own the source of truth (grouped) or the
101
+ // caller explicitly passed `checked` (controlled). A plain `defaultChecked`/neither-prop
102
+ // usage must stay uncontrolled — otherwise `checked` gets pinned to a value that never
103
+ // updates, and MUI's native input silently snaps back after every click. Mirrors Switch's
104
+ // identical `isGrouped ? { checked: ... } : {}` pattern (Switch never had this bug).
105
+ const isControlled = isGrouped || restRecord.checked !== undefined;
99
106
 
100
107
  if (readOnly && !!readOnlyComponent) {
101
108
  const ReadOnlyComp = readOnlyComponent;
@@ -166,8 +173,9 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
166
173
  classes={mergedClassNames}
167
174
  disabled={readOnly || disabled || isGroupReadOnly}
168
175
  {...(sanitizedProps as unknown as MuiCheckboxProps)}
169
- checked={isChecked as boolean}
176
+ {...(isControlled ? { checked: isChecked as boolean } : {})}
170
177
  onChange={handleChange}
178
+ disableRipple
171
179
  sx={label ? { padding: 0 } : undefined}
172
180
  icon={<div className={styles.input} />}
173
181
  checkedIcon={
@@ -204,25 +212,23 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
204
212
  >
205
213
  {label as React.ReactNode}
206
214
  </label>
207
- {description && (
208
- <div
209
- className={styles.description}
210
- data-disabled={
211
- readOnly || disabled || isGroupReadOnly ? true : undefined
212
- }
213
- >
214
- {description}
215
- </div>
216
- )}
217
- {error && (
218
- <div
219
- className={styles.error}
220
- data-disabled={
221
- readOnly || disabled || isGroupReadOnly ? true : undefined
222
- }
215
+ {/* description/error are mutually exclusive — error takes precedence */}
216
+ {error ? (
217
+ <AssistiveElement
218
+ assistiveVariant="error"
219
+ assistiveWithIcon={false}
223
220
  >
224
221
  {error}
225
- </div>
222
+ </AssistiveElement>
223
+ ) : (
224
+ description && (
225
+ <AssistiveElement
226
+ assistiveVariant="help"
227
+ assistiveWithIcon={false}
228
+ >
229
+ {description}
230
+ </AssistiveElement>
231
+ )
226
232
  )}
227
233
  </div>
228
234
  </div>
@@ -0,0 +1,102 @@
1
+ # Chip Implementation Notes
2
+
3
+ ## Architecture decisions
4
+
5
+ ### MUI DOM Structure & Label/Icon Overrides
6
+
7
+ MUI's `<Chip>` renders a plain `<div>` root with an optional leading `icon`, a `label`, and an
8
+ optional `deleteIcon` — no hidden `<input>` involved (unlike Mantine's checkbox/radio-based
9
+ `Chip`). This adapter targets MUI's own style hooks (`classes.root`/`classes.label`/`classes.icon`/
10
+ `classes.deleteIcon`) directly via the `classes` prop, matching the same visual surface the
11
+ mantine-adapter's `Chip` exposes.
12
+
13
+ ### Icon and Remove Implementations
14
+
15
+ To match the mantine-adapter's internal `children` wrapper strategy, `label` is always set to a
16
+ `<span className={styles.innerWrapper}><span className={styles.children}>{children}</span></span>`
17
+ — any caller-supplied `label` in `sanitizedProps` is unconditionally overridden, since this
18
+ component's real public API is `children`, not `label` (see the `Chip.tsx` type comment: MUI's own
19
+ `ChipProps.children` is typed `null | undefined`, re-typed here as `React.ReactNode`).
20
+
21
+ `deleteIcon` and `icon` are each wrapped in their own `<span>` (`styles.removeIconWrapper`/
22
+ `styles.leadingIcon`) rather than styled as bare SVGs, so sizing/color/gap tokens have a stable
23
+ element to target — MUI clones whatever element is passed as `deleteIcon`/`icon`, adding only its
24
+ own `className`/`onClick`, so a `ref`/`tabIndex` set directly on that `<span>` survives the clone
25
+ untouched.
26
+
27
+ ### Long-label truncation was hiding the remove icon (Matt Massey, 2026-08-17)
28
+
29
+ Same root cause as mantine-adapter's `Chip` (see its `CHIP_IMPLEMENTATION_NOTES.md`): `.children`
30
+ had ellipsis/`overflow: hidden`/`white-space: nowrap` but no `min-width: 0` on it or `.innerWrapper`,
31
+ so a flex child never actually shrank enough to trigger the ellipsis — the label overflowed instead,
32
+ pushing `.removeIconWrapper` outside the chip's clipped `max-width`. Fixed by adding `min-width: 0`
33
+ to `.innerWrapper`/`.children` and `flex-shrink: 0` to `.leadingIcon`/`.removeIconWrapper`.
34
+
35
+ ### The remove icon had no accessible label and no real keyboard activation (Matt Massey, 2026-08-17)
36
+
37
+ `removeLabel` was destructured from props and defaulted to `"Remove"`, but was never actually
38
+ applied anywhere — dead code (`// eslint-disable-next-line @typescript-eslint/no-unused-vars`)
39
+ since this component was first built. The `deleteIcon` `<span>` had no `aria-label`, no `role`, and
40
+ no keyboard handler of its own: MUI's own `onDelete` wiring only reacts to `Backspace`/`Delete`,
41
+ and only when the _root_ itself is both the event's `target` and `currentTarget` (see
42
+ `isDeleteKeyboardEvent`/`handleKeyUp` in MUI's own `Chip.js`) — it never fires once focus moves onto
43
+ a child span directly (which is exactly what happens once you `.focus()` the remove icon
44
+ imperatively, as `FileUpload`'s roving-tabindex group does below). A plain `<span>` also gets no
45
+ native Enter/Space-triggers-click behavior the way a real `<button>` would. Fixed by adding
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
48
+ three.
49
+
50
+ ### Roving tabindex support for chip groups (Matt Massey, 2026-08-17)
51
+
52
+ Added two optional pass-through props — `removeTabIndex` and `removeIconRef` — purely so a parent
53
+ managing a _group_ of chips (e.g. `FileUpload`'s file list, see its own `IMPLEMENTATION_NOTES.md`)
54
+ can implement roving-tabindex/arrow-key navigation across them: set `removeTabIndex={-1}` on every
55
+ chip but the currently-active one, and use `removeIconRef` to move real DOM focus there
56
+ imperatively on arrow-key press. Both are set directly on the `<span>` passed as `deleteIcon`,
57
+ which MUI's `Chip` preserves when it clones that element. Both are no-ops for a standalone `Chip`
58
+ (defaults: `tabIndex={0}`, no ref) — this doesn't change any existing single-chip behavior.
59
+
60
+ A group composing multiple chips also needs to pass a plain `tabIndex={-1}` directly on each
61
+ `<Chip>` itself (not just `removeTabIndex` on the remove icon) — MUI's `Chip` silently renders its
62
+ _root_ as a focusable `ButtonBase` (not a plain `<div>`) whenever `onDelete` is set, even with no
63
+ `onClick` (`component = clickable || onDelete ? ButtonBase : ...`), so without this the root is a
64
+ second, unwanted tab stop ahead of the remove icon on every chip. `FileUpload` does this; any other
65
+ consumer building a roving-tabindex chip group needs to as well.
66
+
67
+ ### Descenders were being clipped on `.children` and `.root` (Matt Massey, 2026-08-18)
68
+
69
+ Same root cause and fix as mantine-adapter's `Chip` (see its `CHIP_IMPLEMENTATION_NOTES.md`): a
70
+ label with a descender (e.g. the "g" in "image.png") had its bottom clipped off, because both
71
+ `.children` and `.root.root` set plain `overflow: hidden` — needed on the x-axis for
72
+ `text-overflow: ellipsis`/`max-width` truncation, but clipping the y-axis too cuts off glyph ink
73
+ that extends past a `text_line-height` token tighter than the font's natural ascent+descent. Split
74
+ both into `overflow-x: hidden; overflow-y: visible;`; truncation is unaffected (still x-axis only).
75
+ Unlike mantine's `Chip`, MUI's `.root.root` _is_ the visible pill (background-color/border-radius
76
+ live there, not on a separate `.label`), but border-radius rendering doesn't depend on `overflow`,
77
+ so opening the y-axis has no visual side effect on the rounded corners.
78
+
79
+ ### Removing Sizing Properties
80
+
81
+ Matching mantine-adapter's `Chip`: Figma tokens export explicit height/padding vectors rather than
82
+ string size variants (`sm`/`md`/`lg`), so `size` is omitted from `RecursicaChipProps` entirely.
83
+
84
+ ### A non-interactive chip still looked clickable, and had a phantom Tab stop (Matt Massey, 2026-08-18)
85
+
86
+ A `Chip` with no `onRemove`/`onClick`/`onChange` (e.g. `FileUpload`'s `readOnly` file list) still
87
+ showed a pointer cursor on hover — `.root.root` hardcoded `cursor: pointer` unconditionally, with
88
+ no notion of whether the chip actually did anything. Added an `isInteractive` check (mirroring the
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
91
+ misread the way Mantine's did, since MUI's `Chip` has no real underlying form control. A new
92
+ `data-interactive` attribute (set from `isInteractive`) gates `cursor: pointer` in CSS; without it,
93
+ the chip falls back to whatever MUI's own non-clickable `Chip` renders as (no cursor override, no
94
+ `ButtonBase`).
95
+
96
+ Separately, `.children`'s `overflow-x: hidden` (added for ellipsis truncation) made it a scroll
97
+ container, and Chromium auto-adds scroll containers with actually-overflowing content to the Tab
98
+ order — with no `tabindex` attribute at all — so a solo Tab press could land on a chip's plain
99
+ filename text before ever reaching a real control. Switched to `overflow-x: clip`, which doesn't
100
+ establish a scrollport (same visual clipping, still x-axis only per the descender fix above), so
101
+ it's no longer a focus candidate. This affected every chip with long enough content, not just
102
+ read-only ones — see `FileUpload`'s own `IMPLEMENTATION_NOTES.md` for how it surfaced there.
@@ -81,12 +81,22 @@
81
81
  max-width: var(--recursica_ui-kit_components_chip_properties_max-width);
82
82
  box-shadow: var(--recursica_ui-kit_components_chip_properties_elevation);
83
83
 
84
- cursor: pointer;
85
84
  user-select: none;
86
- overflow: hidden;
85
+ overflow-x: hidden; /* HARDCODE: horizontal-only — `overflow: hidden` on both axes was clipping
86
+ descenders (e.g. the "g" in "image.png") whenever the line-height token is tighter than the
87
+ font's natural glyph extent (Matt Massey, 2026-08-18); border-radius still renders correctly
88
+ on this box regardless of overflow, so rounded corners are unaffected */
89
+ overflow-y: visible;
87
90
  transition: all 0.2s ease;
88
91
  }
89
92
 
93
+ /* Only a chip with a real handler (onRemove/onClick/onChange — see Chip.tsx's `isInteractive`)
94
+ gets the pointer cursor. A display-only chip (e.g. a read-only FileUpload file list) has
95
+ nothing for a click to do, so it shouldn't look clickable. */
96
+ .root.root[data-interactive] {
97
+ cursor: pointer;
98
+ }
99
+
90
100
  /* We target the mantine label directly because that is the visible container in Mantine's Chip */
91
101
  .label.label {
92
102
  box-sizing: border-box;
@@ -128,8 +138,6 @@
128
138
  background-color: var(--chip-bg);
129
139
  border-color: var(--chip-border);
130
140
  color: var(--chip-text);
131
- min-width: var(--recursica_ui-kit_components_chip_properties_min-width);
132
- max-width: var(--recursica_ui-kit_components_chip_properties_max-width);
133
141
  box-shadow: var(--recursica_ui-kit_components_chip_properties_elevation);
134
142
 
135
143
  transition: all 0.2s ease;
@@ -195,11 +203,12 @@
195
203
  cursor: not-allowed;
196
204
  }
197
205
 
198
- /* Override Mantine's built-in check icon colors and size if we keep it */
199
- .mantineIconWrapper.mantineIconWrapper {
206
+ /* Selected-state check icon colors and size */
207
+ .checkIconWrapper.checkIconWrapper {
200
208
  color: var(--chip-icon);
201
209
  width: var(--recursica_ui-kit_components_chip_properties_icon-size);
202
210
  height: var(--recursica_ui-kit_components_chip_properties_icon-size);
211
+ margin-left: 0;
203
212
  margin-right: var(
204
213
  --recursica_ui-kit_components_chip_properties_icon-text-gap
205
214
  );
@@ -210,22 +219,40 @@
210
219
  align-items: center;
211
220
  gap: var(--recursica_ui-kit_components_chip_properties_icon-text-gap);
212
221
  width: 100%;
222
+ min-width: 0; /* HARDCODE: lets .children actually shrink and ellipsize instead of overflowing */
213
223
  }
214
224
 
215
225
  .children {
216
226
  flex-grow: 1;
227
+ min-width: 0; /* HARDCODE: a flex child needs this for text-overflow: ellipsis to engage at all */
217
228
  text-overflow: ellipsis;
218
- overflow: hidden;
229
+ overflow-x: clip; /* HARDCODE: text-overflow: ellipsis only needs the x-axis clipped — clipping
230
+ y too (plain `overflow: hidden`) cut off descenders (e.g. the "g" in "image.png") whenever the
231
+ line-height token is tighter than the font's natural glyph extent (Matt Massey, 2026-08-18).
232
+ `clip` rather than `hidden` because `hidden` makes this a scroll container, and Chromium
233
+ auto-adds scroll containers with overflowing content to the Tab order (so a browser user can
234
+ arrow-key-scroll them) — `clip` doesn't create a scrollport, so long/truncated chip labels
235
+ (e.g. a read-only FileUpload file list) don't pick up a phantom, un-styled tab stop. */
236
+ overflow-y: visible;
219
237
  white-space: nowrap;
220
238
  }
221
239
 
222
- .leadingIcon {
240
+ /* Doubled selector for specificity parity with MUI's own compound
241
+ `.MuiChip-root .MuiChip-icon` rule, which otherwise wins outright on a
242
+ plain single-class selector and leaks its default margin/color. */
243
+ .leadingIcon.leadingIcon {
223
244
  display: inline-flex;
224
245
  align-items: center;
225
246
  justify-content: center;
247
+ flex-shrink: 0; /* HARDCODE: keep the icon from being squeezed by a long, truncating label */
226
248
  color: var(--chip-icon);
227
249
  width: var(--recursica_ui-kit_components_chip_properties_icon-size);
228
250
  height: var(--recursica_ui-kit_components_chip_properties_icon-size);
251
+ /* Reset MUI's default icon margin, keep the icon-text gap */
252
+ margin-left: 0;
253
+ margin-right: var(
254
+ --recursica_ui-kit_components_chip_properties_icon-text-gap
255
+ );
229
256
  }
230
257
 
231
258
  .leadingIcon svg {
@@ -233,10 +260,13 @@
233
260
  height: 100%;
234
261
  }
235
262
 
236
- .removeIconWrapper {
263
+ /* Doubled selector for specificity parity with MUI's own compound
264
+ `.MuiChip-root .MuiChip-deleteIcon` rule (same reasoning as .leadingIcon). */
265
+ .removeIconWrapper.removeIconWrapper {
237
266
  display: inline-flex;
238
267
  align-items: center;
239
268
  justify-content: center;
269
+ flex-shrink: 0; /* HARDCODE: keep the remove icon visible when the label truncates instead */
240
270
  color: var(--chip-close);
241
271
  width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
242
272
  height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
@@ -247,8 +277,13 @@
247
277
  margin: 0;
248
278
  }
249
279
 
250
- .removeIconWrapper:focus-visible {
251
- box-shadow: 0 0 0 2px var(--chip-border); /* HARDCODE: Focus indication */
280
+ .removeIconWrapper.removeIconWrapper:focus-visible {
281
+ box-shadow:
282
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
283
+ var(--recursica_brand_states_focus_color),
284
+ 0 0 var(--recursica_brand_states_focus_blur)
285
+ var(--recursica_brand_states_focus_margin)
286
+ var(--recursica_brand_states_focus_color);
252
287
  }
253
288
 
254
289
  .removeIconWrapper svg {
@@ -9,8 +9,14 @@ import styles from "./Chip.module.css";
9
9
  import { type RecursicaChipProps } from "@recursica/adapter-common";
10
10
 
11
11
  export type ChipProps = RecursicaOverStyled<
12
- Omit<MuiChipProps, "variant" | "size" | "color" | "radius"> &
13
- RecursicaChipProps
12
+ Omit<MuiChipProps, "variant" | "size" | "color" | "radius" | "children"> &
13
+ RecursicaChipProps & {
14
+ // MUI's own ChipProps types `children` as `null | undefined` (MUI's Chip expects `label`
15
+ // instead) — this component's actual API is `children` (see the `label={...}` JSX below,
16
+ // which always wins over any caller-supplied `label` in `sanitizedProps`), so restore a
17
+ // real type for it here.
18
+ children?: React.ReactNode;
19
+ }
14
20
  >;
15
21
 
16
22
  function CloseIcon(props: React.ComponentPropsWithoutRef<"svg">) {
@@ -56,8 +62,9 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
56
62
  error = false,
57
63
  icon,
58
64
  onRemove,
59
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
60
65
  removeLabel = "Remove",
66
+ removeTabIndex,
67
+ removeIconRef,
61
68
  children,
62
69
  checked,
63
70
  overStyled = false,
@@ -97,6 +104,14 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
97
104
  const dataError = error ? "" : undefined;
98
105
  const dataChecked = checked ? "" : undefined;
99
106
  const isIconOnly = !children && (!!icon || !!onRemove);
107
+ // A chip only counts as interactive when something actually responds to it — merely passing a
108
+ // `checked` value (e.g. to pin a display-only chip to a fixed visual state, as FileUpload's
109
+ // read-only file list does) isn't itself an interaction, since clicking it with no onChange/
110
+ // onClick wired does nothing observable.
111
+ const isInteractive =
112
+ onRemove !== undefined ||
113
+ restRecord.onClick !== undefined ||
114
+ restRecord.onChange !== undefined;
100
115
 
101
116
  return (
102
117
  <MuiChip
@@ -106,10 +121,11 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
106
121
  {...(dataError !== undefined ? { "data-error": "" } : {})}
107
122
  {...(dataChecked !== undefined ? { "data-checked": "" } : {})}
108
123
  {...(isIconOnly ? { "data-icon-only": "" } : {})}
124
+ {...(isInteractive ? { "data-interactive": "" } : {})}
109
125
  {...sanitizedProps}
110
126
  icon={
111
127
  checked ? (
112
- <span className={styles.mantineIconWrapper} aria-hidden>
128
+ <span className={styles.checkIconWrapper} aria-hidden>
113
129
  <CheckIcon />
114
130
  </span>
115
131
  ) : icon ? (
@@ -121,7 +137,27 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
121
137
  onDelete={onRemove}
122
138
  deleteIcon={
123
139
  onRemove ? (
124
- <span className={styles.removeIconWrapper}>
140
+ <span
141
+ ref={removeIconRef}
142
+ role="button"
143
+ className={styles.removeIconWrapper}
144
+ aria-label={removeLabel}
145
+ tabIndex={removeTabIndex ?? 0}
146
+ onKeyDown={(e) => {
147
+ // MUI's own `onDelete` wiring only reacts to Backspace/Delete, and only when this
148
+ // span itself is both the event's target and currentTarget — neither holds once a
149
+ // parent (e.g. FileUpload's roving-tabindex group) moves real focus onto this span
150
+ // directly. A plain `<span>` also gets no native Enter/Space-triggers-click behavior
151
+ // the way a real `<button>` would, so it's handled explicitly here instead.
152
+ if (e.key === "Enter" || e.key === " ") {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ onRemove(
156
+ e as unknown as React.MouseEvent<HTMLSpanElement, MouseEvent>,
157
+ );
158
+ }
159
+ }}
160
+ >
125
161
  <CloseIcon />
126
162
  </span>
127
163
  ) : undefined
@@ -34,3 +34,22 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
34
34
  > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
35
35
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
36
36
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
37
+
38
+ ---
39
+
40
+ ## 4. Key Integration Features & Constraints
41
+
42
+ ### Sizing
43
+
44
+ Chip does not support a `size` prop; chips render at a fixed size.
45
+
46
+ ### Building a keyboard-navigable chip group
47
+
48
+ `removeTabIndex` and `removeIconRef` are optional escape hatches for composing a _group_ of chips
49
+ with roving-tabindex keyboard navigation (Tab reaches one chip at a time, arrow keys move between
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.
52
+
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`).