@recursica/mantine-adapter 0.38.1 → 0.40.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 (32) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/CHANGELOG.md +51 -0
  3. package/dist/index.d.ts +186 -20
  4. package/dist/mantine-adapter.cjs +2 -2
  5. package/dist/mantine-adapter.cjs.map +1 -1
  6. package/dist/mantine-adapter.css +1 -1
  7. package/dist/mantine-adapter.js +2724 -2491
  8. package/dist/mantine-adapter.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +16 -2
  11. package/src/components/Accordion/Accordion.module.css +10 -1
  12. package/src/components/Accordion/Accordion.stories.tsx +36 -0
  13. package/src/components/Accordion/Accordion.tsx +40 -7
  14. package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +45 -0
  15. package/src/components/AssistiveElement/AssistiveElement.tsx +8 -1
  16. package/src/components/AutoComplete/AutoComplete.tsx +1 -4
  17. package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +10 -0
  18. package/src/components/Button/Button.module.css +13 -0
  19. package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +59 -0
  20. package/src/components/Chip/Chip.module.css +36 -4
  21. package/src/components/Chip/Chip.tsx +14 -5
  22. package/src/components/Chip/USAGE.md +7 -0
  23. package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +244 -0
  24. package/src/components/FileUpload/FileUpload.module.css +204 -42
  25. package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
  26. package/src/components/FileUpload/FileUpload.tsx +352 -5
  27. package/src/components/FileUpload/USAGE.md +163 -5
  28. package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +36 -0
  29. package/src/components/Switch/Switch.module.css +19 -2
  30. package/src/components/Switch/SwitchGroup.tsx +6 -3
  31. package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +10 -0
  32. package/src/components/TimePicker/TimePicker.tsx +1 -1
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mantine-adapter"
15
15
  },
16
- "version": "0.38.1",
16
+ "version": "0.40.0",
17
17
  "type": "module",
18
18
  "main": "./dist/mantine-adapter.cjs",
19
19
  "module": "./dist/mantine-adapter.js",
@@ -16,8 +16,8 @@ Decisions and design tweaks strictly tailored for the UI Kit's Accordion wrapped
16
16
 
17
17
  ## 2. Default Configuration Reset (`unstyled`)
18
18
 
19
- **Decision:** We strip Mantine's inner styles away completely from Accordion mappings by leveraging React's default `variant="unstyled"`.
20
- **Implementation:** In `Accordion.tsx`, `<MantineAccordion>` binds `variant="unstyled"`. This effectively deletes Mantine's precomputed padding, borders, and shadow mappings allowing our targeted `classNames` inside `Accordion.module.css` to become the exact source of foundational truth without "fighting" `!important` tags or unpredictable flex-layouts inherited globally.
19
+ **Decision:** We strip Mantine's inner styles away completely from Accordion mappings by leveraging Mantine's own `variant="unstyled"`. That Mantine sentinel is an internal implementation detail, not something a Recursica consumer should ever set directly — `RecursicaAccordionProps.variant` is `"default" | (string & {})`: `"default"` is the only value with dedicated Recursica styling, mapped internally via `mapVariant` in `Accordion.tsx`; any other string is a caller-supplied custom variant and passes straight through to Mantine unmapped.
20
+ **Implementation:** `AccordionBase` defaults `variant` to Recursica's `"default"`, looks it up in `mapVariant` (`{ default: "unstyled" }`), and passes the resolved value (or the original string, if it wasn't a recognized key) to `<MantineAccordion variant={...}>`. The `"default"` → `"unstyled"` mapping effectively deletes Mantine's precomputed padding, borders, and shadow mappings allowing our targeted `classNames` inside `Accordion.module.css` to become the exact source of foundational truth without "fighting" `!important` tags or unpredictable flex-layouts inherited globally.
21
21
 
22
22
  ---
23
23
 
@@ -46,3 +46,17 @@ Decisions and design tweaks strictly tailored for the UI Kit's Accordion wrapped
46
46
 
47
47
  **Decision:** We do not bind isolated `open={true}` state properties natively on individual `<AccordionItem>` configurations.
