@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.
@@ -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).
@@ -1,40 +1,205 @@
1
1
  /* EXEMPTIONS:
2
- This component is a placeholder stub. Once fully implemented, its specific
3
- tokens will be fully wired up to direct styles. */
4
-
5
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_border-radius */
2
+ - border-style has no usable var(...) form: the Figma-exported token's value is the literal
3
+ string `"dashed"` (quotes included), which is not a valid CSS <line-style> keyword — the same
4
+ class of "structural, not tokenized" property the canonical guide already treats border-style
5
+ as (see .root's own HARDCODED VALUES note below and TimePicker/DatePicker's identical
6
+ hardcoded `border-style: solid`). Hardcoded to the keyword `dashed` instead.
7
+ - properties_icon-size has no equivalent: unlike TextField/DatePicker, file-upload has no
8
+ dedicated icon-size token. Sized to visually match the Figma reference instead (see
9
+ HARDCODED VALUES below). */
6
10
  /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_border-style */
7
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_item-gap */
8
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_list-spacing */
9
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_padding */
10
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_font-family */
11
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_font-size */
12
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_font-style */
13
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_font-weight */
14
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_letter-spacing */
15
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_line-height */
16
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_text-decoration */
17
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_text_text-transform */
18
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_properties_vertical-element-gap */
19
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_layouts_side-by-side_properties_top-bottom-margin */
20
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_layouts_stacked_properties_top-bottom-margin */
21
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_default_properties_border-size */
22
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_default_properties_colors_background */
23
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_default_properties_colors_border-color */
24
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_default_properties_colors_text */
25
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_default_properties_colors_upload-icon */
26
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_border-size */
27
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_background */
28
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_border-color */
29
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_text */
30
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_upload-icon */
31
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_error_properties_border-size */
32
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_background */
33
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_border-color */
34
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_text */
35
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_upload-icon */
36
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_focus_properties_border-size */
37
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_focus_properties_colors_background */
38
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_focus_properties_colors_border-color */
39
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_focus_properties_colors_text */
40
- /* recursica-ignore: --recursica_ui-kit_components_file-upload_variants_states_focus_properties_colors_upload-icon */
11
+
12
+ /* HARDCODED VALUES:
13
+ - .root: display: flex; flex-direction: column; width: 100% — structural layout, not a design
14
+ token concern (matches how other block-level components like Layer are laid out).
15
+ - .dropzone: border-style: dashed — see EXEMPTIONS above.
16
+ - .uploadIcon: width/height: 2rem — no icon-size token exists for file-upload (unlike
17
+ TextField/DatePicker); sized to visually match the Figma reference. Revisit if a dedicated
18
+ token is ever added. */
19
+
20
+ /* LAYOUT SPACING OVERRIDES:
21
+ - Sets the --form-control-margin-bottom spacing hook to map component-specific layout tokens. */
22
+ .layoutOverride {
23
+ --form-control-margin-bottom: var(
24
+ --recursica_ui-kit_components_file-upload_variants_layouts_stacked_properties_top-bottom-margin
25
+ );
26
+ }
27
+
28
+ .layoutOverride[data-form-layout="side-by-side"] {
29
+ --form-control-margin-bottom: var(
30
+ --recursica_ui-kit_components_file-upload_variants_layouts_side-by-side_properties_top-bottom-margin
31
+ );
32
+ }
33
+
34
+ .root {
35
+ display: flex;
36
+ flex-direction: column;
37
+ width: 100%;
38
+ gap: var(--recursica_ui-kit_components_file-upload_properties_list-spacing);
39
+ }
40
+
41
+ .dropzone {
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ position: relative; /* HARDCODE: anchors the drag-over overlay below */
47
+ box-sizing: border-box;
48
+ width: 100%;
49
+ border-style: dashed; /* HARDCODE: see EXEMPTIONS above */
50
+ border-width: var(
51
+ --recursica_ui-kit_components_file-upload_properties_border-size
52
+ );
53
+ border-radius: var(
54
+ --recursica_ui-kit_components_file-upload_properties_border-radius
55
+ );
56
+ border-color: var(
57
+ --recursica_ui-kit_components_file-upload_properties_colors_border-color
58
+ );
59
+ background-color: var(
60
+ --recursica_ui-kit_components_file-upload_properties_colors_background-color
61
+ );
62
+ padding: var(--recursica_ui-kit_components_file-upload_properties_padding);
63
+ gap: var(
64
+ --recursica_ui-kit_components_file-upload_properties_vertical-element-gap
65
+ );
66
+ }
67
+
68
+ /* Dragging-over state: reuses the generic hover overlay recipe (see Button.module.css) for the
69
+ background, and the shared "selected" border bridge var FormControlWrapper already exposes
70
+ (see FormControlWrapper.module.css) for the border — file-upload has no color tokens of its own
71
+ for this state. See "Dragging-over visual state" in FILEUPLOAD_IMPLEMENTATION_NOTES.md. */
72
+ .dropzone > * {
73
+ position: relative;
74
+ z-index: 1;
75
+ }
76
+ .dropzone::after {
77
+ content: "";
78
+ position: absolute;
79
+ inset: 0;
80
+ border-radius: inherit;
81
+ z-index: 0;
82
+ pointer-events: none;
83
+ transition: opacity 150ms ease;
84
+ opacity: 0;
85
+ background-color: var(--recursica_brand_states_hover_color);
86
+ }
87
+ .dropzone[data-dragging="true"] {
88
+ border-color: var(--form-field-border-selected);
89
+ }
90
+ .dropzone[data-dragging="true"]::after {
91
+ opacity: var(--recursica_brand_states_hover_opacity);
92
+ }
93
+
94
+ .uploadIcon {
95
+ display: flex;
96
+ width: 2rem; /* HARDCODE: see EXEMPTIONS above */
97
+ height: 2rem; /* HARDCODE: see EXEMPTIONS above */
98
+ color: var(
99
+ --recursica_ui-kit_components_file-upload_properties_colors_upload-icon
100
+ );
101
+ }
102
+
103
+ .uploadIcon svg {
104
+ width: 100%;
105
+ height: 100%;
106
+ }
107
+
108
+ .dropzoneText {
109
+ font-family: var(
110
+ --recursica_ui-kit_components_file-upload_properties_text_font-family
111
+ );
112
+ font-size: var(
113
+ --recursica_ui-kit_components_file-upload_properties_text_font-size
114
+ );
115
+ font-style: var(
116
+ --recursica_ui-kit_components_file-upload_properties_text_font-style
117
+ );
118
+ font-weight: var(
119
+ --recursica_ui-kit_components_file-upload_properties_text_font-weight
120
+ );
121
+ letter-spacing: var(
122
+ --recursica_ui-kit_components_file-upload_properties_text_letter-spacing
123
+ );
124
+ line-height: var(
125
+ --recursica_ui-kit_components_file-upload_properties_text_line-height
126
+ );
127
+ text-decoration: var(
128
+ --recursica_ui-kit_components_file-upload_properties_text_text-decoration
129
+ );
130
+ text-transform: var(
131
+ --recursica_ui-kit_components_file-upload_properties_text_text-transform
132
+ );
133
+ color: var(
134
+ --recursica_ui-kit_components_file-upload_properties_colors_text-color
135
+ );
136
+ text-align: center;
137
+ }
138
+
139
+ .fileList {
140
+ display: flex;
141
+ flex-wrap: wrap;
142
+ align-items: center;
143
+ gap: var(--recursica_ui-kit_components_file-upload_properties_item-gap);
144
+ }
145
+
146
+ /* -------------------------------------
147
+ STATE CASCADE ARCHITECTURE
148
+ -------------------------------------- */
149
+
150
+ /* Error State Mapping (Propagated strictly down from the wrapper DOM context) */
151
+ .root[data-error="true"] .dropzone {
152
+ border-width: var(
153
+ --recursica_ui-kit_components_file-upload_variants_states_error_properties_border-size
154
+ );
155
+ border-color: var(
156
+ --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_border-color
157
+ );
158
+ background-color: var(
159
+ --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_background-color
160
+ );
161
+ }
162
+
163
+ .root[data-error="true"] .dropzoneText {
164
+ color: var(
165
+ --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_text-color
166
+ );
167
+ }
168
+
169
+ .root[data-error="true"] .uploadIcon {
170
+ color: var(
171
+ --recursica_ui-kit_components_file-upload_variants_states_error_properties_colors_upload-icon
172
+ );
173
+ }
174
+
175
+ /* Disabled State Mapping (Propagated strictly down from the wrapper DOM context) */
176
+ .root[data-disabled="true"] .dropzone {
177
+ border-width: var(
178
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_border-size
179
+ );
180
+ border-color: var(
181
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_border-color
182
+ );
183
+ background-color: var(
184
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_background-color
185
+ );
186
+ }
187
+
188
+ .root[data-disabled="true"] .dropzoneText {
189
+ color: var(
190
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_text-color
191
+ );
192
+ }
193
+
194
+ .root[data-disabled="true"] .uploadIcon {
195
+ color: var(
196
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_colors_upload-icon
197
+ );
198
+ }
199
+
200
+ .root[data-disabled="true"] {
201
+ opacity: var(
202
+ --recursica_ui-kit_components_file-upload_variants_states_disabled_properties_opacity
203
+ );
204
+ cursor: not-allowed;
205
+ }