@recursica/mui-adapter 0.28.0 → 0.30.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 +29 -0
- package/dist/index.d.ts +105 -49
- package/dist/mui-adapter.cjs +62 -62
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +6693 -6674
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +3 -3
- package/src/components/Accordion/Accordion.tsx +6 -1
- package/src/components/Checkbox/CheckboxGroup.tsx +5 -3
- package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +3 -3
- package/src/components/Chip/Chip.module.css +5 -5
- package/src/components/Chip/Chip.stories.tsx +2 -2
- package/src/components/Chip/Chip.tsx +20 -19
- package/src/components/Chip/USAGE.md +4 -4
- package/src/components/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +5 -3
- package/src/components/FileInput/FileInput.stories.tsx +3 -0
- package/src/components/FileInput/FileInput.tsx +11 -10
- package/src/components/FileInput/USAGE.md +1 -0
- package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +6 -6
- package/src/components/FileUpload/FileUpload.tsx +9 -9
- package/src/components/Link/Link.module.css +4 -0
- package/src/components/Radio/RadioGroup.stories.tsx +4 -4
- package/src/components/Radio/RadioGroup.tsx +1 -12
- package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +8 -0
- package/src/components/SegmentedControl/SegmentedControl.stories.tsx +17 -28
- package/src/components/SegmentedControl/SegmentedControl.tsx +15 -17
- package/src/components/SegmentedControl/USAGE.md +13 -0
- package/src/components/Switch/SwitchGroup.tsx +5 -1
- package/src/components/Table/TABLE_IMPLEMENTATION_NOTES.md +64 -0
- package/src/components/Table/Table.module.css +443 -2
- package/src/components/Table/Table.stories.tsx +105 -0
- package/src/components/Table/Table.tsx +62 -5
- package/src/components/Table/USAGE.md +55 -16
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "git+https://github.com/borderux/recursica.git",
|
|
14
14
|
"directory": "packages/mui-adapter"
|
|
15
15
|
},
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.30.0",
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
@@ -102,8 +102,8 @@
|
|
|
102
102
|
"vitest": "^3.2.4"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
-
"@recursica/adapter-common": "
|
|
106
|
-
"@recursica/official-release": "
|
|
105
|
+
"@recursica/adapter-common": "^0.21.0",
|
|
106
|
+
"@recursica/official-release": "^2.8.0",
|
|
107
107
|
"dayjs": "^1.11.21"
|
|
108
108
|
},
|
|
109
109
|
"peerDependencies": {
|
|
@@ -43,7 +43,12 @@ const AccordionContext = createContext<{
|
|
|
43
43
|
|
|
44
44
|
// ==== ACCORDION CONTAINER ====
|
|
45
45
|
export type AccordionProps = RecursicaOverStyled<
|
|
46
|
-
React.HTMLAttributes<HTMLDivElement> &
|
|
46
|
+
React.HTMLAttributes<HTMLDivElement> &
|
|
47
|
+
RecursicaAccordionProps & {
|
|
48
|
+
// MUI has no native multi-panel accordion group to match (its own `Accordion` is a
|
|
49
|
+
// single controlled item) — this signature is Recursica's own, same as TransferList.
|
|
50
|
+
onChange?: (value: string | string[] | null) => void;
|
|
51
|
+
}
|
|
47
52
|
>;
|
|
48
53
|
|
|
49
54
|
const AccordionBase = forwardRef<HTMLDivElement, AccordionProps>(
|
|
@@ -36,7 +36,11 @@ export interface RecursicaCheckboxGroupProps
|
|
|
36
36
|
| "defaultValue"
|
|
37
37
|
>,
|
|
38
38
|
ReadOnlyControlProps,
|
|
39
|
-
BaseRecursicaCheckboxGroupProps {
|
|
39
|
+
BaseRecursicaCheckboxGroupProps {
|
|
40
|
+
// MUI has no native checkbox-group concept to match (its own `FormGroup` is layout-only,
|
|
41
|
+
// no value/onChange) — this signature is Recursica's own, same as TransferList/Accordion.
|
|
42
|
+
onChange?: (value: unknown[]) => void;
|
|
43
|
+
}
|
|
40
44
|
|
|
41
45
|
export type CheckboxGroupProps =
|
|
42
46
|
RecursicaOverStyled<RecursicaCheckboxGroupProps>;
|
|
@@ -72,7 +76,6 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
|
|
|
72
76
|
value,
|
|
73
77
|
defaultValue,
|
|
74
78
|
onChange,
|
|
75
|
-
row,
|
|
76
79
|
...rest
|
|
77
80
|
} = props;
|
|
78
81
|
// NOTE: this component's props surface (HTMLAttributes<HTMLDivElement>-based) has no native
|
|
@@ -125,7 +128,6 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
|
|
|
125
128
|
>
|
|
126
129
|
<MuiFormGroup
|
|
127
130
|
ref={ref}
|
|
128
|
-
row={row}
|
|
129
131
|
{...(sanitizedProps as any)}
|
|
130
132
|
className={`${styles.groupRoot} ${(sanitizedProps as any).className || ""}`.trim()}
|
|
131
133
|
data-layout={formLayout}
|
|
@@ -44,7 +44,7 @@ a child span directly (which is exactly what happens once you `.focus()` the rem
|
|
|
44
44
|
imperatively, as `FileUpload`'s roving-tabindex group does below). A plain `<span>` also gets no
|
|
45
45
|
native Enter/Space-triggers-click behavior the way a real `<button>` would. Fixed by adding
|
|
46
46
|
`role="button"`, `aria-label={removeLabel}`, and an explicit `onKeyDown` for `Enter`/`Space` that
|
|
47
|
-
calls `
|
|
47
|
+
calls `onDelete` directly — matching the mantine-adapter `Chip`'s remove icon, which already did all
|
|
48
48
|
three.
|
|
49
49
|
|
|
50
50
|
### Roving tabindex support for chip groups (Matt Massey, 2026-08-17)
|
|
@@ -83,11 +83,11 @@ string size variants (`sm`/`md`/`lg`), so `size` is omitted from `RecursicaChipP
|
|
|
83
83
|
|
|
84
84
|
### A non-interactive chip still looked clickable, and had a phantom Tab stop (Matt Massey, 2026-08-18)
|
|
85
85
|
|
|
86
|
-
A `Chip` with no `
|
|
86
|
+
A `Chip` with no `onDelete`/`onClick`/`onChange` (e.g. `FileUpload`'s `readOnly` file list) still
|
|
87
87
|
showed a pointer cursor on hover — `.root.root` hardcoded `cursor: pointer` unconditionally, with
|
|
88
88
|
no notion of whether the chip actually did anything. Added an `isInteractive` check (mirroring the
|
|
89
89
|
one added to mantine-adapter's `Chip`, see its `CHIP_IMPLEMENTATION_NOTES.md`) based on
|
|
90
|
-
`
|
|
90
|
+
`onDelete`/`onClick`/`onChange` — this adapter's `Chip` has no `checked`-driven native-input case to
|
|
91
91
|
misread the way Mantine's did, since MUI's `Chip` has no real underlying form control. A new
|
|
92
92
|
`data-interactive` attribute (set from `isInteractive`) gates `cursor: pointer` in CSS; without it,
|
|
93
93
|
the chip falls back to whatever MUI's own non-clickable `Chip` renders as (no cursor override, no
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
transition: all 0.2s ease;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/* Only a chip with a real handler (
|
|
66
|
+
/* Only a chip with a real handler (onDelete/onClick/onChange — see Chip.tsx's `isInteractive`)
|
|
67
67
|
gets the pointer cursor. A display-only chip (e.g. a read-only FileUpload file list) has
|
|
68
68
|
nothing for a click to do, so it shouldn't look clickable. */
|
|
69
69
|
.root.root[data-interactive] {
|
|
@@ -235,11 +235,11 @@
|
|
|
235
235
|
|
|
236
236
|
/* Doubled selector for specificity parity with MUI's own compound
|
|
237
237
|
`.MuiChip-root .MuiChip-deleteIcon` rule (same reasoning as .leadingIcon). */
|
|
238
|
-
.
|
|
238
|
+
.deleteIconWrapper.deleteIconWrapper {
|
|
239
239
|
display: inline-flex;
|
|
240
240
|
align-items: center;
|
|
241
241
|
justify-content: center;
|
|
242
|
-
flex-shrink: 0; /* HARDCODE: keep the
|
|
242
|
+
flex-shrink: 0; /* HARDCODE: keep the delete icon visible when the label truncates instead */
|
|
243
243
|
color: var(--chip-close);
|
|
244
244
|
width: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
245
245
|
height: var(--recursica_ui-kit_components_chip_properties_close-icon-size);
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
margin: 0;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
.
|
|
253
|
+
.deleteIconWrapper.deleteIconWrapper:focus-visible {
|
|
254
254
|
box-shadow:
|
|
255
255
|
0 0 0 var(--recursica_brand_states_focus_border-size)
|
|
256
256
|
var(--recursica_brand_states_focus_color),
|
|
@@ -259,7 +259,7 @@
|
|
|
259
259
|
var(--recursica_brand_states_focus_color);
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
.
|
|
262
|
+
.deleteIconWrapper svg {
|
|
263
263
|
width: 100%;
|
|
264
264
|
height: 100%;
|
|
265
265
|
}
|
|
@@ -12,7 +12,7 @@ const meta: Meta<ChipStoryProps> = {
|
|
|
12
12
|
docs: {
|
|
13
13
|
description: {
|
|
14
14
|
component:
|
|
15
|
-
"The Chip component is used to represent interactive selections, descriptive tags, or dynamic filters natively bounded to Recursica variables. It can be used as a toggleable input, can render a custom leading `icon`, and handles close constraints automatically via the `
|
|
15
|
+
"The Chip component is used to represent interactive selections, descriptive tags, or dynamic filters natively bounded to Recursica variables. It can be used as a toggleable input, can render a custom leading `icon`, and handles close constraints automatically via the `onDelete` property.",
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
},
|
|
@@ -86,7 +86,7 @@ export const Removable: Story = {
|
|
|
86
86
|
args: {
|
|
87
87
|
children: "Dismissible",
|
|
88
88
|
checked: false,
|
|
89
|
-
|
|
89
|
+
onDelete: () => console.log("Removal Action Triggered"),
|
|
90
90
|
},
|
|
91
91
|
render: (args: ChipStoryProps) => <Chip {...args} />,
|
|
92
92
|
};
|
|
@@ -62,10 +62,10 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
|
|
|
62
62
|
{
|
|
63
63
|
error = false,
|
|
64
64
|
icon,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
onDelete,
|
|
66
|
+
deleteLabel = "Delete",
|
|
67
|
+
deleteTabIndex,
|
|
68
|
+
deleteIconRef,
|
|
69
69
|
children,
|
|
70
70
|
checked,
|
|
71
71
|
overStyled = false,
|
|
@@ -83,7 +83,7 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
|
|
|
83
83
|
root: styles.root,
|
|
84
84
|
label: styles.label,
|
|
85
85
|
icon: styles.leadingIcon,
|
|
86
|
-
deleteIcon: styles.
|
|
86
|
+
deleteIcon: styles.deleteIconWrapper,
|
|
87
87
|
},
|
|
88
88
|
restRecord.classes as Partial<Record<string, string>> | undefined,
|
|
89
89
|
);
|
|
@@ -96,13 +96,13 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
|
|
|
96
96
|
// Determine state
|
|
97
97
|
const dataError = error ? "" : undefined;
|
|
98
98
|
const dataChecked = checked ? "" : undefined;
|
|
99
|
-
const isIconOnly = !children && (!!icon || !!
|
|
99
|
+
const isIconOnly = !children && (!!icon || !!onDelete);
|
|
100
100
|
// A chip only counts as interactive when something actually responds to it — merely passing a
|
|
101
101
|
// `checked` value (e.g. to pin a display-only chip to a fixed visual state, as FileUpload's
|
|
102
102
|
// read-only file list does) isn't itself an interaction, since clicking it with no onChange/
|
|
103
103
|
// onClick wired does nothing observable.
|
|
104
104
|
const isInteractive =
|
|
105
|
-
|
|
105
|
+
onDelete !== undefined ||
|
|
106
106
|
restRecord.onClick !== undefined ||
|
|
107
107
|
restRecord.onChange !== undefined;
|
|
108
108
|
|
|
@@ -127,25 +127,26 @@ export const Chip = forwardRef<HTMLInputElement, ChipProps>(function Chip(
|
|
|
127
127
|
</span>
|
|
128
128
|
) : undefined
|
|
129
129
|
}
|
|
130
|
-
onDelete={
|
|
130
|
+
onDelete={onDelete}
|
|
131
131
|
deleteIcon={
|
|
132
|
-
|
|
132
|
+
onDelete ? (
|
|
133
133
|
<span
|
|
134
|
-
ref={
|
|
134
|
+
ref={deleteIconRef}
|
|
135
135
|
role="button"
|
|
136
|
-
className={styles.
|
|
137
|
-
aria-label={
|
|
138
|
-
tabIndex={
|
|
136
|
+
className={styles.deleteIconWrapper}
|
|
137
|
+
aria-label={deleteLabel}
|
|
138
|
+
tabIndex={deleteTabIndex ?? 0}
|
|
139
139
|
onKeyDown={(e) => {
|
|
140
|
-
// MUI's
|
|
141
|
-
// span itself is both the event's
|
|
142
|
-
// parent (e.g. FileUpload's
|
|
143
|
-
// directly. A plain `<span>`
|
|
144
|
-
// the way a real `<button>`
|
|
140
|
+
// MUI's native `onDelete` wiring (distinct from our own `onDelete` prop above) only
|
|
141
|
+
// reacts to Backspace/Delete, and only when this span itself is both the event's
|
|
142
|
+
// target and currentTarget — neither holds once a parent (e.g. FileUpload's
|
|
143
|
+
// roving-tabindex group) moves real focus onto this span directly. A plain `<span>`
|
|
144
|
+
// also gets no native Enter/Space-triggers-click behavior the way a real `<button>`
|
|
145
|
+
// would, so it's handled explicitly here instead.
|
|
145
146
|
if (e.key === "Enter" || e.key === " ") {
|
|
146
147
|
e.preventDefault();
|
|
147
148
|
e.stopPropagation();
|
|
148
|
-
|
|
149
|
+
onDelete(
|
|
149
150
|
e as unknown as React.MouseEvent<HTMLSpanElement, MouseEvent>,
|
|
150
151
|
);
|
|
151
152
|
}
|
|
@@ -45,11 +45,11 @@ Chip does not support a `size` prop; chips render at a fixed size.
|
|
|
45
45
|
|
|
46
46
|
### Building a keyboard-navigable chip group
|
|
47
47
|
|
|
48
|
-
`
|
|
48
|
+
`deleteTabIndex` and `deleteIconRef` are optional escape hatches for composing a _group_ of chips
|
|
49
49
|
with roving-tabindex keyboard navigation (Tab reaches one chip at a time, arrow keys move between
|
|
50
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}`
|
|
51
|
+
default to a normal `tabIndex={0}` delete icon with no ref.
|
|
52
52
|
|
|
53
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 `
|
|
55
|
-
second, unwanted tab stop ahead of the
|
|
54
|
+
becomes a focusable element on its own whenever `onDelete` is set, which would otherwise be a
|
|
55
|
+
second, unwanted tab stop ahead of the delete icon (see `CHIP_IMPLEMENTATION_NOTES.md`).
|
|
@@ -20,7 +20,7 @@ The control is one clickable, focusable `<div role="button">` containing:
|
|
|
20
20
|
single- and multiple-file mode render identically here; only the selection/replace behavior
|
|
21
21
|
differs (see "`multiple` defaults to `false`" below).
|
|
22
22
|
3. **A trailing "clear" `Button`** — shown whenever a file is selected; clears the entire current
|
|
23
|
-
selection (see "Trailing clear button" below).
|
|
23
|
+
selection, icon overridable via `clearIcon` (see "Trailing clear button" below).
|
|
24
24
|
4. **A hidden `<input type="file">`**, triggered programmatically — same approach as
|
|
25
25
|
`FileUpload`'s dropzone, not the "invisible overlay input" pattern some styled file inputs use
|
|
26
26
|
(that pattern would need every interactive child — chip remove icons, the clear icon — to sit
|
|
@@ -107,7 +107,9 @@ color/disabled states, so `file-input`'s own `properties_colors_trailing-icon` t
|
|
|
107
107
|
entirely. Clicking/activating it calls `onFileRemove` once per currently-selected file (reusing
|
|
108
108
|
the same callback `FileUpload`'s individual chip removal uses, rather than introducing a separate
|
|
109
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.
|
|
110
|
+
at once, distinct from a chip's own individual remove icon. The icon itself defaults to the
|
|
111
|
+
built-in X glyph and is overridable via `clearIcon` — the same pattern as the leading `icon`,
|
|
112
|
+
giving Forge's `leading-icon`/`trailing-icon` token pair a matching pair of override props.
|
|
111
113
|
|
|
112
114
|
## Why each chip is wrapped with its own `stopPropagation`
|
|
113
115
|
|
|
@@ -140,7 +142,7 @@ already made for `.value`'s ellipsis truncation.
|
|
|
140
142
|
|
|
141
143
|
`readOnly` renders the same content (placeholder/filename/chip row) but the root loses its
|
|
142
144
|
`role="button"` interactivity (no `tabIndex`, no click/keyboard/drag handlers), chips render with
|
|
143
|
-
no `removeLabel`/`
|
|
145
|
+
no `removeLabel`/`onDelete` (so `Chip` itself renders no remove icon, same as `FileUpload`'s
|
|
144
146
|
read-only chips), and the trailing clear icon is omitted entirely. `disabled` keeps the control
|
|
145
147
|
structurally the same but inert (`tabIndex={-1}`, `aria-disabled`, no handlers, native `<input>`
|
|
146
148
|
disabled) — both are computed together as a single `interactive` flag used throughout, but remain
|
|
@@ -124,6 +124,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
124
124
|
? `Maximum of ${maxFiles} files allowed`
|
|
125
125
|
: "Only one file is allowed",
|
|
126
126
|
icon,
|
|
127
|
+
clearIcon,
|
|
127
128
|
placeholder = "Select a file...",
|
|
128
129
|
browseLabel = "Choose file",
|
|
129
130
|
removeFileLabel = "Remove",
|
|
@@ -234,10 +235,10 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
234
235
|
};
|
|
235
236
|
|
|
236
237
|
// Roving tabindex across the file chip list (single- or multiple-file mode): only the
|
|
237
|
-
// "active" chip's
|
|
238
|
+
// "active" chip's delete icon is a tab stop, and Left/Right/Up/Down move it — same pattern
|
|
238
239
|
// as FileUpload, see FILEINPUT_IMPLEMENTATION_NOTES.md.
|
|
239
240
|
const [activeChipIndex, setActiveChipIndex] = useState(0);
|
|
240
|
-
const
|
|
241
|
+
const deleteIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
|
241
242
|
const prevFileCountRef = useRef(files?.length ?? 0);
|
|
242
243
|
|
|
243
244
|
useEffect(() => {
|
|
@@ -245,7 +246,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
245
246
|
if (count > 0 && count < prevFileCountRef.current) {
|
|
246
247
|
const nextIndex = Math.min(activeChipIndex, count - 1);
|
|
247
248
|
setActiveChipIndex(nextIndex);
|
|
248
|
-
|
|
249
|
+
deleteIconRefs.current[nextIndex]?.focus();
|
|
249
250
|
}
|
|
250
251
|
prevFileCountRef.current = count;
|
|
251
252
|
// Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
|
|
@@ -267,7 +268,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
267
268
|
event.preventDefault();
|
|
268
269
|
event.stopPropagation();
|
|
269
270
|
setActiveChipIndex(nextIndex);
|
|
270
|
-
|
|
271
|
+
deleteIconRefs.current[nextIndex]?.focus();
|
|
271
272
|
};
|
|
272
273
|
|
|
273
274
|
// The built-in `accept`/cap-mismatch message is only shown when the integrator hasn't
|
|
@@ -351,14 +352,14 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
351
352
|
>
|
|
352
353
|
<Chip
|
|
353
354
|
tabIndex={-1}
|
|
354
|
-
|
|
355
|
-
|
|
355
|
+
deleteLabel={readOnly ? undefined : removeFileLabel}
|
|
356
|
+
deleteTabIndex={
|
|
356
357
|
!readOnly && index === activeChipIndex ? 0 : -1
|
|
357
358
|
}
|
|
358
|
-
|
|
359
|
-
|
|
359
|
+
deleteIconRef={(el) => {
|
|
360
|
+
deleteIconRefs.current[index] = el;
|
|
360
361
|
}}
|
|
361
|
-
|
|
362
|
+
onDelete={
|
|
362
363
|
readOnly || disabled
|
|
363
364
|
? undefined
|
|
364
365
|
: () => onFileRemove?.(itemId)
|
|
@@ -378,7 +379,7 @@ export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
|
378
379
|
overStyled
|
|
379
380
|
variant="text"
|
|
380
381
|
size="small"
|
|
381
|
-
icon={<ClearIcon />}
|
|
382
|
+
icon={clearIcon ?? <ClearIcon />}
|
|
382
383
|
aria-label={clearLabel}
|
|
383
384
|
className={styles.trailingIcon}
|
|
384
385
|
disabled={disabled}
|
|
@@ -57,6 +57,7 @@ rather than appending — picking a new file in single-file mode always replaces
|
|
|
57
57
|
| `invalidFileTypeMessage` | `React.ReactNode` | Error message shown when a file is rejected for not matching `accept`. Defaults to `"File type not accepted"`. An explicit `error` prop always takes priority over this. |
|
|
58
58
|
| `maxFilesMessage` | `React.ReactNode` | Error message shown when a file is rejected for exceeding the cap. Defaults to `"Maximum of {maxFiles} files allowed"` when `multiple`, or `"Only one file is allowed"` otherwise. An explicit `error` prop always wins. |
|
|
59
59
|
| `icon` | `React.ReactNode` | Leading icon shown inside the control. Defaults to the built-in upload icon. |
|
|
60
|
+
| `clearIcon` | `React.ReactNode` | Trailing clear-all icon shown once a file is selected. Defaults to the built-in X icon. |
|
|
60
61
|
| `placeholder` | `React.ReactNode` | Text shown when no file is selected. Defaults to `"Select a file..."`. |
|
|
61
62
|
| `browseLabel` | `string` | Screen-reader label for the control itself (it's the sole interactive/focusable surface, there being no separate "Browse" button). Defaults to `"Choose file"`. |
|
|
62
63
|
| `removeFileLabel` | `string` | Screen-reader label for a file chip's remove button. Defaults to `"Remove"`. |
|
|
@@ -10,7 +10,7 @@ core or as a separate package). The component composes:
|
|
|
10
10
|
(`onDragOver`/`onDrop`), containing an upload icon, instructional text, and a hidden
|
|
11
11
|
`<input type="file">` triggered by...
|
|
12
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 `
|
|
13
|
+
3. **The file list** — this adapter's own `<Chip>` component (with `onDelete`), one per entry in
|
|
14
14
|
the controlled `files` prop.
|
|
15
15
|
|
|
16
16
|
This shape is **not configurable** — there is no prop to render a bare native file input without
|
|
@@ -37,7 +37,7 @@ change (the runtime already accepted arbitrary `children`). (Matt Massey, 2026-0
|
|
|
37
37
|
(spacing between/around file entries) but **no color/border tokens of its own for the file
|
|
38
38
|
entries themselves** — the visual design intentionally delegates that to the existing `Chip`
|
|
39
39
|
component's own token namespace (`--recursica_ui-kit_components_chip_...`). Reusing `<Chip
|
|
40
|
-
|
|
40
|
+
onDelete={...}>` directly (rather than reimplementing a similar-looking element) keeps that
|
|
41
41
|
separation intact per the canonical guide's "Component Specificity" rule — `FileUpload.module.css`
|
|
42
42
|
never reaches into `chip`'s namespace, and `Chip.module.css` never reaches into `file-upload`'s.
|
|
43
43
|
|
|
@@ -105,9 +105,9 @@ list's read-only form is the same chip list, just without the ability to remove
|
|
|
105
105
|
`readOnly` is handled directly in `FileUpload.tsx` rather than reusing `WithReadOnlyWrapper`:
|
|
106
106
|
|
|
107
107
|
- The dropzone (icon, instructional text, Browse button, hidden `<input>`) is omitted entirely.
|
|
108
|
-
- Each `Chip` is rendered with no `
|
|
108
|
+
- Each `Chip` is rendered with no `onDelete` (and no `removeTabIndex`/`removeIconRef`/roving
|
|
109
109
|
keyboard handlers, which only exist to manage the remove icon) — `Chip` itself already renders no
|
|
110
|
-
`deleteIcon` at all when `
|
|
110
|
+
`deleteIcon` at all when `onDelete` is `undefined`, so this falls out for free rather than needing
|
|
111
111
|
a separate `readOnly` prop on `Chip`. (It also means MUI's own `ButtonBase`-on-`onDelete` quirk —
|
|
112
112
|
see the keyboard-navigation section below — never triggers in read-only mode either.)
|
|
113
113
|
- `disabled` is independent of `readOnly` and has no effect when `readOnly` is set (there's no
|
|
@@ -225,7 +225,7 @@ triggers both.
|
|
|
225
225
|
## Read-only chips were still interactive (Matt Massey, 2026-08-18)
|
|
226
226
|
|
|
227
227
|
The `readOnly` file list (added 2026-08-18, see USAGE.md §7) rendered each filename as a
|
|
228
|
-
`<Chip tabIndex={-1}>` with no `
|
|
228
|
+
`<Chip tabIndex={-1}>` with no `onDelete`, expecting that to be fully inert. It wasn't, because of
|
|
229
229
|
a bug shared with the Mantine adapter that lives entirely in `Chip`/`Chip.module.css`, not
|
|
230
230
|
`FileUpload` — see the Mantine adapter's `FILEUPLOAD_IMPLEMENTATION_NOTES.md` for the parallel
|
|
231
231
|
write-up. The MUI-specific pieces:
|
|
@@ -233,7 +233,7 @@ write-up. The MUI-specific pieces:
|
|
|
233
233
|
- **The chip's cursor still showed `pointer` on hover with no real interaction wired.** `.root.root`
|
|
234
234
|
hardcoded `cursor: pointer` unconditionally — unlike Mantine, this wasn't inherited from MUI's own
|
|
235
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` (`
|
|
236
|
+
Added an `isInteractive` check to `Chip.tsx` (`onDelete`/`onClick`/`onChange` — MUI's `Chip` has
|
|
237
237
|
no `checked`-driven native-input case to misread, unlike Mantine's) and a `data-interactive`
|
|
238
238
|
attribute that gates `cursor: pointer` in CSS; a chip with none of those handlers now falls back
|
|
239
239
|
to whatever MUI's own un-clickable `Chip` renders as (no cursor override, no `ButtonBase`).
|
|
@@ -193,11 +193,11 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
|
193
193
|
event.target.value = "";
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
// Roving tabindex across the file chip list: only the "active" chip's
|
|
196
|
+
// Roving tabindex across the file chip list: only the "active" chip's delete icon is a tab
|
|
197
197
|
// stop (Tab lands on the first chip), and Left/Right/Up/Down move it — see
|
|
198
198
|
// FILEUPLOAD_IMPLEMENTATION_NOTES.md.
|
|
199
199
|
const [activeChipIndex, setActiveChipIndex] = useState(0);
|
|
200
|
-
const
|
|
200
|
+
const deleteIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
|
201
201
|
const prevFileCountRef = useRef(files?.length ?? 0);
|
|
202
202
|
|
|
203
203
|
useEffect(() => {
|
|
@@ -207,7 +207,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
|
207
207
|
if (count > 0 && count < prevFileCountRef.current) {
|
|
208
208
|
const nextIndex = Math.min(activeChipIndex, count - 1);
|
|
209
209
|
setActiveChipIndex(nextIndex);
|
|
210
|
-
|
|
210
|
+
deleteIconRefs.current[nextIndex]?.focus();
|
|
211
211
|
}
|
|
212
212
|
prevFileCountRef.current = count;
|
|
213
213
|
// Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
|
|
@@ -228,7 +228,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
|
228
228
|
if (nextIndex === undefined) return;
|
|
229
229
|
event.preventDefault();
|
|
230
230
|
setActiveChipIndex(nextIndex);
|
|
231
|
-
|
|
231
|
+
deleteIconRefs.current[nextIndex]?.focus();
|
|
232
232
|
};
|
|
233
233
|
|
|
234
234
|
// The built-in `accept`-mismatch message is only shown when the integrator hasn't supplied
|
|
@@ -328,12 +328,12 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
|
328
328
|
<Chip
|
|
329
329
|
key={itemId}
|
|
330
330
|
tabIndex={-1}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
deleteLabel={removeFileLabel}
|
|
332
|
+
deleteTabIndex={index === activeChipIndex ? 0 : -1}
|
|
333
|
+
deleteIconRef={(el) => {
|
|
334
|
+
deleteIconRefs.current[index] = el;
|
|
335
335
|
}}
|
|
336
|
-
|
|
336
|
+
onDelete={
|
|
337
337
|
disabled ? undefined : () => onFileRemove?.(itemId)
|
|
338
338
|
}
|
|
339
339
|
>
|
|
@@ -44,7 +44,7 @@ export const Default: Story = {
|
|
|
44
44
|
render: function StoryRender({ withLayer, layer, ...args }: any) {
|
|
45
45
|
const [value, setValue] = useState<string>("");
|
|
46
46
|
return (
|
|
47
|
-
<RadioGroup {...args} value={value} onChange={setValue}>
|
|
47
|
+
<RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
|
|
48
48
|
<Radio value="1" label="Option 1" />
|
|
49
49
|
<Radio value="2" label="Option 2" />
|
|
50
50
|
</RadioGroup>
|
|
@@ -63,7 +63,7 @@ export const StackedLayout: Story = {
|
|
|
63
63
|
render: function StoryRender({ withLayer, layer, ...args }: any) {
|
|
64
64
|
const [value, setValue] = useState<string>("aws");
|
|
65
65
|
return (
|
|
66
|
-
<RadioGroup {...args} value={value} onChange={setValue}>
|
|
66
|
+
<RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
|
|
67
67
|
<Radio value="aws" label="Amazon Web Services" />
|
|
68
68
|
<Radio
|
|
69
69
|
value="gcp"
|
|
@@ -87,7 +87,7 @@ export const SideBySideLayout: Story = {
|
|
|
87
87
|
render: function StoryRender({ withLayer, layer, ...args }: any) {
|
|
88
88
|
const [value, setValue] = useState<string>("us-east");
|
|
89
89
|
return (
|
|
90
|
-
<RadioGroup {...args} value={value} onChange={setValue}>
|
|
90
|
+
<RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
|
|
91
91
|
<Radio value="us-east" label="US East (N. Virginia)" />
|
|
92
92
|
<Radio value="us-west" label="US West (Oregon)" />
|
|
93
93
|
<Radio value="eu-central" label="EU Central (Frankfurt)" />
|
|
@@ -108,7 +108,7 @@ export const ReadOnly: Story = {
|
|
|
108
108
|
render: function StoryRender({ withLayer, layer, ...args }: any) {
|
|
109
109
|
const [value, setValue] = useState<string>("react");
|
|
110
110
|
return (
|
|
111
|
-
<RadioGroup {...args} value={value} onChange={setValue}>
|
|
111
|
+
<RadioGroup {...args} value={value} onChange={(_e, val) => setValue(val)}>
|
|
112
112
|
<Radio value="react" label="React" />
|
|
113
113
|
<Radio value="vue" label="Vue" />
|
|
114
114
|
</RadioGroup>
|
|
@@ -18,13 +18,7 @@ import { type RecursicaRadioGroupProps as BaseRecursicaRadioGroupProps } from "@
|
|
|
18
18
|
export interface RecursicaRadioGroupProps
|
|
19
19
|
extends Omit<
|
|
20
20
|
MuiRadioGroupProps,
|
|
21
|
-
| "
|
|
22
|
-
| "labelProps"
|
|
23
|
-
| "classes"
|
|
24
|
-
| "ref"
|
|
25
|
-
| "onChange"
|
|
26
|
-
| "value"
|
|
27
|
-
| "defaultValue"
|
|
21
|
+
"size" | "labelProps" | "classes" | "ref" | "value" | "defaultValue"
|
|
28
22
|
>,
|
|
29
23
|
Omit<
|
|
30
24
|
RecursicaFormControlWrapperProps,
|
|
@@ -70,7 +64,6 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
|
70
64
|
emptyValueComponent,
|
|
71
65
|
value,
|
|
72
66
|
defaultValue,
|
|
73
|
-
onChange,
|
|
74
67
|
...rest
|
|
75
68
|
} = props;
|
|
76
69
|
// NOTE: MuiRadioGroupProps (via FormGroupProps) has no native "size" (or other
|
|
@@ -111,10 +104,6 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
|
111
104
|
{...(sanitizedProps as unknown as MuiRadioGroupProps)}
|
|
112
105
|
value={value}
|
|
113
106
|
defaultValue={defaultValue}
|
|
114
|
-
// MUI natively calls onChange(event, value); the Recursica contract is
|
|
115
|
-
// single-argument (value only), matching Mantine's native RadioGroup
|
|
116
|
-
// onChange (the cross-adapter source of truth) — normalize here.
|
|
117
|
-
onChange={onChange ? (_event, val) => onChange(val) : undefined}
|
|
118
107
|
className={styles.groupRoot}
|
|
119
108
|
data-layout={formLayout}
|
|
120
109
|
>
|
|
@@ -4,3 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
- **Root cause:** `--recursica_ui-kit_components_segmented-control-item_variants_selection-states_{unselected,selected}_properties_text_text-transform` resolves to `--recursica_tokens_font_cases_original`, which has no definition in `recursica_variables_scoped.css` (only `_lowercase`/`_titlecase`/`_uppercase` are defined there). The resulting `var()` on `.label` is invalid, and since `text-transform` is an inherited property, the invalid value falls back to the inherited value from `.control` (`.MuiToggleButton-root`) — which carries MUI's own `text-transform: uppercase` button default. Mantine's control has no such native uppercase default, so the same broken token never surfaced there.
|
|
6
6
|
- **Fix:** Reset `text-transform: none` on `.root .control` alongside the other MUI ToggleButton baseline resets (padding/border/etc.) already there, so nothing uppercase is left to inherit. Matches the existing `text-transform: none` MUI-baseline reset pattern in `Button.module.css`. Not a design-token value — it's a structural reset of MUI's own default, same category as the other hardcoded resets already exempted at the top of this file.
|
|
7
|
+
|
|
8
|
+
## Per-item `icon`
|
|
9
|
+
|
|
10
|
+
`RecursicaSegmentedControlProps.data` objects accept an optional `icon`, rendered ahead of `label` inside the existing `.label` div (already a flex container with the icon-size/gap tokens wired), so no CSS changes were needed.
|
|
11
|
+
|
|
12
|
+
## Whole-control `disabled`
|
|
13
|
+
|
|
14
|
+
`RecursicaSegmentedControlProps.disabled` was previously typed `never` and stripped at runtime. It's now a real `boolean?`, passed straight through to MUI's native `ToggleButtonGroup.disabled`, which cascades to every child `ToggleButton` via `ToggleButtonGroupContext`. Per-item `data[].disabled` now passes `undefined` (not `false`) when unset, so it doesn't mask that context cascade — an item's own explicit `disabled` still overrides the group.
|
|
@@ -21,7 +21,9 @@ const meta: Meta<typeof SegmentedControl> = {
|
|
|
21
21
|
fullWidth: {
|
|
22
22
|
control: "boolean",
|
|
23
23
|
},
|
|
24
|
-
disabled: {
|
|
24
|
+
disabled: {
|
|
25
|
+
control: "boolean",
|
|
26
|
+
},
|
|
25
27
|
data: { table: { disable: true } },
|
|
26
28
|
defaultChecked: { table: { disable: true } },
|
|
27
29
|
},
|
|
@@ -64,6 +66,17 @@ export const Vertical: Story = {
|
|
|
64
66
|
},
|
|
65
67
|
};
|
|
66
68
|
|
|
69
|
+
export const Disabled: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
data: ["Preview", "Code", "Edit"],
|
|
72
|
+
disabled: true,
|
|
73
|
+
},
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
|
75
|
+
render: ({ withLayer, layer, ...args }: any) => {
|
|
76
|
+
return <SegmentedControl {...args} />;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
67
80
|
const CheckIcon = () => (
|
|
68
81
|
<svg
|
|
69
82
|
viewBox="0 0 24 24"
|
|
@@ -81,33 +94,9 @@ const CheckIcon = () => (
|
|
|
81
94
|
export const WithIcons: Story = {
|
|
82
95
|
args: {
|
|
83
96
|
data: [
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<>
|
|
88
|
-
<CheckIcon />
|
|
89
|
-
<span>Daily</span>
|
|
90
|
-
</>
|
|
91
|
-
),
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
value: "weekly",
|
|
95
|
-
label: (
|
|
96
|
-
<>
|
|
97
|
-
<CheckIcon />
|
|
98
|
-
<span>Weekly</span>
|
|
99
|
-
</>
|
|
100
|
-
),
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
value: "monthly",
|
|
104
|
-
label: (
|
|
105
|
-
<>
|
|
106
|
-
<CheckIcon />
|
|
107
|
-
<span>Monthly</span>
|
|
108
|
-
</>
|
|
109
|
-
),
|
|
110
|
-
},
|
|
97
|
+
{ value: "daily", label: "Daily", icon: <CheckIcon /> },
|
|
98
|
+
{ value: "weekly", label: "Weekly", icon: <CheckIcon /> },
|
|
99
|
+
{ value: "monthly", label: "Monthly", icon: <CheckIcon /> },
|
|
111
100
|
],
|
|
112
101
|
},
|
|
113
102
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|