@recursica/mantine-adapter 0.39.0 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +130 -2
- package/dist/mantine-adapter.cjs +2 -2
- package/dist/mantine-adapter.cjs.map +1 -1
- package/dist/mantine-adapter.css +1 -1
- package/dist/mantine-adapter.js +2221 -1998
- package/dist/mantine-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 +59 -0
- package/src/components/Chip/Chip.module.css +36 -4
- package/src/components/Chip/Chip.tsx +14 -5
- package/src/components/Chip/USAGE.md +7 -0
- package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +244 -0
- package/src/components/FileUpload/FileUpload.module.css +204 -42
- package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
- package/src/components/FileUpload/FileUpload.tsx +352 -5
- package/src/components/FileUpload/USAGE.md +163 -5
- package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +10 -0
- package/src/components/TimePicker/TimePicker.tsx +1 -1
package/package.json
CHANGED
|
@@ -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:
|
|
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:
|
|
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={
|
|
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,244 @@
|
|
|
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 Mantine primitive to wrap (Mantine core ships `FileInput`, a styled single-line
|
|
7
|
+
file input, but no drag-and-drop dropzone UI; that's a separate `@mantine/dropzone` package we
|
|
8
|
+
deliberately did not add — see "Why not `@mantine/dropzone`" below). The component composes:
|
|
9
|
+
|
|
10
|
+
1. **The dropzone** — a plain `<div>` handling native HTML5 drag-and-drop events
|
|
11
|
+
(`onDragOver`/`onDrop`), containing an upload icon, instructional text, and a hidden
|
|
12
|
+
`<input type="file">` triggered by...
|
|
13
|
+
2. **The browse button** — this adapter's own `<Button variant="outline" size="small">`.
|
|
14
|
+
3. **The file list** — this adapter's own `<Chip>` component (with `onRemove`), one per entry in
|
|
15
|
+
the controlled `files` prop.
|
|
16
|
+
|
|
17
|
+
This shape is **not configurable** — there is no prop to render a bare native file input without
|
|
18
|
+
the dropzone chrome (that's what the separate, still-stub `FileInput` component is for).
|
|
19
|
+
|
|
20
|
+
## Why not `@mantine/dropzone`
|
|
21
|
+
|
|
22
|
+
Mantine's own drag-and-drop package (`@mantine/dropzone`) would require adding a new peer
|
|
23
|
+
dependency purely for its interaction logic (drag events, accept/reject callbacks) — its visual
|
|
24
|
+
output is fully overridden by our own CSS module regardless, and the interaction logic itself is
|
|
25
|
+
a handful of native `DragEvent`/`<input type="file">` handlers. Implementing it directly with
|
|
26
|
+
native DOM APIs keeps this component's behavior identical across the Mantine and MUI adapters
|
|
27
|
+
(neither ships a drag-and-drop dropzone natively), matching the "one API, any kit" goal in the
|
|
28
|
+
canonical `COMPONENT_DEV_GUIDE.md` more literally than either library's own package would. (Matt
|
|
29
|
+
Massey, 2026-08-11.)
|
|
30
|
+
|
|
31
|
+
## Why `Chip` for the file list, not a bespoke tag element
|
|
32
|
+
|
|
33
|
+
`recursica_variables_scoped.css`'s `file-upload` token namespace defines `item-gap`/`list-spacing`
|
|
34
|
+
(spacing between/around file entries) but **no color/border tokens of its own for the file
|
|
35
|
+
entries themselves** — the visual design intentionally delegates that to the existing `Chip`
|
|
36
|
+
component's own token namespace (`--recursica_ui-kit_components_chip_...`). Reusing `<Chip
|
|
37
|
+
onRemove={...}>` directly (rather than reimplementing a similar-looking element) keeps that
|
|
38
|
+
separation intact per the canonical guide's "Component Specificity" rule — `FileUpload.module.css`
|
|
39
|
+
never reaches into `chip`'s namespace, and `Chip.module.css` never reaches into `file-upload`'s.
|
|
40
|
+
|
|
41
|
+
`checked={false}` is passed explicitly (with no `onChange`) to keep each chip a static, always-
|
|
42
|
+
unchecked removable tag — `Chip` is fundamentally a Mantine `Chip` (checkbox/radio-style toggle)
|
|
43
|
+
under the hood, and a file name isn't something a user should be able to "check"; controlling
|
|
44
|
+
`checked` prevents clicking the label text from toggling Chip's own selected-state styling.
|
|
45
|
+
|
|
46
|
+
## `border-style` and the malformed Figma token
|
|
47
|
+
|
|
48
|
+
`--recursica_ui-kit_components_file-upload_properties_border-style` exists in
|
|
49
|
+
`recursica_variables_scoped.css`, but its value is the **literal string** `"dashed"` (quotes
|
|
50
|
+
included) — not a valid CSS `<line-style>` keyword. Using it via `var(...)` would silently resolve
|
|
51
|
+
to an invalid declaration (the browser drops it, and the border falls back to `initial`/`none`,
|
|
52
|
+
with no error surfaced anywhere). This is consistent with how `border-style` is already treated
|
|
53
|
+
everywhere else in this adapter (e.g. `TimePicker`/`DatePicker` both hardcode `border-style:
|
|
54
|
+
solid`) — the canonical guide explicitly lists `border-style` as a baseline/hardcoded structural
|
|
55
|
+
value, not a tokenized one. Hardcoded to the keyword `dashed` directly; the malformed token is
|
|
56
|
+
`recursica-ignore`d with a note in `FileUpload.module.css`. Worth flagging to design/Forge if the
|
|
57
|
+
token is meant to be consumable as-is in a future export.
|
|
58
|
+
|
|
59
|
+
## No dedicated icon-size token
|
|
60
|
+
|
|
61
|
+
Unlike `TextField`/`DatePicker`, `file-upload` has no `properties_icon-size` token. The upload
|
|
62
|
+
icon is sized with a hardcoded `2rem` to visually match the Figma reference image
|
|
63
|
+
(`packages/adapter-common/src/components/FileUpload/fileupload.png`); revisit if a dedicated token
|
|
64
|
+
is ever added.
|
|
65
|
+
|
|
66
|
+
## Dragging-over visual state (Matt Massey, 2026-08-17)
|
|
67
|
+
|
|
68
|
+
Previously flagged as a known gap ("no distinct dragging-over visual state" — there's still no
|
|
69
|
+
`file-upload`-specific token for it), now implemented using two existing generic tokens instead of
|
|
70
|
+
inventing new component-scoped ones:
|
|
71
|
+
|
|
72
|
+
- **Background**: the same "Global Hover Hack Overlay" recipe `Button.module.css` already uses —
|
|
73
|
+
a `::after` pseudo-element sized to `inset: 0`, `background-color:
|
|
74
|
+
var(--recursica_brand_states_hover_color)`, faded in to `var(--recursica_brand_states_hover_opacity)`
|
|
75
|
+
via `[data-dragging="true"]` instead of `:hover`. Requires `.dropzone` to be `position: relative`
|
|
76
|
+
and its real children `position: relative; z-index: 1` so they render above the overlay.
|
|
77
|
+
- **Border**: `--form-field-border-selected`, the bridge custom property `FormControlWrapper`
|
|
78
|
+
already sets from `--recursica_ui-kit_globals_form_field_colors_border-selected` on its own
|
|
79
|
+
`.root` (an ancestor of `.dropzone` here) — defined for exactly this kind of cross-component
|
|
80
|
+
reuse, but unused anywhere until now. Chosen over reaching for the global token directly, since
|
|
81
|
+
the existing bridge-var pattern is how other component CSS in this adapter already consumes
|
|
82
|
+
globals it doesn't own (see `FormControlWrapper.module.css`).
|
|
83
|
+
|
|
84
|
+
Tracked via a `dragCounterRef` in `FileUpload.tsx`, not a plain boolean: `dragenter`/`dragleave`
|
|
85
|
+
fire for every child element the pointer crosses, not just the dropzone itself, so a plain
|
|
86
|
+
enter-sets-true/leave-sets-false toggle flickers off every time the pointer passes over the icon,
|
|
87
|
+
label, or button inside the dropzone. Counting nested enter/leave pairs and only clearing the
|
|
88
|
+
state at net-zero avoids that.
|
|
89
|
+
|
|
90
|
+
## Read-only mode (Matt Massey, 2026-08-18)
|
|
91
|
+
|
|
92
|
+
Previously flagged as a known gap ("no read-only mode" — see below). Unlike `TextField`/
|
|
93
|
+
`DatePicker`, there's no single "value" to swap for static text via `WithReadOnlyWrapper` — a file
|
|
94
|
+
list's read-only form is the same chip list, just without the ability to remove anything. So
|
|
95
|
+
`readOnly` is handled directly in `FileUpload.tsx` rather than reusing `WithReadOnlyWrapper`:
|
|
96
|
+
|
|
97
|
+
- The dropzone (icon, instructional text, Browse button, hidden `<input>`) is omitted entirely.
|
|
98
|
+
- Each `Chip` is rendered with no `onRemove` (and no `removeTabIndex`/`removeIconRef`/roving
|
|
99
|
+
keyboard handlers, which only exist to manage the remove icon) — `Chip` itself already renders no
|
|
100
|
+
remove icon at all when `onRemove` is `undefined`, so this falls out for free rather than needing
|
|
101
|
+
a separate `readOnly` prop on `Chip`.
|
|
102
|
+
- `disabled` is independent of `readOnly` and has no effect when `readOnly` is set (there's no
|
|
103
|
+
dropzone/remove icon left for it to disable).
|
|
104
|
+
|
|
105
|
+
Original gap this replaces: Figma's `fileupload.png` reference only depicted the interactive
|
|
106
|
+
dropzone and an empty state, no distinct read-only rendering — revisited once one was specified.
|
|
107
|
+
|
|
108
|
+
## Assistive text/error delegated back to FormControlWrapper (Matt Massey, 2026-08-18)
|
|
109
|
+
|
|
110
|
+
Briefly changed to have `FileUpload` render its own `AssistiveElement` directly (dropzone →
|
|
111
|
+
assistive/error → file list, all inside the one `children` slot `FormControlWrapper` sees) so the
|
|
112
|
+
file list could sit _below_ the assistive text instead of `FormControlWrapper`'s default of
|
|
113
|
+
rendering assistive/error _after_ whatever `children` it's given. Reverted at Matt's request:
|
|
114
|
+
`assistiveText`/`error` are passed straight through to `FormControlWrapper` again (file list
|
|
115
|
+
renders above the assistive/error text, same as every other form control), and `FileUpload` no
|
|
116
|
+
longer manages its own `aria-describedby`/`aria-errormessage` wiring — `FormControlWrapper`'s own
|
|
117
|
+
`cloneElement` handles that automatically since `FileUpload`'s root `<div>` is a single element.
|
|
118
|
+
|
|
119
|
+
## Built-in error for `accept` mismatches (Matt Massey, 2026-08-18)
|
|
120
|
+
|
|
121
|
+
A file rejected for not matching `accept` (see below) now surfaces as the control's own error
|
|
122
|
+
state by default, rather than leaving the integrator to wire up `onFilesRejected` into their own
|
|
123
|
+
`error` prop just to get a message on screen. `handleFiles` tracks whether the _most recent_
|
|
124
|
+
drop/pick attempt included an `accept` mismatch (`invalidTypeRejected` state) and `FileUpload`
|
|
125
|
+
computes `effectiveError = error ?? (invalidTypeRejected ? invalidFileTypeMessage : undefined)` —
|
|
126
|
+
an explicit `error` prop always wins over the built-in one. The message itself is a new
|
|
127
|
+
`invalidFileTypeMessage` prop (`RecursicaFileUploadProps`, adapter-common), defaulting to
|
|
128
|
+
`"File type not accepted"`. Scoped to `accept` mismatches only — a `maxSize` rejection still has no
|
|
129
|
+
built-in message, since `onFilesRejected` is the only signal for that case and there's no single
|
|
130
|
+
reasonable default (unlike the type-mismatch text, "too large" needs the actual limit in it).
|
|
131
|
+
|
|
132
|
+
## `accept` is now enforced on drop, not just the picker dialog (Matt Massey, 2026-08-18)
|
|
133
|
+
|
|
134
|
+
The native `accept` attribute on the hidden `<input type="file">` only constrains the browser's own
|
|
135
|
+
file-picker dialog — it has **no effect on a `drop` event**, so a file dragged directly onto the
|
|
136
|
+
dropzone previously bypassed `accept` entirely regardless of extension/MIME type (the prior doc
|
|
137
|
+
comment on `RecursicaFileUploadProps.accept` claimed otherwise; that was wrong and has been
|
|
138
|
+
corrected). `handleFiles` (shared by both the picker and drop paths) now also validates every file
|
|
139
|
+
against `accept` via a new shared `fileMatchesAccept(file, accept)` util in `adapter-common`
|
|
140
|
+
(mirrors the native attribute's own comma-separated extension/MIME/MIME-wildcard semantics) and
|
|
141
|
+
routes non-matching files to `onFilesRejected`, the same callback already used for `maxSize`
|
|
142
|
+
rejections.
|
|
143
|
+
|
|
144
|
+
## Not a nested interactive element
|
|
145
|
+
|
|
146
|
+
The dropzone `<div>` itself has no `onClick`/`role="button"`/`tabIndex` — only the explicit
|
|
147
|
+
"Browse files" `<Button>` opens the native file picker. Many dropzone implementations make the
|
|
148
|
+
entire box clickable in addition to drag-and-drop, but the Figma reference shows the button as
|
|
149
|
+
the sole explicit affordance, and avoiding a second, redundant click target inside (or wrapping)
|
|
150
|
+
a real `<button>` avoids any nested-interactive-element accessibility ambiguity. (This is
|
|
151
|
+
independent of the file list's own keyboard navigation below, which lives entirely in the
|
|
152
|
+
separate `Chip` list, not the dropzone.)
|
|
153
|
+
|
|
154
|
+
## Custom upload icon (Matt Massey, 2026-08-17)
|
|
155
|
+
|
|
156
|
+
Added `icon?: React.ReactNode` to `RecursicaFileUploadProps` (adapter-common) — `FileUpload`
|
|
157
|
+
renders `icon ?? <UploadIcon />` inside the same `.uploadIcon` span either way, so a custom icon
|
|
158
|
+
picks up the same `color`/`width`/`height` styling the default one gets (use `currentColor` and
|
|
159
|
+
fill the viewbox, like `UploadIcon` does, for it to inherit correctly).
|
|
160
|
+
|
|
161
|
+
## Browse button size (Matt Massey, 2026-08-17)
|
|
162
|
+
|
|
163
|
+
The "Browse files" `<Button>` was hardcoded to `size="small"`. There's no design rationale for a
|
|
164
|
+
smaller-than-normal button here — the Figma reference simply wasn't checked against `Button`'s own
|
|
165
|
+
size tokens closely enough when this was first built. Removed the `size` prop entirely so it falls
|
|
166
|
+
through to `Button`'s own default (`"default"`, the standard size used everywhere else in both
|
|
167
|
+
adapters).
|
|
168
|
+
|
|
169
|
+
## Keyboard navigation for the file chip list (Matt Massey, 2026-08-17)
|
|
170
|
+
|
|
171
|
+
The file list previously had no group-level keyboard model at all — each chip's remove icon was
|
|
172
|
+
simply the next `tabIndex={0}` element in natural DOM order (and, in the Mantine adapter, so was
|
|
173
|
+
each chip's own hidden checkbox `<input>`, since `Chip`'s `isInteractive` check treats any chip
|
|
174
|
+
with `onRemove` as tabbable-by-default — a second, redundant tab stop specific to this adapter).
|
|
175
|
+
Implemented a standard roving-tabindex pattern instead, matching `Tree`'s existing keyboard model
|
|
176
|
+
(see `../Tree/IMPLEMENTATION_NOTES.md`) rather than inventing a new one:
|
|
177
|
+
|
|
178
|
+
- **Tab reaches exactly one stop per chip list, landing on the first chip.** `FileUpload` tracks
|
|
179
|
+
`activeChipIndex` (initially `0`) and passes `removeTabIndex={index === activeChipIndex ? 0 : -1}`
|
|
180
|
+
to each `Chip` — a new pass-through prop added to `RecursicaChipProps`/both adapters' `Chip.tsx`
|
|
181
|
+
(see `../Chip/CHIP_IMPLEMENTATION_NOTES.md`) that overrides the remove icon's own tabIndex. Every
|
|
182
|
+
chip in the Mantine adapter is also given a plain `tabIndex={-1}` directly (flows through to
|
|
183
|
+
`MantineChip`'s underlying checkbox `<input>` via the existing `sanitizedProps` passthrough) to
|
|
184
|
+
kill that second, unwanted tab stop — the chip's label/checkbox was never meant to be
|
|
185
|
+
interactive here (`checked={false}`, no `onChange`), only its remove icon is.
|
|
186
|
+
- **Enter removes the focused chip, with focus already on its remove icon.** No new code needed —
|
|
187
|
+
`Chip`'s remove icon already calls `onRemove` on `Enter`/`Space`, and it's already the focused
|
|
188
|
+
element by construction (see above), so "focus ring on the remove icon" falls out for free from
|
|
189
|
+
the existing `.removeIcon:focus-visible` style.
|
|
190
|
+
- **Left/Right or Up/Down move focus between chips.** A `onKeyDown` handler on the file list `<div>`
|
|
191
|
+
(event delegation — it fires for keydowns on any focused chip inside it) computes the next index
|
|
192
|
+
(wrapping at both ends) and moves real DOM focus there via `removeIconRefs`, an array of refs
|
|
193
|
+
populated through the new `removeIconRef` prop on `Chip` (same PR as `removeTabIndex`).
|
|
194
|
+
- **Focus survives removal.** Since `files` is a controlled prop `FileUpload` doesn't mutate
|
|
195
|
+
itself, removing a chip doesn't shrink `files` until the consumer's own state update flows back
|
|
196
|
+
down as a new prop — a `useEffect` keyed on `files` detects the length decreasing, clamps
|
|
197
|
+
`activeChipIndex` to the new last-valid index, and re-focuses that chip's remove icon, so focus
|
|
198
|
+
never falls out of the list back to `<body>`.
|
|
199
|
+
|
|
200
|
+
## Maximum file count (Matt Massey, 2026-08-18)
|
|
201
|
+
|
|
202
|
+
Added `maxFiles`/`maxFilesMessage` to `RecursicaFileUploadProps` (adapter-common), enforced the
|
|
203
|
+
same way `maxSize`/`accept` already are: `handleFiles` compares `files.length` (the current count)
|
|
204
|
+
plus how many of the incoming batch have already been provisionally accepted against `maxFiles`,
|
|
205
|
+
and routes anything past the cap to `onFilesRejected` instead of `onFilesAdded`. The built-in
|
|
206
|
+
`maxFilesMessage` ("Maximum of {maxFiles} files allowed") surfaces through the same
|
|
207
|
+
`effectiveError` mechanism `invalidFileTypeMessage` already uses — an explicit `error` prop still
|
|
208
|
+
wins over both, and an `accept` mismatch takes priority over a `maxFiles` one when a single drop
|
|
209
|
+
triggers both.
|
|
210
|
+
|
|
211
|
+
## Read-only chips were still interactive (Matt Massey, 2026-08-18)
|
|
212
|
+
|
|
213
|
+
The `readOnly` file list (added 2026-08-18, see USAGE.md §7) rendered each filename as a
|
|
214
|
+
`<Chip checked={false} tabIndex={-1}>` with no `onRemove`, expecting that to be fully inert. Two
|
|
215
|
+
separate, previously-latent bugs made it look and behave otherwise — both fixed in
|
|
216
|
+
`Chip`/`Chip.module.css`, not `FileUpload` itself, so every consumer of a non-interactive `Chip`
|
|
217
|
+
benefits, not just this one:
|
|
218
|
+
|
|
219
|
+
- **Passing `checked` (even `false`) was misread as "interactive."** `Chip`'s `isInteractive` check
|
|
220
|
+
treated `checked !== undefined` as a signal that the chip was a real toggle, which forced it into
|
|
221
|
+
the tabbable/exposed branch — but a `checked`-controlled chip with no `onChange` can't actually be
|
|
222
|
+
toggled by a click (Mantine's `useUncontrolled` just ignores the click when `value` is externally
|
|
223
|
+
controlled), so that heuristic was measuring the wrong thing. `isInteractive` now only looks at
|
|
224
|
+
whether something actually responds to interaction: `onRemove`, `onClick`, or `onChange`.
|
|
225
|
+
- **A read-only chip's cursor still showed `pointer` on hover.** Mantine's own base styles hardcode
|
|
226
|
+
`cursor: pointer` on the chip label class; our CSS never overrode it, so it always leaked through
|
|
227
|
+
regardless of the chip's actual behavior. `.label.label` now resets it to `cursor: default` and a
|
|
228
|
+
new `data-interactive` attribute (set by `Chip.tsx` from the same `isInteractive` check above)
|
|
229
|
+
re-enables `pointer` only when the chip is genuinely clickable.
|
|
230
|
+
- **The truncating filename text was itself a phantom, un-styled Tab stop.** `.children` truncates
|
|
231
|
+
long filenames via `overflow-x: hidden` + `text-overflow: ellipsis`. Chromium treats any element
|
|
232
|
+
with non-`visible` overflow whose content actually overflows as a scroll container, and makes
|
|
233
|
+
scroll containers focusable-by-default (so a user can arrow-key-scroll them) — even with no
|
|
234
|
+
`tabindex` attribute at all. Any chip with a long enough label, read-only or not, silently picked
|
|
235
|
+
up an extra Tab stop nobody wrote and no CSS styled a focus ring for. Switched to `overflow-x:
|
|
236
|
+
clip`, which clips identically for our purposes (still x-axis only, descenders on the y-axis
|
|
237
|
+
still unaffected — see the 2026-08-18 fix above this one) but doesn't establish a scrollport, so
|
|
238
|
+
it's never a focus candidate.
|
|
239
|
+
|
|
240
|
+
Confirmed via Playwright against a running Storybook (not just DOM inspection): before the fix,
|
|
241
|
+
`Tab` from the "Browse files" button in `WithFiles` landed on the first chip's filename text, THEN
|
|
242
|
+
its remove icon — an extra, invisible tab stop before the intended one. After the fix, `Tab` goes
|
|
243
|
+
directly to the remove icon, and in `ReadOnly`, `Tab` skips the file list entirely (there's nothing
|
|
244
|
+
in it to focus).
|