@recursica/mui-adapter 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +3 -0
- package/CHANGELOG.md +75 -0
- package/dist/index.d.ts +247 -41
- package/dist/mui-adapter.cjs +78 -78
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +7624 -7311
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +63 -0
- package/src/components/Accordion/Accordion.module.css +21 -2
- package/src/components/Accordion/Accordion.stories.tsx +38 -0
- package/src/components/Accordion/Accordion.tsx +27 -12
- package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +59 -0
- package/src/components/AssistiveElement/AssistiveElement.module.css +8 -1
- package/src/components/AssistiveElement/AssistiveElement.tsx +17 -7
- package/src/components/Autocomplete/Autocomplete.tsx +18 -2
- package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +36 -0
- package/src/components/Avatar/Avatar.tsx +6 -9
- package/src/components/Badge/Badge.module.css +0 -12
- package/src/components/Badge/Badge.tsx +0 -4
- package/src/components/Button/Button.module.css +13 -0
- package/src/components/Card/Card.tsx +5 -5
- package/src/components/Checkbox/Checkbox.module.css +17 -21
- package/src/components/Checkbox/Checkbox.tsx +24 -18
- package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +102 -0
- package/src/components/Chip/Chip.module.css +46 -11
- package/src/components/Chip/Chip.tsx +41 -5
- package/src/components/Chip/USAGE.md +19 -0
- package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +249 -0
- package/src/components/FileUpload/FileUpload.module.css +203 -38
- package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
- package/src/components/FileUpload/FileUpload.tsx +350 -6
- package/src/components/FileUpload/USAGE.md +163 -5
- package/src/components/Flex/Flex.tsx +1 -1
- package/src/components/FormControlWrapper/FORMCONTROLWRAPPER_IMPLEMENTATION_NOTES.md +35 -0
- package/src/components/FormControlWrapper/FormControlWrapper.tsx +24 -8
- package/src/components/Grid/Grid.tsx +1 -1
- package/src/components/Group/Group.tsx +1 -1
- package/src/components/HoverCard/HoverCard.tsx +1 -1
- package/src/components/Label/Label.module.css +15 -2
- package/src/components/Label/Label.tsx +7 -6
- package/src/components/Menu/Menu.tsx +1 -1
- package/src/components/Modal/Modal.tsx +1 -1
- package/src/components/NumberInput/NumberInput.tsx +1 -1
- package/src/components/Pagination/Pagination.tsx +1 -1
- package/src/components/Panel/Panel.tsx +1 -1
- package/src/components/Radio/Radio.module.css +33 -38
- package/src/components/Radio/Radio.tsx +58 -44
- package/src/components/Radio/RadioGroup.tsx +5 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +0 -7
- package/src/components/SegmentedControl/SegmentedControl.tsx +4 -8
- package/src/components/Slider/Slider.tsx +1 -1
- package/src/components/Stack/Stack.tsx +1 -1
- package/src/components/Stepper/Stepper.tsx +3 -3
- package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +123 -0
- package/src/components/Switch/Switch.module.css +152 -89
- package/src/components/Switch/Switch.tsx +140 -33
- package/src/components/Switch/SwitchGroup.tsx +37 -9
- package/src/components/Table/Table.tsx +1 -1
- package/src/components/Tabs/IMPLEMENTATION_NOTES.md +10 -0
- package/src/components/Tabs/Tabs.module.css +120 -43
- package/src/components/Tabs/Tabs.stories.tsx +5 -2
- package/src/components/Tabs/Tabs.tsx +1 -1
- package/src/components/Tabs/USAGE.md +27 -11
- package/src/components/TextField/TextField.tsx +1 -1
- package/src/components/TimePicker/TimePicker.tsx +1 -1
- package/src/components/Timeline/Timeline.tsx +1 -1
- package/src/components/Timeline/TimelineItem.tsx +2 -2
- package/src/components/Toast/Toast.tsx +4 -4
- package/src/components/Tooltip/Tooltip.tsx +1 -1
- package/src/components/Tree/Tree.tsx +1 -1
- package/src/index.ts +3 -1
- package/src/types/mantine.d.ts +0 -7
|
@@ -86,8 +86,14 @@
|
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/* Base explicit reset masking underlying framework defaults
|
|
89
|
+
/* Base explicit reset masking underlying framework defaults.
|
|
90
|
+
display: flex + centering: the checkmark icon renders as a nested child of this box
|
|
91
|
+
(see Checkbox.tsx — icon/checkedIcon are combined nodes, icon inside .input), so this
|
|
92
|
+
box has to center it itself rather than relying on a parent's centering. */
|
|
90
93
|
.input {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
91
97
|
cursor: pointer; /* HARDCODE: core interaction behavior */
|
|
92
98
|
appearance: none; /* HARDCODE: reset native widget */
|
|
93
99
|
box-sizing: border-box; /* HARDCODE: ensures size includes borders */
|
|
@@ -199,7 +205,14 @@
|
|
|
199
205
|
);
|
|
200
206
|
}
|
|
201
207
|
|
|
208
|
+
/* display: flex (not the browser default block) blockifies the native inline <label>
|
|
209
|
+
child per the CSS Display spec's flex-item blockification rule — matches Mantine's
|
|
210
|
+
own labelWrapper (inline-flex), whose flex context does the same to its label.
|
|
211
|
+
Without it, the label stays inline and its line box picks up extra half-leading
|
|
212
|
+
height, visually shifting it below vertical center against the checkbox. */
|
|
202
213
|
.labelWrapper {
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
203
216
|
padding: 0; /* HARDCODE: structural layout reset */
|
|
204
217
|
margin: 0; /* HARDCODE: structural layout reset */
|
|
205
218
|
flex: 1;
|
|
@@ -250,26 +263,9 @@
|
|
|
250
263
|
);
|
|
251
264
|
}
|
|
252
265
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
font-size: 12px; /* HARDCODE: mantine default xs */
|
|
257
|
-
line-height: 1.2;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
.error {
|
|
261
|
-
margin-top: 5px; /* HARDCODE: mantine default */
|
|
262
|
-
color: #fa5252; /* HARDCODE: mantine default error */
|
|
263
|
-
font-size: 12px; /* HARDCODE: mantine default xs */
|
|
264
|
-
line-height: 1.2;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
.description[data-disabled="true"],
|
|
268
|
-
.error[data-disabled="true"] {
|
|
269
|
-
opacity: var(
|
|
270
|
-
--recursica_ui-kit_components_checkbox-item_variants_states_disabled_properties_opacity
|
|
271
|
-
);
|
|
272
|
-
}
|
|
266
|
+
/* description/error text renders via AssistiveElement directly (see Checkbox.tsx) —
|
|
267
|
+
it owns its own tokenized styling. AssistiveElement has no disabled state and
|
|
268
|
+
must never dim. */
|
|
273
269
|
|
|
274
270
|
/* CheckboxGroup Layouts */
|
|
275
271
|
.groupRoot {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
FormControlLayout,
|
|
18
18
|
type FormControlLayoutProps,
|
|
19
19
|
} from "../FormControlLayout/FormControlLayout";
|
|
20
|
+
import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
|
|
20
21
|
|
|
21
22
|
import styles from "./Checkbox.module.css";
|
|
22
23
|
|
|
@@ -96,6 +97,12 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
|
96
97
|
const isChecked = isGrouped
|
|
97
98
|
? (groupContext.value || []).includes(restRecord.value)
|
|
98
99
|
: !!(restRecord.checked ?? restRecord.defaultChecked);
|
|
100
|
+
// Only force controlled mode when we actually own the source of truth (grouped) or the
|
|
101
|
+
// caller explicitly passed `checked` (controlled). A plain `defaultChecked`/neither-prop
|
|
102
|
+
// usage must stay uncontrolled — otherwise `checked` gets pinned to a value that never
|
|
103
|
+
// updates, and MUI's native input silently snaps back after every click. Mirrors Switch's
|
|
104
|
+
// identical `isGrouped ? { checked: ... } : {}` pattern (Switch never had this bug).
|
|
105
|
+
const isControlled = isGrouped || restRecord.checked !== undefined;
|
|
99
106
|
|
|
100
107
|
if (readOnly && !!readOnlyComponent) {
|
|
101
108
|
const ReadOnlyComp = readOnlyComponent;
|
|
@@ -166,8 +173,9 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
|
166
173
|
classes={mergedClassNames}
|
|
167
174
|
disabled={readOnly || disabled || isGroupReadOnly}
|
|
168
175
|
{...(sanitizedProps as unknown as MuiCheckboxProps)}
|
|
169
|
-
checked
|
|
176
|
+
{...(isControlled ? { checked: isChecked as boolean } : {})}
|
|
170
177
|
onChange={handleChange}
|
|
178
|
+
disableRipple
|
|
171
179
|
sx={label ? { padding: 0 } : undefined}
|
|
172
180
|
icon={<div className={styles.input} />}
|
|
173
181
|
checkedIcon={
|
|
@@ -204,25 +212,23 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
|
204
212
|
>
|
|
205
213
|
{label as React.ReactNode}
|
|
206
214
|
</label>
|
|
207
|
-
{description
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
>
|
|
214
|
-
{description}
|
|
215
|
-
</div>
|
|
216
|
-
)}
|
|
217
|
-
{error && (
|
|
218
|
-
<div
|
|
219
|
-
className={styles.error}
|
|
220
|
-
data-disabled={
|
|
221
|
-
readOnly || disabled || isGroupReadOnly ? true : undefined
|
|
222
|
-
}
|
|
215
|
+
{/* description/error are mutually exclusive — error takes precedence */}
|
|
216
|
+
{error ? (
|
|
217
|
+
<AssistiveElement
|
|
218
|
+
assistiveVariant="error"
|
|
219
|
+
assistiveWithIcon={false}
|
|
223
220
|
>
|
|
224
221
|
{error}
|
|
225
|
-
</
|
|
222
|
+
</AssistiveElement>
|
|
223
|
+
) : (
|
|
224
|
+
description && (
|
|
225
|
+
<AssistiveElement
|
|
226
|
+
assistiveVariant="help"
|
|
227
|
+
assistiveWithIcon={false}
|
|
228
|
+
>
|
|
229
|
+
{description}
|
|
230
|
+
</AssistiveElement>
|
|
231
|
+
)
|
|
226
232
|
)}
|
|
227
233
|
</div>
|
|
228
234
|
</div>
|
|
@@ -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;
|
|
@@ -128,8 +138,6 @@
|
|
|
128
138
|
background-color: var(--chip-bg);
|
|
129
139
|
border-color: var(--chip-border);
|
|
130
140
|
color: var(--chip-text);
|
|
131
|
-
min-width: var(--recursica_ui-kit_components_chip_properties_min-width);
|
|
132
|
-
max-width: var(--recursica_ui-kit_components_chip_properties_max-width);
|
|
133
141
|
box-shadow: var(--recursica_ui-kit_components_chip_properties_elevation);
|
|
134
142
|
|
|
135
143
|
transition: all 0.2s ease;
|
|
@@ -195,11 +203,12 @@
|
|
|
195
203
|
cursor: not-allowed;
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
/*
|
|
199
|
-
.
|
|
206
|
+
/* Selected-state check icon colors and size */
|
|
207
|
+
.checkIconWrapper.checkIconWrapper {
|
|
200
208
|
color: var(--chip-icon);
|
|
201
209
|
width: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
202
210
|
height: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
211
|
+
margin-left: 0;
|
|
203
212
|
margin-right: var(
|
|
204
213
|
--recursica_ui-kit_components_chip_properties_icon-text-gap
|
|
205
214
|
);
|
|
@@ -210,22 +219,40 @@
|
|
|
210
219
|
align-items: center;
|
|
211
220
|
gap: var(--recursica_ui-kit_components_chip_properties_icon-text-gap);
|
|
212
221
|
width: 100%;
|
|
222
|
+
min-width: 0; /* HARDCODE: lets .children actually shrink and ellipsize instead of overflowing */
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
.children {
|
|
216
226
|
flex-grow: 1;
|
|
227
|
+
min-width: 0; /* HARDCODE: a flex child needs this for text-overflow: ellipsis to engage at all */
|
|
217
228
|
text-overflow: ellipsis;
|
|
218
|
-
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;
|
|
219
237
|
white-space: nowrap;
|
|
220
238
|
}
|
|
221
239
|
|
|
222
|
-
|
|
240
|
+
/* Doubled selector for specificity parity with MUI's own compound
|
|
241
|
+
`.MuiChip-root .MuiChip-icon` rule, which otherwise wins outright on a
|
|
242
|
+
plain single-class selector and leaks its default margin/color. */
|
|
243
|
+
.leadingIcon.leadingIcon {
|
|
223
244
|
display: inline-flex;
|
|
224
245
|
align-items: center;
|
|
225
246
|
justify-content: center;
|
|
247
|
+
flex-shrink: 0; /* HARDCODE: keep the icon from being squeezed by a long, truncating label */
|
|
226
248
|
color: var(--chip-icon);
|
|
227
249
|
width: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
228
250
|
height: var(--recursica_ui-kit_components_chip_properties_icon-size);
|
|
251
|
+
/* Reset MUI's default icon margin, keep the icon-text gap */
|
|
252
|
+
margin-left: 0;
|
|
253
|
+
margin-right: var(
|
|
254
|
+
--recursica_ui-kit_components_chip_properties_icon-text-gap
|
|
255
|
+
);
|
|
229
256
|
}
|
|
230
257
|
|
|
231
258
|
.leadingIcon svg {
|
|
@@ -233,10 +260,13 @@
|
|
|
233
260
|
height: 100%;
|
|
234
261
|
}
|
|
235
262
|
|
|
236
|
-
|
|
263
|
+
/* Doubled selector for specificity parity with MUI's own compound
|
|
264
|
+
`.MuiChip-root .MuiChip-deleteIcon` rule (same reasoning as .leadingIcon). */
|
|
265
|
+
.removeIconWrapper.removeIconWrapper {
|
|
237
266
|
display: inline-flex;
|
|
238
267
|
align-items: center;
|
|
239
268
|
justify-content: center;
|
|
269
|
+
flex-shrink: 0; /* HARDCODE: keep the remove icon visible when the label truncates instead */
|
|
240
270
|
color: var(--chip-close);
|
|
241
271
|
width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
242
272
|
height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
@@ -247,8 +277,13 @@
|
|
|
247
277
|
margin: 0;
|
|
248
278
|
}
|
|
249
279
|
|
|
250
|
-
.removeIconWrapper:focus-visible {
|
|
251
|
-
box-shadow:
|
|
280
|
+
.removeIconWrapper.removeIconWrapper:focus-visible {
|
|
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);
|
|
252
287
|
}
|
|
253
288
|
|
|
254
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,10 +121,11 @@ 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 ? (
|
|
112
|
-
<span className={styles.
|
|
128
|
+
<span className={styles.checkIconWrapper} aria-hidden>
|
|
113
129
|
<CheckIcon />
|
|
114
130
|
</span>
|
|
115
131
|
) : icon ? (
|
|
@@ -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`).
|