@recursica/mui-adapter 0.22.0 → 0.24.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 +36 -0
- package/dist/index.d.ts +277 -11
- package/dist/mui-adapter.cjs +80 -80
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +8709 -8222
- 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/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +148 -0
- package/src/components/FileInput/FileInput.module.css +268 -43
- package/src/components/FileInput/FileInput.stories.tsx +296 -4
- package/src/components/FileInput/FileInput.tsx +406 -6
- package/src/components/FileInput/USAGE.md +112 -4
- 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 +6 -2
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,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).
|