@recursica/mui-adapter 0.22.0 → 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.
- package/CHANGELOG.md +23 -0
- package/dist/index.d.ts +157 -6
- package/dist/mui-adapter.cjs +76 -76
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +5885 -5650
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Button/Button.module.css +13 -0
- package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +102 -0
- package/src/components/Chip/Chip.module.css +30 -4
- package/src/components/Chip/Chip.tsx +40 -4
- package/src/components/Chip/USAGE.md +19 -0
- package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +249 -0
- package/src/components/FileUpload/FileUpload.module.css +203 -38
- package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
- package/src/components/FileUpload/FileUpload.tsx +350 -6
- package/src/components/FileUpload/USAGE.md +163 -5
- package/src/index.ts +3 -1
package/package.json
CHANGED
|
@@ -364,6 +364,19 @@
|
|
|
364
364
|
);
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/* Focus State Mapping (native browser focus outline replaced with the recursica focus ring).
|
|
368
|
+
Placed after every variant's own box-shadow (elevation) rule so it wins the cascade at equal
|
|
369
|
+
attribute-selector specificity regardless of variant. */
|
|
370
|
+
.root:focus-visible {
|
|
371
|
+
outline: none;
|
|
372
|
+
box-shadow:
|
|
373
|
+
0 0 0 var(--recursica_brand_states_focus_border-size)
|
|
374
|
+
var(--recursica_brand_states_focus_color),
|
|
375
|
+
0 0 var(--recursica_brand_states_focus_blur)
|
|
376
|
+
var(--recursica_brand_states_focus_margin)
|
|
377
|
+
var(--recursica_brand_states_focus_color);
|
|
378
|
+
}
|
|
379
|
+
|
|
367
380
|
/* EXEMPTIONS:
|
|
368
381
|
These general button properties are redundant because visual mapping has been refactored
|
|
369
382
|
to content-specific variables (_content_label_sizes_... etc.) or are handled by design defaults. */
|
|
@@ -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;
|
|
@@ -209,12 +219,21 @@
|
|
|
209
219
|
align-items: center;
|
|
210
220
|
gap: var(--recursica_ui-kit_components_chip_properties_icon-text-gap);
|
|
211
221
|
width: 100%;
|
|
222
|
+
min-width: 0; /* HARDCODE: lets .children actually shrink and ellipsize instead of overflowing */
|
|
212
223
|
}
|
|
213
224
|
|
|
214
225
|
.children {
|
|
215
226
|
flex-grow: 1;
|
|
227
|
+
min-width: 0; /* HARDCODE: a flex child needs this for text-overflow: ellipsis to engage at all */
|
|
216
228
|
text-overflow: ellipsis;
|
|
217
|
-
overflow:
|
|
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;
|
|
218
237
|
white-space: nowrap;
|
|
219
238
|
}
|
|
220
239
|
|
|
@@ -225,6 +244,7 @@
|
|
|
225
244
|
display: inline-flex;
|
|
226
245
|
align-items: center;
|
|
227
246
|
justify-content: center;
|
|
247
|
+
flex-shrink: 0; /* HARDCODE: keep the icon from being squeezed by a long, truncating label */
|
|
228
248
|
color: var(--chip-icon);
|
|
229
249
|
width: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
230
250
|
height: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
@@ -246,6 +266,7 @@
|
|
|
246
266
|
display: inline-flex;
|
|
247
267
|
align-items: center;
|
|
248
268
|
justify-content: center;
|
|
269
|
+
flex-shrink: 0; /* HARDCODE: keep the remove icon visible when the label truncates instead */
|
|
249
270
|
color: var(--chip-close);
|
|
250
271
|
width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
251
272
|
height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
@@ -257,7 +278,12 @@
|
|
|
257
278
|
}
|
|
258
279
|
|
|
259
280
|
.removeIconWrapper.removeIconWrapper:focus-visible {
|
|
260
|
-
box-shadow:
|
|
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);
|
|
261
287
|
}
|
|
262
288
|
|
|
263
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,6 +121,7 @@ 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 ? (
|
|
@@ -121,7 +137,27 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
|
|
|
121
137
|
onDelete={onRemove}
|
|
122
138
|
deleteIcon={
|
|
123
139
|
onRemove ? (
|
|
124
|
-
<span
|
|
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`).
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# FileUpload Implementation Notes
|
|
2
|
+
|
|
3
|
+
## Architecture Overview
|
|
4
|
+
|
|
5
|
+
`FileUpload` is a fully custom composite — unlike most other adapter components, there is no
|
|
6
|
+
single underlying MUI primitive to wrap (MUI has no drag-and-drop dropzone component at all, in
|
|
7
|
+
core or as a separate package). The component composes:
|
|
8
|
+
|
|
9
|
+
1. **The dropzone** — a plain `<div>` handling native HTML5 drag-and-drop events
|
|
10
|
+
(`onDragOver`/`onDrop`), containing an upload icon, instructional text, and a hidden
|
|
11
|
+
`<input type="file">` triggered by...
|
|
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
|
|
14
|
+
the controlled `files` prop.
|
|
15
|
+
|
|
16
|
+
This shape is **not configurable** — there is no prop to render a bare native file input without
|
|
17
|
+
the dropzone chrome (that's what the separate, still-stub `FileInput` component is for). This
|
|
18
|
+
mirrors `mantine-adapter`'s `FileUpload` exactly — implementing the interaction logic with native
|
|
19
|
+
DOM APIs on both sides (rather than reaching for a Mantine-only package like `@mantine/dropzone`,
|
|
20
|
+
which doesn't have an MUI equivalent anyway) keeps the two adapters at real behavioral parity.
|
|
21
|
+
|
|
22
|
+
## Pre-existing `Chip` type gap fixed here
|
|
23
|
+
|
|
24
|
+
MUI's own `ChipProps.children` is typed as `null | undefined` — MUI's `Chip` expects a `label`
|
|
25
|
+
prop instead of `children`. This adapter's `Chip.tsx`, however, has always treated `children` as
|
|
26
|
+
its real public API (it renders `children` inside its own `label={...}` JSX, which unconditionally
|
|
27
|
+
overrides any caller-supplied `label` — so `label` was never actually usable externally to begin
|
|
28
|
+
with), but its `ChipProps` type never overrode MUI's restrictive `children` type to reflect that.
|
|
29
|
+
This went unnoticed until `FileUpload` needed to pass a file name as `<Chip>{name}</Chip>` and hit
|
|
30
|
+
a real compile error. Fixed by omitting `children` from the inherited `MuiChipProps` and re-adding
|
|
31
|
+
it as `React.ReactNode` in `Chip.tsx`'s own `ChipProps` — a type-only fix, no runtime behavior
|
|
32
|
+
change (the runtime already accepted arbitrary `children`). (Matt Massey, 2026-08-11.)
|
|
33
|
+
|
|
34
|
+
## Why `Chip` for the file list, not a bespoke tag element
|
|
35
|
+
|
|
36
|
+
`recursica_variables_scoped.css`'s `file-upload` token namespace defines `item-gap`/`list-spacing`
|
|
37
|
+
(spacing between/around file entries) but **no color/border tokens of its own for the file
|
|
38
|
+
entries themselves** — the visual design intentionally delegates that to the existing `Chip`
|
|
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
|
|
41
|
+
separation intact per the canonical guide's "Component Specificity" rule — `FileUpload.module.css`
|
|
42
|
+
never reaches into `chip`'s namespace, and `Chip.module.css` never reaches into `file-upload`'s.
|
|
43
|
+
|
|
44
|
+
Unlike the Mantine adapter's `Chip` (a checkbox/radio-style Mantine `Chip` under the hood, where a
|
|
45
|
+
`checked={false}` prop has to be passed to prevent the label text from toggling a selected state),
|
|
46
|
+
MUI's `Chip` has no such native toggle semantics — no `checked` prop is passed here at all.
|
|
47
|
+
|
|
48
|
+
## `border-style` and the malformed Figma token
|
|
49
|
+
|
|
50
|
+
`--recursica_ui-kit_components_file-upload_properties_border-style` exists in
|
|
51
|
+
`recursica_variables_scoped.css`, but its value is the **literal string** `"dashed"` (quotes
|
|
52
|
+
included) — not a valid CSS `<line-style>` keyword. Using it via `var(...)` would silently resolve
|
|
53
|
+
to an invalid declaration (the browser drops it, and the border falls back to `initial`/`none`,
|
|
54
|
+
with no error surfaced anywhere). This is consistent with how `border-style` is already treated
|
|
55
|
+
everywhere else in this adapter (e.g. `TextArea` hardcodes its own baseline border styling) — the
|
|
56
|
+
canonical guide explicitly lists `border-style` as a baseline/hardcoded structural value, not a
|
|
57
|
+
tokenized one. Hardcoded to the keyword `dashed` directly; the malformed token is
|
|
58
|
+
`recursica-ignore`d with a note in `FileUpload.module.css`. Worth flagging to design/Forge if the
|
|
59
|
+
token is meant to be consumable as-is in a future export.
|
|
60
|
+
|
|
61
|
+
## No dedicated icon-size token
|
|
62
|
+
|
|
63
|
+
Unlike `TextField`/`DatePicker`, `file-upload` has no `properties_icon-size` token. The upload
|
|
64
|
+
icon is sized with a hardcoded `2rem` to visually match the Figma reference image
|
|
65
|
+
(`packages/adapter-common/src/components/FileUpload/fileupload.png`); revisit if a dedicated token
|
|
66
|
+
is ever added.
|
|
67
|
+
|
|
68
|
+
## Dragging-over visual state (Matt Massey, 2026-08-17)
|
|
69
|
+
|
|
70
|
+
Previously flagged as a known gap ("no distinct dragging-over visual state" — there's still no
|
|
71
|
+
`file-upload`-specific token for it), now implemented using two existing generic tokens instead of
|
|
72
|
+
inventing new component-scoped ones:
|
|
73
|
+
|
|
74
|
+
- **Background**: the same "Global Hover Hack Overlay" recipe `Button.module.css` already uses —
|
|
75
|
+
a `::after` pseudo-element sized to `inset: 0`, `background-color:
|
|
76
|
+
var(--recursica_brand_states_hover_color)`, faded in to `var(--recursica_brand_states_hover_opacity)`
|
|
77
|
+
via `[data-dragging="true"]` instead of `:hover`. Requires `.dropzone` to be `position: relative`
|
|
78
|
+
and its real children `position: relative; z-index: 1` so they render above the overlay.
|
|
79
|
+
- **Border**: `--form-field-border-selected`, the bridge custom property `FormControlWrapper`
|
|
80
|
+
already sets from `--recursica_ui-kit_globals_form_field_colors_border-selected` on its own
|
|
81
|
+
`.root` (an ancestor of `.dropzone` here) — defined for exactly this kind of cross-component
|
|
82
|
+
reuse, but unused anywhere until now. Chosen over reaching for the global token directly, since
|
|
83
|
+
the existing bridge-var pattern is how other component CSS in this adapter already consumes
|
|
84
|
+
globals it doesn't own (see `FormControlWrapper.module.css`).
|
|
85
|
+
|
|
86
|
+
Tracked via a `dragCounterRef` in `FileUpload.tsx`, not a plain boolean: `dragenter`/`dragleave`
|
|
87
|
+
fire for every child element the pointer crosses, not just the dropzone itself, so a plain
|
|
88
|
+
enter-sets-true/leave-sets-false toggle flickers off every time the pointer passes over the icon,
|
|
89
|
+
label, or button inside the dropzone. Counting nested enter/leave pairs and only clearing the
|
|
90
|
+
state at net-zero avoids that.
|
|
91
|
+
|
|
92
|
+
## No `!important` needed
|
|
93
|
+
|
|
94
|
+
Unlike form controls that override MUI's own native input styling (e.g. `TextArea.module.css`
|
|
95
|
+
needs `!important` to beat MUI's own `.Mui-error`/`.Mui-disabled` state classes), `FileUpload`'s
|
|
96
|
+
dropzone and file list are plain custom `<div>`s with no MUI component involved beyond the
|
|
97
|
+
separately-styled `Button`/`Chip` — there's no competing MUI baseline to beat, so the error/
|
|
98
|
+
disabled state cascade here uses plain selectors with no `!important`.
|
|
99
|
+
|
|
100
|
+
## Read-only mode (Matt Massey, 2026-08-18)
|
|
101
|
+
|
|
102
|
+
Previously flagged as a known gap ("no read-only mode" — see below). Unlike `TextArea`/
|
|
103
|
+
`NumberInput`, there's no single "value" to swap for static text via `WithReadOnlyWrapper` — a file
|
|
104
|
+
list's read-only form is the same chip list, just without the ability to remove anything. So
|
|
105
|
+
`readOnly` is handled directly in `FileUpload.tsx` rather than reusing `WithReadOnlyWrapper`:
|
|
106
|
+
|
|
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
|
|
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
|
|
111
|
+
a separate `readOnly` prop on `Chip`. (It also means MUI's own `ButtonBase`-on-`onDelete` quirk —
|
|
112
|
+
see the keyboard-navigation section below — never triggers in read-only mode either.)
|
|
113
|
+
- `disabled` is independent of `readOnly` and has no effect when `readOnly` is set (there's no
|
|
114
|
+
dropzone/remove icon left for it to disable).
|
|
115
|
+
|
|
116
|
+
Original gap this replaces: Figma's `fileupload.png` reference only depicted the interactive
|
|
117
|
+
dropzone and an empty state, no distinct read-only rendering — revisited once one was specified.
|
|
118
|
+
|
|
119
|
+
## Assistive text/error delegated back to FormControlWrapper (Matt Massey, 2026-08-18)
|
|
120
|
+
|
|
121
|
+
Briefly changed to have `FileUpload` render its own `AssistiveElement` directly (dropzone →
|
|
122
|
+
assistive/error → file list, all inside the one `children` slot `FormControlWrapper` sees) so the
|
|
123
|
+
file list could sit _below_ the assistive text instead of `FormControlWrapper`'s default of
|
|
124
|
+
rendering assistive/error _after_ whatever `children` it's given. Reverted at Matt's request:
|
|
125
|
+
`assistiveText`/`error` are passed straight through to `FormControlWrapper` again (file list
|
|
126
|
+
renders above the assistive/error text, same as every other form control), and `FileUpload` no
|
|
127
|
+
longer manages its own `aria-describedby`/`aria-errormessage` wiring — `FormControlWrapper`'s own
|
|
128
|
+
`cloneElement` handles that automatically since `FileUpload`'s root `<div>` is a single element.
|
|
129
|
+
|
|
130
|
+
## Built-in error for `accept` mismatches (Matt Massey, 2026-08-18)
|
|
131
|
+
|
|
132
|
+
A file rejected for not matching `accept` (see below) now surfaces as the control's own error
|
|
133
|
+
state by default, rather than leaving the integrator to wire up `onFilesRejected` into their own
|
|
134
|
+
`error` prop just to get a message on screen. `handleFiles` tracks whether the _most recent_
|
|
135
|
+
drop/pick attempt included an `accept` mismatch (`invalidTypeRejected` state) and `FileUpload`
|
|
136
|
+
computes `effectiveError = error ?? (invalidTypeRejected ? invalidFileTypeMessage : undefined)` —
|
|
137
|
+
an explicit `error` prop always wins over the built-in one. The message itself is a new
|
|
138
|
+
`invalidFileTypeMessage` prop (`RecursicaFileUploadProps`, adapter-common), defaulting to
|
|
139
|
+
`"File type not accepted"`. Scoped to `accept` mismatches only — a `maxSize` rejection still has no
|
|
140
|
+
built-in message, since `onFilesRejected` is the only signal for that case and there's no single
|
|
141
|
+
reasonable default (unlike the type-mismatch text, "too large" needs the actual limit in it).
|
|
142
|
+
|
|
143
|
+
## `accept` is now enforced on drop, not just the picker dialog (Matt Massey, 2026-08-18)
|
|
144
|
+
|
|
145
|
+
The native `accept` attribute on the hidden `<input type="file">` only constrains the browser's own
|
|
146
|
+
file-picker dialog — it has **no effect on a `drop` event**, so a file dragged directly onto the
|
|
147
|
+
dropzone previously bypassed `accept` entirely regardless of extension/MIME type (the prior doc
|
|
148
|
+
comment on `RecursicaFileUploadProps.accept` claimed otherwise; that was wrong and has been
|
|
149
|
+
corrected). `handleFiles` (shared by both the picker and drop paths) now also validates every file
|
|
150
|
+
against `accept` via a new shared `fileMatchesAccept(file, accept)` util in `adapter-common`
|
|
151
|
+
(mirrors the native attribute's own comma-separated extension/MIME/MIME-wildcard semantics) and
|
|
152
|
+
routes non-matching files to `onFilesRejected`, the same callback already used for `maxSize`
|
|
153
|
+
rejections.
|
|
154
|
+
|
|
155
|
+
## Not a nested interactive element
|
|
156
|
+
|
|
157
|
+
The dropzone `<div>` itself has no `onClick`/`role="button"`/`tabIndex` — only the explicit
|
|
158
|
+
"Browse files" `<Button>` opens the native file picker. Many dropzone implementations make the
|
|
159
|
+
entire box clickable in addition to drag-and-drop, but the Figma reference shows the button as
|
|
160
|
+
the sole explicit affordance, and avoiding a second, redundant click target inside (or wrapping)
|
|
161
|
+
a real `<button>` avoids any nested-interactive-element accessibility ambiguity. (This is
|
|
162
|
+
independent of the file list's own keyboard navigation below, which lives entirely in the
|
|
163
|
+
separate `Chip` list, not the dropzone.)
|
|
164
|
+
|
|
165
|
+
## Custom upload icon (Matt Massey, 2026-08-17)
|
|
166
|
+
|
|
167
|
+
Added `icon?: React.ReactNode` to `RecursicaFileUploadProps` (adapter-common) — `FileUpload`
|
|
168
|
+
renders `icon ?? <UploadIcon />` inside the same `.uploadIcon` span either way, so a custom icon
|
|
169
|
+
picks up the same `color`/`width`/`height` styling the default one gets (use `currentColor` and
|
|
170
|
+
fill the viewbox, like `UploadIcon` does, for it to inherit correctly).
|
|
171
|
+
|
|
172
|
+
## Browse button size (Matt Massey, 2026-08-17)
|
|
173
|
+
|
|
174
|
+
The "Browse files" `<Button>` was hardcoded to `size="small"`. There's no design rationale for a
|
|
175
|
+
smaller-than-normal button here — the Figma reference simply wasn't checked against `Button`'s own
|
|
176
|
+
size tokens closely enough when this was first built. Removed the `size` prop entirely so it falls
|
|
177
|
+
through to `Button`'s own default (`"default"`, the standard size used everywhere else in both
|
|
178
|
+
adapters).
|
|
179
|
+
|
|
180
|
+
## Keyboard navigation for the file chip list (Matt Massey, 2026-08-17)
|
|
181
|
+
|
|
182
|
+
The file list previously had no group-level keyboard model at all — each chip's remove icon was
|
|
183
|
+
simply the next `tabIndex={0}` element in natural DOM order. Implemented a standard roving-tabindex
|
|
184
|
+
pattern instead, matching `Tree`'s existing keyboard model (see `../Tree/IMPLEMENTATION_NOTES.md`)
|
|
185
|
+
rather than inventing a new one:
|
|
186
|
+
|
|
187
|
+
- **Tab reaches exactly one stop per chip list, landing on the first chip.** `FileUpload` tracks
|
|
188
|
+
`activeChipIndex` (initially `0`) and passes `removeTabIndex={index === activeChipIndex ? 0 : -1}`
|
|
189
|
+
to each `Chip` — a new pass-through prop added to `RecursicaChipProps`/both adapters' `Chip.tsx`
|
|
190
|
+
(see `../Chip/CHIP_IMPLEMENTATION_NOTES.md`) that overrides the remove icon's own tabIndex.
|
|
191
|
+
Every `<Chip>` here is also given a plain `tabIndex={-1}` directly (flows through to `MuiChip`
|
|
192
|
+
via the existing `sanitizedProps` passthrough) — MUI's `Chip` silently renders its root as a
|
|
193
|
+
focusable `ButtonBase` (not a plain `<div>`) whenever `onDelete` is set, _even with no `onClick`_
|
|
194
|
+
(see `component = clickable || onDelete ? ButtonBase : ...` in MUI's own `Chip.js`), so without
|
|
195
|
+
this override the root would be a second, unwanted tab stop ahead of the remove icon on every
|
|
196
|
+
chip — the same class of bug as the Mantine adapter's checkbox `<input>`, just from a different
|
|
197
|
+
MUI internal.
|
|
198
|
+
- **Enter removes the focused chip, with focus already on its remove icon.** No new code needed —
|
|
199
|
+
`Chip`'s `onDelete`/`deleteIcon` wiring already responds to activation on the focused delete
|
|
200
|
+
icon, which is already the focused element by construction (see above), so "focus ring on the
|
|
201
|
+
remove icon" falls out for free from the existing `.removeIconWrapper:focus-visible` style.
|
|
202
|
+
- **Left/Right or Up/Down move focus between chips.** A `onKeyDown` handler on the file list `<div>`
|
|
203
|
+
(event delegation — it fires for keydowns on any focused chip inside it) computes the next index
|
|
204
|
+
(wrapping at both ends) and moves real DOM focus there via `removeIconRefs`, an array of refs
|
|
205
|
+
populated through the new `removeIconRef` prop on `Chip` (same PR as `removeTabIndex`) — set
|
|
206
|
+
directly on the `<span>` passed as `deleteIcon`, which MUI's `Chip` preserves when it clones that
|
|
207
|
+
element.
|
|
208
|
+
- **Focus survives removal.** Since `files` is a controlled prop `FileUpload` doesn't mutate
|
|
209
|
+
itself, removing a chip doesn't shrink `files` until the consumer's own state update flows back
|
|
210
|
+
down as a new prop — a `useEffect` keyed on `files` detects the length decreasing, clamps
|
|
211
|
+
`activeChipIndex` to the new last-valid index, and re-focuses that chip's remove icon, so focus
|
|
212
|
+
never falls out of the list back to `<body>`.
|
|
213
|
+
|
|
214
|
+
## Maximum file count (Matt Massey, 2026-08-18)
|
|
215
|
+
|
|
216
|
+
Added `maxFiles`/`maxFilesMessage` to `RecursicaFileUploadProps` (adapter-common), enforced the
|
|
217
|
+
same way `maxSize`/`accept` already are: `handleFiles` compares `files.length` (the current count)
|
|
218
|
+
plus how many of the incoming batch have already been provisionally accepted against `maxFiles`,
|
|
219
|
+
and routes anything past the cap to `onFilesRejected` instead of `onFilesAdded`. The built-in
|
|
220
|
+
`maxFilesMessage` ("Maximum of {maxFiles} files allowed") surfaces through the same
|
|
221
|
+
`effectiveError` mechanism `invalidFileTypeMessage` already uses — an explicit `error` prop still
|
|
222
|
+
wins over both, and an `accept` mismatch takes priority over a `maxFiles` one when a single drop
|
|
223
|
+
triggers both.
|
|
224
|
+
|
|
225
|
+
## Read-only chips were still interactive (Matt Massey, 2026-08-18)
|
|
226
|
+
|
|
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
|
|
229
|
+
a bug shared with the Mantine adapter that lives entirely in `Chip`/`Chip.module.css`, not
|
|
230
|
+
`FileUpload` — see the Mantine adapter's `FILEUPLOAD_IMPLEMENTATION_NOTES.md` for the parallel
|
|
231
|
+
write-up. The MUI-specific pieces:
|
|
232
|
+
|
|
233
|
+
- **The chip's cursor still showed `pointer` on hover with no real interaction wired.** `.root.root`
|
|
234
|
+
hardcoded `cursor: pointer` unconditionally — unlike Mantine, this wasn't inherited from MUI's own
|
|
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
|
|
237
|
+
no `checked`-driven native-input case to misread, unlike Mantine's) and a `data-interactive`
|
|
238
|
+
attribute that gates `cursor: pointer` in CSS; a chip with none of those handlers now falls back
|
|
239
|
+
to whatever MUI's own un-clickable `Chip` renders as (no cursor override, no `ButtonBase`).
|
|
240
|
+
- **The truncating filename text was itself a phantom, un-styled Tab stop.** Same root cause as the
|
|
241
|
+
Mantine adapter: `.children` truncates via `overflow-x: hidden`, which Chromium treats as a
|
|
242
|
+
focusable scroll container whenever its content actually overflows — regardless of any
|
|
243
|
+
`tabindex`. Switched to `overflow-x: clip` (same visual result, no scrollport, so it's never a
|
|
244
|
+
focus candidate).
|
|
245
|
+
|
|
246
|
+
Confirmed via Playwright against a running Storybook: before the fix, `Tab` from the "Browse files"
|
|
247
|
+
button in `WithFiles` landed on the first chip's filename text, THEN its remove icon — an extra,
|
|
248
|
+
invisible tab stop before the intended one. After the fix, `Tab` goes directly to the remove icon,
|
|
249
|
+
and in `ReadOnly`, `Tab` skips the file list entirely (there's nothing in it to focus).
|