48
48
  **Implementation:** Recursica natively dictates an item-level `open` tracking mapping. However, internally mapping boolean flags structurally across specific tree nodes heavily corrupts Mantine's DOM layout algorithms mapping parent-driven transition listeners. Mantine forces all expanded-height logic to run symmetrically off the `<Accordion value="...">` string matching array to accurately bind ARIA transitions. We explicitly ignore isolated item `<AccordionItem open={...}>` booleans to shield the rendering sequence cleanly.
49
+
50
+ ---
51
+
52
+ ## 7. Native `icon` slot is omitted on `AccordionControl`
53
+
54
+ **Decision:** Mantine's `AccordionControl` has its own native `icon` prop — a distinct,
55
+ documented slot ("icon displayed next to the label") — which the adapter reuses internally
56
+ to render Recursica's `leftIcon`. Left unguarded, a caller could pass native `icon` directly
57
+ and silently override the `leftIcon`-derived element (spread order would let it win).
58
+ **Implementation:** `AccordionControlWrapperProps` omits `icon` from Mantine's native
59
+ `AccordionControlProps` before merging in `RecursicaAccordionControlProps`, so the only way
60
+ to set that slot is through `leftIcon`. Mantine's separate, per-control `chevron` override
61
+ is _not_ omitted — nothing here computes it, so it still passes through safely if a caller
62
+ wants to override the cascaded container-level chevron for a single item.
@@ -159,7 +159,7 @@
159
159
  constant background (Mantine's native :hover CSS would otherwise show through) and use an
160
160
  overlay ::after structure with the generic hover tokens for the actual hover tint; no per-item
161
161
  hover-color/hover-opacity tokens exist anymore. */
162
- .control:hover {
162
+ .control:hover:not(:disabled) {
163
163
  background-color: var(
164
164
  --recursica_ui-kit_components_accordion-header_variants_appearance_closed_properties_colors_background-color
165
165
  );
@@ -179,6 +179,15 @@
179
179
  opacity: var(--recursica_brand_states_hover_opacity);
180
180
  }
181
181
 
182
+ /* Dims the whole item — control and any currently-visible panel content alike — using the
183
+ generic disabled opacity token, same convention as every other disabled component in the
184
+ design system (no accordion-item-specific disabled token exists). The Control's own native
185
+ `disabled` attribute already blocks click/keyboard interaction on its own; this only
186
+ handles the visual dim. */
187
+ .item[data-disabled] {
188
+ opacity: var(--recursica_brand_states_disabled);
189
+ }
190
+
182
191
  /* Recursica focus ring instead of Mantine's default `.mantine-focus-auto:focus-visible` outline
183
192
  (same class+pseudo-class specificity, so !important guarantees ours wins regardless of CSS
184
193
  import order). */
@@ -153,6 +153,42 @@ export const LongTitleTruncation: StoryObj<typeof Accordion> = {
153
153
  },
154
154
  };
155
155
 
156
+ export const Disabled: StoryObj<typeof Accordion> = {
157
+ render: () => {
158
+ return (
159
+ <Accordion defaultValue="expanded-disabled" chevron={<ChevronIcon />}>
160
+ <Accordion.Item value="expanded-disabled" disabled>
161
+ <Accordion.Control leftIcon={<SVGIcon />}>
162
+ Expanded and Disabled
163
+ </Accordion.Control>
164
+ <Accordion.Panel>
165
+ This item starts expanded so the panel content's dimming can be
166
+ verified alongside the control's, not just the collapsed header.
167
+ </Accordion.Panel>
168
+ </Accordion.Item>
169
+
170
+ <Accordion.Item value="collapsed-disabled" disabled>
171
+ <Accordion.Control leftIcon={<SVGIcon />}>
172
+ Collapsed and Disabled
173
+ </Accordion.Control>
174
+ <Accordion.Panel>
175
+ Clicking or tabbing to this control should have no effect.
176
+ </Accordion.Panel>
177
+ </Accordion.Item>
178
+
179
+ <Accordion.Item value="enabled">
180
+ <Accordion.Control leftIcon={<SVGIcon />}>
181
+ Enabled, for Comparison
182
+ </Accordion.Control>
183
+ <Accordion.Panel>
184
+ A normal, interactive item alongside the disabled ones above.
185
+ </Accordion.Panel>
186
+ </Accordion.Item>
187
+ </Accordion>
188
+ );
189
+ },
190
+ };
191
+
156
192
  /** Demonstrates the component nested inside a non-default layer — the one case where an
157
193
  * explicit `<Layer>` wrap belongs in a story (see COMPONENT_STORYBOOK_GUIDE.md §9). */
158
194
  export const LayerOne: StoryObj<typeof Accordion> = {
@@ -16,6 +16,7 @@ import {
16
16
  type RecursicaAccordionProps,
17
17
  type RecursicaAccordionItemProps,
18
18
  type RecursicaAccordionControlProps,
19
+ type RecursicaAccordionPanelProps,
19
20
  } from "@recursica/adapter-common";
20
21
 
21
22
  // ==== ACCORDION CONTAINER ====
@@ -27,11 +28,24 @@ export type AccordionProps = RecursicaOverStyled<
27
28
  RecursicaAccordionProps
28
29
  >;
29
30
 
31
+ // Maps Recursica's public variant vocabulary to Mantine's own. `unstyled` is a Mantine
32
+ // sentinel with no meaning to Recursica consumers — it exists only to suppress Mantine's
33
+ // built-in variant CSS so our module CSS is the sole source of styling. Anything outside
34
+ // this map (a caller-supplied custom variant string) passes straight through to Mantine.
35
+ const mapVariant: Record<string, string> = {
36
+ default: "unstyled",
37
+ };
38
+
30
39
  const AccordionBase = function Accordion({
31
- variant = "unstyled",
40
+ variant = "default",
32
41
  overStyled = false,
33
42
  ...rest
34
43
  }: AccordionProps) {
44
+ const resolvedVariant =
45
+ typeof variant === "string" && mapVariant[variant]
46
+ ? mapVariant[variant]
47
+ : variant;
48
+
35
49
  const sanitizedProps = filterStylingProps(rest, overStyled);
36
50
 
37
51
  // Bind all deep CSS module references natively into the global class mapping schema
@@ -67,7 +81,7 @@ const AccordionBase = function Accordion({
67
81
 
68
82
  return (
69
83
  <MantineAccordion
70
- variant={variant}
84
+ variant={resolvedVariant as MantineAccordionProps["variant"]}
71
85
  className={classNameProp}
72
86
  classNames={mergedClassNames}
73
87
  {...(sanitizedProps as unknown as MantineAccordionProps)}
@@ -85,7 +99,15 @@ export const AccordionItem = forwardRef<
85
99
  HTMLDivElement,
86
100
  AccordionItemWrapperProps
87
101
  >(function AccordionItem(
88
- { title, leftIcon, divider = true, children, overStyled = false, ...rest },
102
+ {
103
+ title,
104
+ leftIcon,
105
+ divider = true,
106
+ children,
107
+ disabled = false,
108
+ overStyled = false,
109
+ ...rest
110
+ },
89
111
  ref,
90
112
  ) {
91
113
  const sanitizedProps = filterStylingProps(rest, overStyled);
@@ -99,15 +121,22 @@ export const AccordionItem = forwardRef<
99
121
 
100
122
  // If the user utilizes the explicit 'title' prop from Recursica, we securely auto-construct the Mantine sub-hierarchy natively!
101
123
  // If not, we defer to raw composable children (meaning the integrator maps `<Accordion.Control>` manually).
124
+ // `disabled` has no native concept at the Item level — only Control has a real `disabled`
125
+ // prop (it renders a `<button>`, so the native HTML `disabled` attribute alone blocks click,
126
+ // focus, and keyboard activation with no extra guards needed). We forward it there in the
127
+ // auto-composed path; a manually-composed `<Accordion.Control>` needs it passed explicitly.
102
128
  return (
103
129
  <MantineAccordion.Item
104
130
  ref={ref}
105
131
  className={finalClass}
132
+ data-disabled={disabled || undefined}
106
133
  {...(sanitizedProps as unknown as AccordionItemProps)}
107
134
  >
108
135
  {title ? (
109
136
  <>
110
- <AccordionControl leftIcon={leftIcon}>{title}</AccordionControl>
137
+ <AccordionControl leftIcon={leftIcon} disabled={disabled}>
138
+ {title}
139
+ </AccordionControl>
111
140
  <AccordionPanel>{children}</AccordionPanel>
112
141
  </>
113
142
  ) : (
@@ -120,7 +149,10 @@ AccordionItem.displayName = "AccordionItem";
120
149
 
121
150
  // ==== ACCORDION CONTROL ====
122
151
  export type AccordionControlWrapperProps = RecursicaOverStyled<
123
- AccordionControlProps & RecursicaAccordionControlProps
152
+ // Mantine's native `icon` slot is resolved internally from `leftIcon` — omitted so a
153
+ // caller can't silently override it by passing `icon` directly. Mantine's per-control
154
+ // `chevron` override is left as-is; nothing here computes it, so it passes through safely.
155
+ Omit<AccordionControlProps, "icon"> & RecursicaAccordionControlProps
124
156
  >;
125
157
 
126
158
  export const AccordionControl = forwardRef<
@@ -154,8 +186,9 @@ export const AccordionControl = forwardRef<
154
186
  AccordionControl.displayName = "AccordionControl";
155
187
 
156
188
  // ==== ACCORDION PANEL ====
157
- export type AccordionPanelWrapperProps =
158
- RecursicaOverStyled<AccordionPanelProps>;
189
+ export type AccordionPanelWrapperProps = RecursicaOverStyled<
190
+ AccordionPanelProps & RecursicaAccordionPanelProps
191
+ >;
159
192
 
160
193
  export const AccordionPanel = forwardRef<
161
194
  HTMLDivElement,
@@ -0,0 +1,45 @@
1
+ # AssistiveElement – implementation notes
2
+
3
+ Decisions and design tweaks specific to the UI Kit's AssistiveElement wrapped against
4
+ `@mantine/core`.
5
+
6
+ ---
7
+
8
+ ## 1. Plain `<div>` — no underlying component wrapped
9
+
10
+ **Decision:** Unlike most components in this adapter, `AssistiveElement` doesn't wrap any real
11
+ `@mantine/core` component at all — it's a bare `<div>` with two `<span>` wrappers (icon, text).
12
+ Mantine has no dedicated helper/error-text component to wrap in the first place (it composes
13
+ that presentation ad hoc per-field via `TextInput`'s own `error`/`description` props instead of
14
+ a standalone component), so there's nothing here to inherit layout or ARIA semantics from.
15
+ **Implementation:** A `<div>` is a reasonable generic container, but it carries no semantic
16
+ meaning of its own. `role="alert"` is now defaulted automatically for the `"error"` variant
17
+ (see §3) — the one piece of ARIA behavior that genuinely belongs at this component's own level.
18
+ `aria-describedby`/`aria-errormessage` association with a field is a different story: this
19
+ component has no reference to any sibling field, so it can't wire that itself. It's already
20
+ handled one layer up, in `FormControlWrapper` — which generates the ids, passes them down via
21
+ this component's own `id` prop, and clones `aria-describedby`/`aria-errormessage` onto the
22
+ field. Only a fully standalone `<AssistiveElement>` used outside `FormControlWrapper` still
23
+ needs that wiring done by hand, via the standard HTML attributes that pass through `{...rest}`.
24
+
25
+ ---
26
+
27
+ ## 2. Fixed icon per variant, no custom icon slot
28
+
29
+ **Decision:** `assistiveWithIcon` only toggles visibility — there's no prop to swap in a custom
30
+ icon. The icon is always the one belonging to the current `assistiveVariant` (`InfoIcon` for
31
+ `"help"`, `AlertIcon` for `"error"`).
32
+ **Implementation:** `IconComponent = assistiveVariant === "error" ? AlertIcon : InfoIcon` is
33
+ resolved directly in the render body; there's no icon prop in `RecursicaAssistiveElementProps`
34
+ to intercept.
35
+
36
+ ---
37
+
38
+ ## 3. `role="alert"` defaults on for the error variant
39
+
40
+ **Decision:** Error text needs to be announced by assistive tech as it appears or changes;
41
+ static help text doesn't. Rather than requiring every integrator to remember `role="alert"`,
42
+ `assistiveVariant="error"` defaults it automatically.
43
+ **Implementation:** `role` is destructured out and resolved as `role ?? (assistiveVariant ===
44
+ "error" ? "alert" : undefined)` before the spread — an explicit caller-supplied `role` always
45
+ wins over the default, and `"help"` gets no default at all.
@@ -52,6 +52,7 @@ export const AssistiveElement = forwardRef<
52
52
  assistiveWithIcon = true,
53
53
  children,
54
54
  className,
55
+ role,
55
56
  overStyled = false,
56
57
  ...rest
57
58
  },
@@ -65,13 +66,19 @@ export const AssistiveElement = forwardRef<
65
66
 
66
67
  const IconComponent = assistiveVariant === "error" ? AlertIcon : InfoIcon;
67
68
 
69
+ // Announce error text as it appears/changes; a caller-supplied `role` always wins. No
70
+ // default for `help` — static descriptive text doesn't need a live region.
71
+ const resolvedRole =
72
+ role ?? (assistiveVariant === "error" ? "alert" : undefined);
73
+
68
74
  return (
69
75
  <div
70
76
  ref={ref}
71
77
  className={finalClass}
72
78
  data-variant={assistiveVariant}
79
+ role={resolvedRole}
73
80
  style={restRecord.style as React.CSSProperties}
74
- {...restRecord} // Spread standard HTML attributes (like id, aria-*, role) natively.
81
+ {...restRecord} // Spread standard HTML attributes (like id, aria-*) natively.
75
82
  >
76
83
  {assistiveWithIcon && (
77
84
  <span className={styles.iconWrapper}>
@@ -151,10 +151,7 @@ export const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(
151
151
  disabled={disabled}
152
152
  value={value as string | undefined}
153
153
  defaultValue={defaultValue as string | undefined}
154
- wrapperProps={{
155
- "data-disabled": disabled ? "true" : undefined,
156
- "data-error": error ? "true" : undefined,
157
- }}
154
+ error={!!error}
158
155
  {...(sanitizedProps as unknown as MantineAutocompleteProps)}
159
156
  />
160
157
  }
@@ -22,3 +22,13 @@ Since Avatar children (icons or initials) require robust centering that might di
22
22
  ### CSS Reset Hacks
23
23
 
24
24
  Noticeable `/* HARDCODE: ... */` hacks are deployed within `.root` to completely zero-out Mantine's `--avatar-bg` and internal variables statically since Recursica handles background-colors inherently via the CSS variants cascade.
25
+
26
+ ### `variant` mapping is a genuine match here (unlike MUI's)
27
+
28
+ Mantine's native `Avatar.variant` (`'filled'|'light'|'gradient'|'outline'|'transparent'|...`)
29
+ really is the same color-treatment concept Recursica's own `variant` is, so `mapVariant`
30
+ (`solid→filled, outline→outline, ghost→transparent`) is a correct, meaningful mapping — even
31
+ though the CSS reset hack above means it's visually inert either way. Worth noting only because
32
+ the MUI adapter's `Avatar.variant` means something completely different (shape) and had a real
33
+ bug from assuming the same word meant the same thing there; see mui-adapter's own
34
+ `AVATAR_IMPLEMENTATION_NOTES.md`.
@@ -401,3 +401,16 @@
401
401
  --recursica_ui-kit_components_button_variants_styles_text_variants_states_disabled_properties_elevation
402
402
  );
403
403
  }
404
+
405
+ /* Focus State Mapping (native browser focus outline replaced with the recursica focus ring).
406
+ Placed after every variant's own box-shadow (elevation) rule so it wins the cascade at equal
407
+ attribute-selector specificity regardless of variant. */
408
+ .root:focus-visible {
409
+ outline: none;
410
+ box-shadow:
411
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
412
+ var(--recursica_brand_states_focus_color),
413
+ 0 0 var(--recursica_brand_states_focus_blur)
414
+ var(--recursica_brand_states_focus_margin)
415
+ var(--recursica_brand_states_focus_color);
416
+ }
@@ -36,3 +36,62 @@ To accommodate this, the visual "close" icon uses a `<span>` element configured
36
36
  ### Removing Sizing Properties
37
37
 
38
38
  During implementation, the parsed Figma design tokens natively exported specific height/padding vectors dynamically (e.g., `--recursica_ui-kit_components_chip_properties_icon-size`) rather than explicit string variants (`sm`, `md`, `lg`). Therefore, we omitted `size` conceptually from the `RecursicaChipProps` wrapper to lock down size evaluation natively against the active layer variables.
39
+
40
+ ### Long-label truncation was hiding the remove icon (Matt Massey, 2026-08-17)
41
+
42
+ `.children` already had `text-overflow: ellipsis; overflow: hidden; white-space: nowrap;`, but
43
+ neither it nor its parent `.innerWrapper` had `min-width: 0` — a flex child without that refuses
44
+ to shrink below its intrinsic (full, unwrapped) content width, so `text-overflow: ellipsis` never
45
+ actually engaged. A long label overflowed the flex row instead, and since `.removeIcon` had no
46
+ `flex-shrink: 0`, it got squeezed out of the clipped (`max-width`-bounded) chip entirely. Fixed by
47
+ adding `min-width: 0` to `.innerWrapper`/`.children` and `flex-shrink: 0` to `.leadingIcon`/
48
+ `.removeIcon`. Reproduces easily with `FileUpload`'s file list, since its chip `max-width`
49
+ (`--recursica_ui-kit_components_chip_properties_max-width`, 200px) is small enough that most real
50
+ filenames overflow it.
51
+
52
+ ### Descenders were being clipped on `.children` and `.root` (Matt Massey, 2026-08-18)
53
+
54
+ A label with a descender (e.g. the "g" in "image.png") had its bottom clipped off. Both `.children`
55
+ and `.root.root` set plain `overflow: hidden` — needed on the x-axis so `.children`'s
56
+ `text-overflow: ellipsis` (and `.root`'s `max-width`) actually clip long labels, but clipping the
57
+ y-axis too cuts off any glyph ink that extends past the line box, which happens whenever the
58
+ `text_line-height` token is tighter than the font's natural ascent+descent. Fixed by splitting both
59
+ into `overflow-x: hidden; overflow-y: visible;` — ellipsis/max-width truncation is unaffected (still
60
+ x-axis only), but descenders can now paint outside a too-tight line box instead of being clipped.
61
+ `.root` has no background/border-radius of its own to protect (that's `.label`), so opening its
62
+ y-axis has no visual side effect.
63
+
64
+ ### Roving tabindex support for chip groups (Matt Massey, 2026-08-17)
65
+
66
+ Added two optional pass-through props — `removeTabIndex` and `removeIconRef` — purely so a parent
67
+ managing a _group_ of chips (e.g. `FileUpload`'s file list, see its own `IMPLEMENTATION_NOTES.md`)
68
+ can implement roving-tabindex/arrow-key navigation across them: set `removeTabIndex={-1}` on every
69
+ chip but the currently-active one, and use `removeIconRef` to move real DOM focus there
70
+ imperatively on arrow-key press. Both are no-ops for a standalone `Chip` (defaults: `tabIndex={0}`,
71
+ no ref) — this doesn't change any existing single-chip behavior.
72
+
73
+ ### `isInteractive` was measuring the wrong signal, and leaked a pointer cursor + phantom Tab stop (Matt Massey, 2026-08-18)
74
+
75
+ A `Chip` rendered with `checked` but no real handler (e.g. `FileUpload`'s `readOnly` file list —
76
+ `<Chip checked={false} tabIndex={-1}>`, no `onRemove`) still looked and behaved clickable, from two
77
+ separate bugs:
78
+
79
+ - `isInteractive` treated `checked !== undefined` (even `false`) as proof of interactivity. That's
80
+ the wrong signal — a `checked`-controlled chip with no `onChange` can't actually be toggled by a
81
+ click (Mantine's `useUncontrolled` discards the click when `value` is externally controlled), so
82
+ clicking it does nothing observable regardless. `isInteractive` now only looks at whether
83
+ something actually responds: `onRemove`, `onClick`, or `onChange`.
84
+ - `.label.label` never set its own `cursor`, so Mantine's base style (`cursor: pointer`, hardcoded
85
+ on the underlying `mantine-Chip-label` class) always leaked through, independent of whether the
86
+ chip was actually interactive. Reset it to `cursor: default` and added a `data-interactive`
87
+ attribute (driven by the fixed `isInteractive` above, set via `wrapperProps` on `MantineChip` so
88
+ it lands on `.root`) that re-enables `pointer` via `.root[data-interactive] .label.label` only
89
+ when there's a real handler.
90
+
91
+ Separately, `.children`'s `overflow-x: hidden` (added for ellipsis truncation) made it a scroll
92
+ container, and Chromium auto-adds scroll containers with actually-overflowing content to the Tab
93
+ order — with no `tabindex` attribute at all — so a solo Tab press could land on a chip's plain
94
+ filename text before ever reaching a real control. Switched to `overflow-x: clip`, which doesn't
95
+ establish a scrollport (same visual clipping, still x-axis only per the descender fix above), so
96
+ it's no longer a focus candidate. This affected every chip with long enough content, not just
97
+ read-only ones — see `FileUpload`'s own `IMPLEMENTATION_NOTES.md` for how it surfaced there.
@@ -53,9 +53,11 @@
53
53
  --recursica_ui-kit_components_chip_properties_border-radius
54
54
  );
55
55
 
56
- cursor: pointer;
57
56
  user-select: none;
58
- overflow: hidden;
57
+ overflow-x: hidden; /* HARDCODE: horizontal-only, same descender reasoning as .children below —
58
+ .root has no visible background of its own (that's .label), so there's nothing here that
59
+ needs vertical clipping */
60
+ overflow-y: visible;
59
61
  }
60
62
 
61
63
  /* We target the mantine label directly because that is the visible container in Mantine's Chip */
@@ -67,6 +69,10 @@
67
69
 
68
70
  /* Reset mantine styles */
69
71
  background: transparent; /* HARDCODE: Mantine forces background styling on label */
72
+ cursor: default; /* HARDCODE: Mantine's own base styles hardcode cursor: pointer on this class —
73
+ override it to plain default, then only re-enable it below for a chip with a real handler
74
+ (onRemove/onClick/onChange — see Chip.tsx's `isInteractive`). A display-only chip (e.g. a
75
+ read-only FileUpload file list) has nothing for a click to do, so it shouldn't look clickable. */
70
76
  border-style: solid;
71
77
  border-width: var(--recursica_ui-kit_components_chip_properties_border-size);
72
78
  padding: var(--recursica_ui-kit_components_chip_properties_vertical-padding)
@@ -110,12 +116,22 @@
110
116
  .label.label > span:not(.mantineIconWrapper) {
111
117
  display: inline-flex;
112
118
  align-items: center;
119
+ flex: 1 1 auto; /* HARDCODE: let this anonymous wrapper actually fill/shrink to .label's real
120
+ width instead of sizing to its content — otherwise a long .children label overflows this
121
+ span well past .label's 200px max-width, and .children's own text-overflow: ellipsis never
122
+ engages because ITS box is still as wide as the unwrapped text (see CHIP_IMPLEMENTATION_NOTES.md) */
123
+ min-width: 0; /* HARDCODE: flex items default to min-width: auto (their content's min-content
124
+ size) — without this override, flex-shrink is a no-op and the span never shrinks below that */
113
125
  }
114
126
 
115
127
  .label.label:hover {
116
128
  background-color: var(--chip-bg);
117
129
  }
118
130
 
131
+ .root[data-interactive] .label.label {
132
+ cursor: pointer;
133
+ }
134
+
119
135
  /* Default (Unselected) hover/active interactions (Mantine overrides) */
120
136
  .label.label[data-checked] {
121
137
  --chip-bg: var(
@@ -191,12 +207,21 @@
191
207
  align-items: center;
192
208
  gap: var(--recursica_ui-kit_components_chip_properties_icon-text-gap);
193
209
  width: 100%;
210
+ min-width: 0; /* HARDCODE: lets .children actually shrink and ellipsize instead of overflowing */
194
211
  }
195
212
 
196
213
  .children {
197
214
  flex-grow: 1;
215
+ min-width: 0; /* HARDCODE: a flex child needs this for text-overflow: ellipsis to engage at all */
198
216
  text-overflow: ellipsis;
199
- overflow: hidden;
217
+ overflow-x: clip; /* HARDCODE: text-overflow: ellipsis only needs the x-axis clipped — clipping
218
+ y too (plain `overflow: hidden`) cut off descenders (e.g. the "g" in "image.png") whenever the
219
+ line-height token is tighter than the font's natural glyph extent (Matt Massey, 2026-08-18).
220
+ `clip` rather than `hidden` because `hidden` makes this a scroll container, and Chromium
221
+ auto-adds scroll containers with overflowing content to the Tab order (so a browser user can
222
+ arrow-key-scroll them) — `clip` doesn't create a scrollport, so long/truncated chip labels
223
+ (e.g. a read-only FileUpload file list) don't pick up a phantom, un-styled tab stop. */
224
+ overflow-y: visible;
200
225
  white-space: nowrap;
201
226
  }
202
227
 
@@ -204,6 +229,7 @@
204
229
  display: inline-flex;
205
230
  align-items: center;
206
231
  justify-content: center;
232
+ flex-shrink: 0; /* HARDCODE: keep the icon from being squeezed by a long, truncating label */
207
233
  color: var(--chip-icon);
208
234
  width: var(--recursica_ui-kit_components_chip_properties_icon-size);
209
235
  height: var(--recursica_ui-kit_components_chip_properties_icon-size);
@@ -218,6 +244,7 @@
218
244
  display: inline-flex;
219
245
  align-items: center;
220
246
  justify-content: center;
247
+ flex-shrink: 0; /* HARDCODE: keep the remove icon visible when the label truncates instead */
221
248
  color: var(--chip-close);
222
249
  width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
223
250
  height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
@@ -227,7 +254,12 @@
227
254
  }
228
255
 
229
256
  .removeIcon:focus-visible {
230
- box-shadow: 0 0 0 2px var(--chip-border); /* HARDCODE: Focus indication */
257
+ box-shadow:
258
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
259
+ var(--recursica_brand_states_focus_color),
260
+ 0 0 var(--recursica_brand_states_focus_blur)
261
+ var(--recursica_brand_states_focus_margin)
262
+ var(--recursica_brand_states_focus_color);
231
263
  }
232
264
 
233
265
  .removeIcon svg {
@@ -41,6 +41,8 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
41
41
  icon,
42
42
  onRemove,
43
43
  removeLabel = "Remove",
44
+ removeTabIndex,
45
+ removeIconRef,
44
46
  children,
45
47
  overStyled = false,
46
48
  ...rest
@@ -79,18 +81,24 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
79
81
  // Determine state
80
82
  const dataError = error ? "" : undefined;
81
83
  const isIconOnly = !children && (!!icon || !!onRemove);
84
+ // A chip only counts as interactive when something actually responds to it — merely passing a
85
+ // `checked` value (e.g. to pin a display-only chip to a fixed visual state, as FileUpload's
86
+ // read-only file list does) isn't itself an interaction, since clicking it with no onChange/
87
+ // onClick wired does nothing observable.
82
88
  const isInteractive =
83
- restRecord.checked !== undefined ||
84
- restRecord.defaultChecked !== undefined ||
85
89
  onRemove !== undefined ||
86
- restRecord.onClick !== undefined;
90
+ restRecord.onClick !== undefined ||
91
+ restRecord.onChange !== undefined;
87
92
 
88
93
  return (
89
94
  <MantineChip
90
95
  ref={ref}
91
96
  className={finalClass}
92
97
  classNames={mergedClassNames}
93
- wrapperProps={dataError !== undefined ? { "data-error": "" } : undefined}
98
+ wrapperProps={{
99
+ ...(dataError !== undefined ? { "data-error": "" } : {}),
100
+ ...(isInteractive ? { "data-interactive": "" } : {}),
101
+ }}
94
102
  {...(isIconOnly ? { "data-icon-only": "" } : {})}
95
103
  {...(!isInteractive ? { tabIndex: -1, "aria-hidden": true } : {})}
96
104
  {...sanitizedProps}
@@ -106,6 +114,7 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
106
114
 
107
115
  {onRemove && (
108
116
  <span
117
+ ref={removeIconRef}
109
118
  role="button"
110
119
  className={styles.removeIcon}
111
120
  onClick={(e) => {
@@ -123,7 +132,7 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
123
132
  );
124
133
  }
125
134
  }}
126
- tabIndex={0}
135
+ tabIndex={removeTabIndex ?? 0}
127
136
  >
128
137
  <CloseIcon />
129
138
  </span>
@@ -46,3 +46,10 @@ The remove/close action is keyboard accessible — it can be activated via keybo
46
46
  ### Sizing
47
47
 
48
48
  Chip does not support a `size` prop; chips render at a fixed size.
49
+
50
+ ### Building a keyboard-navigable chip group
51
+
52
+ `removeTabIndex` and `removeIconRef` are optional escape hatches for composing a _group_ of chips
53
+ with roving-tabindex keyboard navigation (Tab reaches one chip at a time, arrow keys move between
54
+ them) — see `FileUpload`'s file list for a working example. Ignore both for a standalone chip; they
55
+ default to a normal `tabIndex={0}` remove icon with no ref.