@recursica/mantine-adapter 0.39.0 → 0.41.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.
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.39.0",
16
+ "version": "0.41.0",
17
17
  "type": "module",
18
18
  "main": "./dist/mantine-adapter.cjs",
19
19
  "module": "./dist/mantine-adapter.js",
@@ -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.
@@ -0,0 +1,148 @@
1
+ # FileInput Implementation Notes
2
+
3
+ ## Architecture overview
4
+
5
+ `FileInput` replaces the previous "coming soon" stub with a fully custom composite, the same way
6
+ `FileUpload` was built (see `../FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md`). It shares
7
+ `FileUpload`'s selection/validation contract (`accept`/`maxSize`/`maxFiles`, `onFilesRejected`,
8
+ `readOnly`) via a common `RecursicaFileUploadItem` type and near-identical `handleFiles`/drag
9
+ logic, but is presented as a single-line, `TextField`-shaped control rather than a dropzone —
10
+ Forge's own `file-input` token set is shaped almost identically to `text-field`'s (border-radius,
11
+ horizontal/vertical padding, icon-size, icon-text-gap, min-height, placeholder-opacity, a full
12
+ `text_*` type-style block), confirming the design intent directly rather than needing to guess it
13
+ from the reference screenshot alone.
14
+
15
+ The control is one clickable, focusable `<div role="button">` containing:
16
+
17
+ 1. **A leading icon** — the upload icon by default, overridable via `icon`.
18
+ 2. **A content area** — placeholder text when empty, or a horizontally scrollable row of `Chip`s
19
+ (reusing `FileUpload`'s chip, same as its file list) once one or more files are selected —
20
+ single- and multiple-file mode render identically here; only the selection/replace behavior
21
+ differs (see "`multiple` defaults to `false`" below).
22
+ 3. **A trailing "clear" `Button`** — shown whenever a file is selected; clears the entire current
23
+ selection (see "Trailing clear button" below).
24
+ 4. **A hidden `<input type="file">`**, triggered programmatically — same approach as
25
+ `FileUpload`'s dropzone, not the "invisible overlay input" pattern some styled file inputs use
26
+ (that pattern would need every interactive child — chip remove icons, the clear icon — to sit
27
+ at a higher stacking context than the overlay just to receive its own clicks; keeping the input
28
+ hidden and driving it via `ref.click()` avoids that entirely).
29
+
30
+ ## The stub's exemption comments described a schema that no longer exists
31
+
32
+ Before writing any CSS, the stub `FileInput.module.css`'s 43 `recursica-ignore` comments were
33
+ checked against `packages/official-release/recursica_variables_scoped.css` directly, rather than
34
+ trusted as the token inventory. They didn't match — the stub encoded a `states.default`/
35
+ `states.focus`-shaped schema (mirroring the same stale-plugin-export drift documented in
36
+ `docs/alignment/ALIGNMENT_2026-08-11.md` §3.2 for other components) that the current export has
37
+ never had for `file-input`. The real schema (40 variables, matching that report's own L5 count)
38
+ has:
39
+
40
+ - **Flat, non-state-scoped base properties and colors** (`properties_border-size`,
41
+ `properties_colors_{background-color,border-color,leading-icon,text-color,trailing-icon}`) —
42
+ much closer to `TextField`'s shape than `FileUpload`'s.
43
+ - **Only two states — `disabled` and `error`.** No `default` state (the flat properties above
44
+ are the default) and no `focus` state at all.
45
+ - **`layouts.{stacked,side-by-side}` each carrying their own `max-width`/`min-width`/
46
+ `top-bottom-margin`** — three properties per layout, not the single flat `max-width`/
47
+ `min-width` an early draft of this component assumed.
48
+
49
+ Every token actually referenced in `FileInput.module.css` was re-verified against this real list
50
+ (38 applied + 2 exempted = 40, zero gap on either adapter).
51
+
52
+ ## No forge-defined focus state — generic ring, same fallback as TextField
53
+
54
+ Since `file-input` has no `states.focus` axis, `:focus-visible` on the root falls back to the
55
+ generic `--recursica_brand_states_focus_*` box-shadow ring, exactly like `TextField`/`DatePicker`
56
+ already do for the same reason.
57
+
58
+ ## Disabled/error border-size intentionally unused
59
+
60
+ Unlike `FileUpload` (which has no border-size token at all to choose from), `file-input`'s export
61
+ does define real per-state `border-size` variables for `disabled` and `error`. They're
62
+ `recursica-ignore`d anyway and the flat `properties_border-size` is applied uniformly instead —
63
+ same house policy `TextField`/`DatePicker` already follow (a border that changes thickness across
64
+ states shifts the layout), just applied here to real rather than phantom tokens.
65
+
66
+ ## Why the root is a focusable, clickable `<div>` (a deliberate divergence from FileUpload)
67
+
68
+ `FileUpload`'s dropzone deliberately has no `onClick`/`role="button"`/`tabIndex` of its own — the
69
+ separate "Browse files" `<Button>` is its sole click/keyboard affordance, avoiding a
70
+ nested-interactive-element ambiguity (see `FILEUPLOAD_IMPLEMENTATION_NOTES.md`, "Not a nested
71
+ interactive element"). `FileInput` has no separate Browse button in the reference design — the
72
+ box itself plays that role, the same way a real `<input type="file">` is its own focusable,
73
+ clickable, `Enter`/`Space`-activatable control. So it needs to be one directly: `role="button"`,
74
+ `tabIndex={0}` when interactive, `onClick`/`onKeyDown` (`Enter`/`Space`) both call the same
75
+ `openFilePicker`, and an explicit `aria-label` (`browseLabel`, defaulting to `"Choose file"`)
76
+ since there's no native `<label for>` association available for a custom `div` the way a real
77
+ `<input>` gets one implicitly.
78
+
79
+ ## `multiple` defaults to `false`, unlike `FileUpload`'s `true`
80
+
81
+ The reference design shows "Single File" and "Multiple Files" as two distinct, deliberately-named
82
+ usages, and a single-line `TextField`-shaped control reads most naturally as single-value by
83
+ default — matching a native `<input type="file">`, which is single-file unless `multiple` is set.
84
+ `FileUpload`'s dropzone is the opposite by design (it defaults to accepting a batch).
85
+
86
+ ## Single-file mode replaces; it doesn't append
87
+
88
+ `onFilesAdded`/`onFileRemove` keep the exact same shape as `FileUpload`'s, but single-file mode's
89
+ effective cap is hardcoded to 1 (`currentCount` is never read from the existing `files` prop in
90
+ that mode) rather than counting the file already there — picking a new file is meant to replace
91
+ the old one, not be rejected as "over the limit." This is a documentation convention, not special
92
+ component state: see USAGE.md §2 for the "handlers typically replace, not append" note for
93
+ single-file consumers. Dropping more than one file onto a single-file control still routes the
94
+ extras to `onFilesRejected` via the same effective-cap-of-1 logic, with the default
95
+ `maxFilesMessage` reading `"Only one file is allowed"` instead of `FileUpload`'s
96
+ `"Maximum of N files allowed"` phrasing.
97
+
98
+ ## Trailing clear button
99
+
100
+ The clear-all affordance shown whenever `files.length > 0` is a real shared `Button`
101
+ (`variant="text" size="small"`, icon-only), not a bespoke `<span role="button">` — it gets real
102
+ button semantics and keyboard handling for free, the same reasoning as `Tree`'s expand/collapse
103
+ button (see `Tree.module.css`'s `.expandButton`). It's rendered through Button's own tokened
104
+ color/disabled states, so `file-input`'s own `properties_colors_trailing-icon` token (and its
105
+ `disabled`/`error` state variants) is `recursica-ignore`d rather than applied — it has no
106
+ "clear button is disabled" state of its own anyway, since `disabled` already omits interactivity
107
+ entirely. Clicking/activating it calls `onFileRemove` once per currently-selected file (reusing
108
+ the same callback `FileUpload`'s individual chip removal uses, rather than introducing a separate
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.
111
+
112
+ ## Why each chip is wrapped with its own `stopPropagation`
113
+
114
+ `FileUpload`'s chip list sits _below_ its dropzone, entirely outside the click-to-browse surface,
115
+ so a click on a chip's body never risks also opening the file picker. `FileInput`'s chip row lives
116
+ _inside_ the same clickable root, so each chip is wrapped in a thin `<span onClick={(e) =>
117
+ e.stopPropagation()}>` — blank space within the row (and the leading icon/placeholder/filename
118
+ text) still bubbles up to open the picker, but clicking directly on a chip's body doesn't also
119
+ trigger it. Chip's own remove icon already calls `stopPropagation` internally (see `Chip.tsx`), so
120
+ it needed no changes for this.
121
+
122
+ ## Keyboard navigation for the chip row
123
+
124
+ Same roving-tabindex model as `FileUpload`'s file list (`activeChipIndex`, `removeIconRefs`,
125
+ Left/Right/Up/Down roving, focus-survives-removal `useEffect`) — reused rather than reinvented,
126
+ and applies in single-file mode too (a one-chip roving group is a no-op but needs no special
127
+ casing). The only addition specific to `FileInput` is that `Tab` reaches the root control itself
128
+ first (since it's the click/keyboard surface for opening the picker), _then_ the chip row's
129
+ roving group, _then_ the trailing clear button as its own stop.
130
+
131
+ ## The chip row scrolls horizontally instead of wrapping or clipping
132
+
133
+ `.chipRow` uses `overflow-x: auto; overflow-y: hidden` rather than `FileUpload`'s file list, which
134
+ wraps onto multiple lines below the dropzone — `FileInput` is a fixed single-line, `min-height`d
135
+ control, so wrapping would grow it vertically. Enough chips to overflow the control's own width
136
+ scroll horizontally within it instead (mouse wheel/trackpad or a native scrollbar), same tradeoff
137
+ already made for `.value`'s ellipsis truncation.
138
+
139
+ ## Read-only vs disabled
140
+
141
+ `readOnly` renders the same content (placeholder/filename/chip row) but the root loses its
142
+ `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
144
+ read-only chips), and the trailing clear icon is omitted entirely. `disabled` keeps the control
145
+ structurally the same but inert (`tabIndex={-1}`, `aria-disabled`, no handlers, native `<input>`
146
+ disabled) — both are computed together as a single `interactive` flag used throughout, but remain
147
+ independently toggleable props (a `readOnly` control is never simultaneously `disabled` in
148
+ practice, but nothing enforces that at the type level, matching `FileUpload`'s own convention).