@recursica/mui-adapter 0.23.0 → 0.25.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 +39 -0
- package/dist/index.d.ts +287 -15
- package/dist/mui-adapter.cjs +71 -71
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +9554 -8500
- package/dist/mui-adapter.js.map +1 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/components/AssistiveElement/AssistiveElement.tsx +1 -1
- package/src/components/Button/BUTTON_IMPLEMENTATION_NOTES.md +12 -0
- package/src/components/Button/Button.module.css +6 -5
- package/src/components/Checkbox/Checkbox.tsx +4 -1
- package/src/components/FileInput/FILEINPUT_IMPLEMENTATION_NOTES.md +148 -0
- package/src/components/FileInput/FileInput.module.css +268 -43
- package/src/components/FileInput/FileInput.stories.tsx +296 -4
- package/src/components/FileInput/FileInput.tsx +412 -6
- package/src/components/FileInput/USAGE.md +112 -4
- package/src/components/Popover/IMPLEMENTATION_NOTES.md +22 -0
- package/src/components/Popover/Popover.module.css +123 -0
- package/src/components/Popover/Popover.stories.tsx +133 -0
- package/src/components/Popover/Popover.tsx +275 -0
- package/src/components/Popover/USAGE.md +69 -0
- package/src/components/Popover/index.ts +1 -0
- package/src/components/SegmentedControl/IMPLEMENTATION_NOTES.md +6 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +33 -4
- package/src/components/SegmentedControl/SegmentedControl.tsx +18 -4
- package/src/components/Slider/IMPLEMENTATION_NOTES.md +29 -0
- package/src/components/Slider/Slider.module.css +80 -17
- package/src/components/Slider/Slider.stories.tsx +1 -1
- package/src/components/Slider/Slider.tsx +36 -1
- package/src/components/Stepper/IMPLEMENTATION_NOTES.md +54 -0
- package/src/components/Stepper/Stepper.module.css +139 -106
- package/src/components/Stepper/Stepper.tsx +76 -10
- package/src/components/Stepper/USAGE.md +4 -0
- package/src/components/Tabs/IMPLEMENTATION_NOTES.md +12 -0
- package/src/components/Tabs/Tabs.module.css +107 -24
- package/src/components/Tabs/Tabs.tsx +1 -0
- package/src/components/TextArea/TextArea.module.css +20 -4
- package/src/components/TextArea/TextArea.tsx +12 -23
- package/src/components/Timeline/IMPLEMENTATION_NOTES.md +24 -3
- package/src/components/Timeline/Timeline.module.css +56 -68
- package/src/components/Timeline/Timeline.tsx +23 -27
- package/src/components/Timeline/TimelineItem.tsx +38 -35
- package/src/components/TransferList/TRANSFERLIST_IMPLEMENTATION_NOTES.md +140 -0
- package/src/components/TransferList/TransferList.module.css +179 -35
- package/src/components/TransferList/TransferList.stories.tsx +110 -6
- package/src/components/TransferList/TransferList.tsx +417 -8
- package/src/components/TransferList/USAGE.md +37 -6
- package/src/components/index.ts +1 -0
- package/src/index.ts +6 -1
|
@@ -1,8 +1,414 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
filterStylingProps,
|
|
4
|
+
type RecursicaOverStyled,
|
|
5
|
+
} from "../../utils/filterStylingProps";
|
|
6
|
+
import { Button } from "../Button/Button";
|
|
7
|
+
import { Chip } from "../Chip/Chip";
|
|
8
|
+
import {
|
|
9
|
+
FormControlWrapper,
|
|
10
|
+
type RecursicaFormControlWrapperProps,
|
|
11
|
+
} from "../FormControlWrapper/FormControlWrapper";
|
|
12
|
+
import styles from "./FileInput.module.css";
|
|
3
13
|
|
|
4
|
-
|
|
14
|
+
import {
|
|
15
|
+
fileMatchesAccept,
|
|
16
|
+
type RecursicaFileUploadItem,
|
|
17
|
+
type RecursicaFileInputProps as BaseRecursicaFileInputProps,
|
|
18
|
+
} from "@recursica/adapter-common";
|
|
19
|
+
export type { RecursicaFileUploadItem };
|
|
5
20
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
21
|
+
export interface RecursicaFileInputProps
|
|
22
|
+
extends Omit<
|
|
23
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
24
|
+
"children" | "onDrop" | "onChange"
|
|
25
|
+
>,
|
|
26
|
+
Omit<
|
|
27
|
+
RecursicaFormControlWrapperProps,
|
|
28
|
+
"controlMaxWidth" | "controlMinWidth"
|
|
29
|
+
>,
|
|
30
|
+
BaseRecursicaFileInputProps {
|
|
31
|
+
/** Visually forces the mandatory asterisk. Accepted for API parity with other adapters; MUI's own `required` already renders the asterisk, so this has no separate effect here. */
|
|
32
|
+
withAsterisk?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type FileInputProps = RecursicaOverStyled<RecursicaFileInputProps>;
|
|
36
|
+
|
|
37
|
+
function UploadIcon() {
|
|
38
|
+
return (
|
|
39
|
+
<svg
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
fill="none"
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
strokeWidth="2"
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
aria-hidden
|
|
48
|
+
>
|
|
49
|
+
<path d="M12 3v12" />
|
|
50
|
+
<path d="M7 8l5-5 5 5" />
|
|
51
|
+
<path d="M4 21h16" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ClearIcon() {
|
|
57
|
+
return (
|
|
58
|
+
<svg
|
|
59
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="currentColor"
|
|
63
|
+
strokeWidth="2"
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
aria-hidden
|
|
67
|
+
>
|
|
68
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
69
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
70
|
+
</svg>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A single-line, `TextField`-shaped control for choosing one or more files, sharing
|
|
76
|
+
* `FileUpload`'s selection/validation interface (`accept`/`maxSize`/`maxFiles`, `readOnly`)
|
|
77
|
+
* behind a different presentation.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* <FileInput
|
|
81
|
+
* label="Resume"
|
|
82
|
+
* files={files}
|
|
83
|
+
* onFilesAdded={(added) => setFiles(added.map((file) => ({ file })))}
|
|
84
|
+
* onFileRemove={() => setFiles([])}
|
|
85
|
+
* />
|
|
86
|
+
*/
|
|
87
|
+
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
|
88
|
+
function FileInput(props, ref) {
|
|
89
|
+
const {
|
|
90
|
+
overStyled = false,
|
|
91
|
+
formLayout = "stacked",
|
|
92
|
+
|
|
93
|
+
// Label & Wrapper Maps
|
|
94
|
+
labelSize,
|
|
95
|
+
labelAlignment,
|
|
96
|
+
labelOptionalText,
|
|
97
|
+
labelWithEditIcon,
|
|
98
|
+
labelActionArea,
|
|
99
|
+
onLabelEditClick,
|
|
100
|
+
|
|
101
|
+
label,
|
|
102
|
+
assistiveText,
|
|
103
|
+
assistiveWithIcon,
|
|
104
|
+
error,
|
|
105
|
+
required,
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
107
|
+
withAsterisk,
|
|
108
|
+
id,
|
|
109
|
+
className,
|
|
110
|
+
style,
|
|
111
|
+
disabled,
|
|
112
|
+
readOnly,
|
|
113
|
+
|
|
114
|
+
files,
|
|
115
|
+
onFilesAdded,
|
|
116
|
+
onFileRemove,
|
|
117
|
+
accept,
|
|
118
|
+
multiple = false,
|
|
119
|
+
maxSize,
|
|
120
|
+
maxFiles,
|
|
121
|
+
onFilesRejected,
|
|
122
|
+
invalidFileTypeMessage = "File type not accepted",
|
|
123
|
+
maxFilesMessage = multiple
|
|
124
|
+
? `Maximum of ${maxFiles} files allowed`
|
|
125
|
+
: "Only one file is allowed",
|
|
126
|
+
icon,
|
|
127
|
+
placeholder = "Select a file...",
|
|
128
|
+
browseLabel = "Choose file",
|
|
129
|
+
removeFileLabel = "Remove",
|
|
130
|
+
clearLabel = "Clear",
|
|
131
|
+
...rest
|
|
132
|
+
} = props;
|
|
133
|
+
|
|
134
|
+
const sanitizedProps = filterStylingProps(rest, overStyled);
|
|
135
|
+
const restRecord = sanitizedProps as Record<string, unknown>;
|
|
136
|
+
|
|
137
|
+
const interactive = !disabled && !readOnly;
|
|
138
|
+
const hasFiles = !!files && files.length > 0;
|
|
139
|
+
|
|
140
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
141
|
+
|
|
142
|
+
// Whether the most recent drop/pick attempt included a file that failed the `accept` check —
|
|
143
|
+
// surfaced as the control's error state (see `effectiveError` below), same as FileUpload.
|
|
144
|
+
const [invalidTypeRejected, setInvalidTypeRejected] = useState(false);
|
|
145
|
+
// Whether the most recent drop/pick attempt included a file past the effective cap — 1 in
|
|
146
|
+
// single-file mode, `maxFiles` in multiple-file mode.
|
|
147
|
+
const [tooManyFilesRejected, setTooManyFilesRejected] = useState(false);
|
|
148
|
+
|
|
149
|
+
const handleFiles = (incoming: FileList | File[]) => {
|
|
150
|
+
if (!interactive) return;
|
|
151
|
+
const list = Array.from(incoming);
|
|
152
|
+
if (list.length === 0) return;
|
|
153
|
+
|
|
154
|
+
// Single-file mode always replaces the current selection rather than adding to it, so it
|
|
155
|
+
// never counts the existing file against the cap — the effective cap is just 1.
|
|
156
|
+
const effectiveMaxFiles = multiple ? maxFiles : 1;
|
|
157
|
+
const currentCount = multiple ? (files?.length ?? 0) : 0;
|
|
158
|
+
|
|
159
|
+
const accepted: File[] = [];
|
|
160
|
+
const rejected: File[] = [];
|
|
161
|
+
let hasInvalidType = false;
|
|
162
|
+
let hasTooMany = false;
|
|
163
|
+
for (const file of list) {
|
|
164
|
+
const isInvalidType = !fileMatchesAccept(file, accept);
|
|
165
|
+
if (isInvalidType) hasInvalidType = true;
|
|
166
|
+
const isTooLarge = maxSize !== undefined && file.size > maxSize;
|
|
167
|
+
const wouldExceedMax =
|
|
168
|
+
effectiveMaxFiles !== undefined &&
|
|
169
|
+
currentCount + accepted.length >= effectiveMaxFiles;
|
|
170
|
+
if (wouldExceedMax) hasTooMany = true;
|
|
171
|
+
const isRejected = isInvalidType || isTooLarge || wouldExceedMax;
|
|
172
|
+
(isRejected ? rejected : accepted).push(file);
|
|
173
|
+
}
|
|
174
|
+
setInvalidTypeRejected(hasInvalidType);
|
|
175
|
+
setTooManyFilesRejected(hasTooMany);
|
|
176
|
+
if (accepted.length > 0) onFilesAdded?.(accepted);
|
|
177
|
+
if (rejected.length > 0) onFilesRejected?.(rejected);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Counts nested dragenter/dragleave pairs (they fire for every child element the pointer
|
|
181
|
+
// crosses, not just the root itself) so the drag-over visual state only clears once the
|
|
182
|
+
// pointer has actually left the control, not just moved between its children. Mirrors
|
|
183
|
+
// FileUpload's dropzone.
|
|
184
|
+
const dragCounterRef = useRef(0);
|
|
185
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
186
|
+
|
|
187
|
+
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
dragCounterRef.current += 1;
|
|
190
|
+
setIsDragging(true);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
dragCounterRef.current -= 1;
|
|
196
|
+
if (dragCounterRef.current <= 0) {
|
|
197
|
+
dragCounterRef.current = 0;
|
|
198
|
+
setIsDragging(false);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
203
|
+
// Required so the browser treats this element as a valid drop target.
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
dragCounterRef.current = 0;
|
|
210
|
+
setIsDragging(false);
|
|
211
|
+
handleFiles(event.dataTransfer.files);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const openFilePicker = () => {
|
|
215
|
+
if (!interactive) return;
|
|
216
|
+
inputRef.current?.click();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleRootKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
220
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
openFilePicker();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
226
|
+
if (event.target.files) handleFiles(event.target.files);
|
|
227
|
+
// Reset so picking the same file again still fires a change event.
|
|
228
|
+
event.target.value = "";
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const handleClearAll = () => {
|
|
232
|
+
if (!interactive || !files || files.length === 0) return;
|
|
233
|
+
files.forEach((item) => onFileRemove?.(item.id ?? item.file.name));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Roving tabindex across the file chip list (single- or multiple-file mode): only the
|
|
237
|
+
// "active" chip's remove icon is a tab stop, and Left/Right/Up/Down move it — same pattern
|
|
238
|
+
// as FileUpload, see FILEINPUT_IMPLEMENTATION_NOTES.md.
|
|
239
|
+
const [activeChipIndex, setActiveChipIndex] = useState(0);
|
|
240
|
+
const removeIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
|
241
|
+
const prevFileCountRef = useRef(files?.length ?? 0);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const count = files?.length ?? 0;
|
|
245
|
+
if (count > 0 && count < prevFileCountRef.current) {
|
|
246
|
+
const nextIndex = Math.min(activeChipIndex, count - 1);
|
|
247
|
+
setActiveChipIndex(nextIndex);
|
|
248
|
+
removeIconRefs.current[nextIndex]?.focus();
|
|
249
|
+
}
|
|
250
|
+
prevFileCountRef.current = count;
|
|
251
|
+
// Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
|
|
252
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
253
|
+
}, [files]);
|
|
254
|
+
|
|
255
|
+
const handleChipRowKeyDown = (
|
|
256
|
+
event: React.KeyboardEvent<HTMLDivElement>,
|
|
257
|
+
) => {
|
|
258
|
+
const count = files?.length ?? 0;
|
|
259
|
+
if (count === 0) return;
|
|
260
|
+
let nextIndex: number | undefined;
|
|
261
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
262
|
+
nextIndex = (activeChipIndex + 1) % count;
|
|
263
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
264
|
+
nextIndex = (activeChipIndex - 1 + count) % count;
|
|
265
|
+
}
|
|
266
|
+
if (nextIndex === undefined) return;
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
setActiveChipIndex(nextIndex);
|
|
270
|
+
removeIconRefs.current[nextIndex]?.focus();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// The built-in `accept`/cap-mismatch message is only shown when the integrator hasn't
|
|
274
|
+
// supplied their own `error` — an explicit error always wins.
|
|
275
|
+
const effectiveError =
|
|
276
|
+
error ??
|
|
277
|
+
(invalidTypeRejected
|
|
278
|
+
? invalidFileTypeMessage
|
|
279
|
+
: tooManyFilesRejected
|
|
280
|
+
? maxFilesMessage
|
|
281
|
+
: undefined);
|
|
282
|
+
|
|
283
|
+
const wrapperClass = className
|
|
284
|
+
? `${styles.layoutOverride} ${className}`
|
|
285
|
+
: styles.layoutOverride;
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<FormControlWrapper
|
|
289
|
+
overStyled={overStyled}
|
|
290
|
+
className={wrapperClass}
|
|
291
|
+
style={style}
|
|
292
|
+
formLayout={formLayout}
|
|
293
|
+
labelSize={labelSize}
|
|
294
|
+
labelAlignment={labelAlignment}
|
|
295
|
+
labelOptionalText={labelOptionalText}
|
|
296
|
+
labelWithEditIcon={labelWithEditIcon}
|
|
297
|
+
labelActionArea={labelActionArea}
|
|
298
|
+
onLabelEditClick={onLabelEditClick}
|
|
299
|
+
label={label}
|
|
300
|
+
assistiveText={assistiveText}
|
|
301
|
+
assistiveWithIcon={assistiveWithIcon}
|
|
302
|
+
error={effectiveError}
|
|
303
|
+
required={required}
|
|
304
|
+
disabled={disabled}
|
|
305
|
+
id={id}
|
|
306
|
+
controlMaxWidth="var(--file-input-control-max-width)"
|
|
307
|
+
controlMinWidth="var(--file-input-control-min-width)"
|
|
308
|
+
>
|
|
309
|
+
<div
|
|
310
|
+
ref={ref}
|
|
311
|
+
className={styles.root}
|
|
312
|
+
role="button"
|
|
313
|
+
aria-label={browseLabel}
|
|
314
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
315
|
+
tabIndex={interactive ? 0 : -1}
|
|
316
|
+
data-disabled={disabled ? "true" : undefined}
|
|
317
|
+
data-readonly={readOnly ? "true" : undefined}
|
|
318
|
+
data-error={effectiveError ? "true" : undefined}
|
|
319
|
+
data-dragging={isDragging ? "true" : undefined}
|
|
320
|
+
onClick={interactive ? openFilePicker : undefined}
|
|
321
|
+
onKeyDown={interactive ? handleRootKeyDown : undefined}
|
|
322
|
+
onDragEnter={interactive ? handleDragEnter : undefined}
|
|
323
|
+
onDragLeave={interactive ? handleDragLeave : undefined}
|
|
324
|
+
onDragOver={interactive ? handleDragOver : undefined}
|
|
325
|
+
onDrop={interactive ? handleDrop : undefined}
|
|
326
|
+
{...restRecord}
|
|
327
|
+
>
|
|
328
|
+
<span className={styles.leadingIcon} aria-hidden>
|
|
329
|
+
{icon ?? <UploadIcon />}
|
|
330
|
+
</span>
|
|
331
|
+
|
|
332
|
+
<div className={styles.content}>
|
|
333
|
+
{!hasFiles && (
|
|
334
|
+
<span className={styles.value} data-placeholder="true">
|
|
335
|
+
{placeholder}
|
|
336
|
+
</span>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{hasFiles && (
|
|
340
|
+
<div
|
|
341
|
+
className={styles.chipRow}
|
|
342
|
+
onKeyDown={readOnly ? undefined : handleChipRowKeyDown}
|
|
343
|
+
>
|
|
344
|
+
{files!.map((item: RecursicaFileUploadItem, index) => {
|
|
345
|
+
const itemId = item.id ?? item.file.name;
|
|
346
|
+
return (
|
|
347
|
+
<span
|
|
348
|
+
key={itemId}
|
|
349
|
+
className={styles.chipWrapper}
|
|
350
|
+
onClick={(e) => e.stopPropagation()}
|
|
351
|
+
>
|
|
352
|
+
<Chip
|
|
353
|
+
tabIndex={-1}
|
|
354
|
+
removeLabel={readOnly ? undefined : removeFileLabel}
|
|
355
|
+
removeTabIndex={
|
|
356
|
+
!readOnly && index === activeChipIndex ? 0 : -1
|
|
357
|
+
}
|
|
358
|
+
removeIconRef={(el) => {
|
|
359
|
+
removeIconRefs.current[index] = el;
|
|
360
|
+
}}
|
|
361
|
+
onRemove={
|
|
362
|
+
readOnly || disabled
|
|
363
|
+
? undefined
|
|
364
|
+
: () => onFileRemove?.(itemId)
|
|
365
|
+
}
|
|
366
|
+
>
|
|
367
|
+
{item.file.name}
|
|
368
|
+
</Chip>
|
|
369
|
+
</span>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
{hasFiles && !readOnly && (
|
|
377
|
+
<Button
|
|
378
|
+
overStyled
|
|
379
|
+
variant="text"
|
|
380
|
+
size="small"
|
|
381
|
+
icon={<ClearIcon />}
|
|
382
|
+
aria-label={clearLabel}
|
|
383
|
+
className={styles.trailingIcon}
|
|
384
|
+
disabled={disabled}
|
|
385
|
+
onClick={(e) => {
|
|
386
|
+
e.preventDefault();
|
|
387
|
+
e.stopPropagation();
|
|
388
|
+
handleClearAll();
|
|
389
|
+
}}
|
|
390
|
+
onKeyDown={(e) => {
|
|
391
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
e.stopPropagation();
|
|
394
|
+
handleClearAll();
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
<input
|
|
400
|
+
ref={inputRef}
|
|
401
|
+
type="file"
|
|
402
|
+
hidden
|
|
403
|
+
accept={accept}
|
|
404
|
+
multiple={multiple}
|
|
405
|
+
disabled={!interactive}
|
|
406
|
+
onChange={handleInputChange}
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
</FormControlWrapper>
|
|
410
|
+
);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
FileInput.displayName = "FileInput";
|
|
@@ -14,23 +14,131 @@ import { FileInput } from "@recursica/mui-adapter";
|
|
|
14
14
|
|
|
15
15
|
## 2. Basic Example
|
|
16
16
|
|
|
17
|
+
`FileInput` is a **controlled** component: it never stores the selected file(s) itself. `onFilesAdded` reports newly picked/dropped files, `onFileRemove` reports which file was removed (or cleared), and you own the `files` array in between.
|
|
18
|
+
|
|
19
|
+
It shares `FileUpload`'s selection/validation interface, but is presented as a single-line, `TextField`-shaped control instead of a dropzone — every selected file renders as a removable chip in a horizontally scrollable row, whether `multiple` is set or not.
|
|
20
|
+
|
|
17
21
|
```tsx
|
|
18
|
-
import React from "react";
|
|
22
|
+
import React, { useState } from "react";
|
|
19
23
|
import { FileInput } from "@recursica/mui-adapter";
|
|
24
|
+
import { type RecursicaFileUploadItem } from "@recursica/adapter-common";
|
|
20
25
|
|
|
21
26
|
export default function Demo() {
|
|
22
|
-
|
|
27
|
+
const [files, setFiles] = useState<RecursicaFileUploadItem[]>([]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<FileInput
|
|
31
|
+
label="Resume"
|
|
32
|
+
files={files}
|
|
33
|
+
onFilesAdded={(added) => setFiles(added.map((file) => ({ file })))}
|
|
34
|
+
onFileRemove={() => setFiles([])}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
23
37
|
}
|
|
24
38
|
```
|
|
25
39
|
|
|
40
|
+
Note that a single-file `onFilesAdded` handler typically **replaces** `files` wholesale (as above)
|
|
41
|
+
rather than appending — picking a new file in single-file mode always replaces the current one.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 3. Props Reference
|
|
46
|
+
|
|
47
|
+
| Prop | Type | Description |
|
|
48
|
+
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
49
|
+
| `files` | `RecursicaFileUploadItem[]` | Files currently selected. Each item is `{ file: File; id?: string }`. |
|
|
50
|
+
| `onFilesAdded` | `(files: File[]) => void` | Called with newly picked/dropped files. Only the new files — merge them into `files` yourself. |
|
|
51
|
+
| `onFileRemove` | `(id: string) => void` | Called with a file's `id` (or `file.name` if no `id` was given) when it's removed via a chip's remove icon or the trailing clear button. |
|
|
52
|
+
| `accept` | `string` | Native `accept` attribute (e.g. `".pdf,.png"` or `"image/*"`) — constrains the picker dialog, and is also enforced against dropped files (via `onFilesRejected`), since the browser never applies `accept` to a `drop` event itself. |
|
|
53
|
+
| `multiple` | `boolean` | Whether more than one file can be selected/dropped at once. Defaults to `false`, unlike `FileUpload` (defaults to `true`). |
|
|
54
|
+
| `maxSize` | `number` | Maximum size per file, in bytes. Oversized files go to `onFilesRejected` instead of `onFilesAdded`. |
|
|
55
|
+
| `maxFiles` | `number` | Maximum total number of files allowed. Only meaningful when `multiple` is `true` — single-file mode always caps at 1 regardless of this prop. |
|
|
56
|
+
| `onFilesRejected` | `(files: File[]) => void` | Called with files rejected for exceeding `maxSize`/`maxFiles` (or the single-file cap) or not matching `accept`. |
|
|
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
|
+
| `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
|
+
| `icon` | `React.ReactNode` | Leading icon shown inside the control. Defaults to the built-in upload icon. |
|
|
60
|
+
| `placeholder` | `React.ReactNode` | Text shown when no file is selected. Defaults to `"Select a file..."`. |
|
|
61
|
+
| `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
|
+
| `removeFileLabel` | `string` | Screen-reader label for a file chip's remove button. Defaults to `"Remove"`. |
|
|
63
|
+
| `clearLabel` | `string` | Screen-reader label (`aria-label`) for the trailing clear-all `Button`. Defaults to `"Clear"`. |
|
|
64
|
+
| `disabled` | `boolean` | Disables the control and its clear/remove icons. |
|
|
65
|
+
| `readOnly` | `boolean` | Renders `files` as a static, non-interactive display with no clear/remove icons, and disables picking or dropping new files. |
|
|
66
|
+
|
|
67
|
+
`FileInput` also accepts the standard Recursica form-control props (`label`, `assistiveText`, `error`, `required`, `withAsterisk`, `formLayout`, `labelSize`, `labelAlignment`, `labelOptionalText`, `labelWithEditIcon`, `onLabelEditClick`) — see [FormControlWrapper's USAGE.md](../FormControlWrapper/USAGE.md) for how these behave.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 4. Multiple Files
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
<FileInput
|
|
75
|
+
label="Attachments"
|
|
76
|
+
assistiveText="Up to 5 files"
|
|
77
|
+
multiple
|
|
78
|
+
files={files}
|
|
79
|
+
onFilesAdded={(added) =>
|
|
80
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
81
|
+
}
|
|
82
|
+
onFileRemove={(id) =>
|
|
83
|
+
setFiles((prev) =>
|
|
84
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
With `multiple`, the same horizontally scrollable row of removable chips (the same `Chip`
|
|
91
|
+
component `FileUpload` uses) can hold more than one file, and the trailing `Button` clears the
|
|
92
|
+
entire selection at once rather than removing a single file.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 5. Rejecting Oversized or Wrong-Type Files
|
|
97
|
+
|
|
98
|
+
Works exactly like `FileUpload`:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<FileInput
|
|
102
|
+
label="Resume"
|
|
103
|
+
assistiveText="PDF only, max 5MB"
|
|
104
|
+
accept=".pdf"
|
|
105
|
+
maxSize={5 * 1024 * 1024}
|
|
106
|
+
files={files}
|
|
107
|
+
onFilesAdded={(added) => setFiles(added.map((file) => ({ file })))}
|
|
108
|
+
onFileRemove={() => setFiles([])}
|
|
109
|
+
/>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A mismatched or oversized file puts the control into its error state automatically — no need to
|
|
113
|
+
wire `onFilesRejected` into your own `error` prop just to show something. Override the message
|
|
114
|
+
with `invalidFileTypeMessage`/`maxFilesMessage`, or pass your own `error` prop to take over the
|
|
115
|
+
error state entirely.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 6. Read-Only Display
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<FileInput
|
|
123
|
+
label="Submitted Files"
|
|
124
|
+
assistiveText="Submitted files cannot be changed"
|
|
125
|
+
multiple
|
|
126
|
+
readOnly
|
|
127
|
+
files={files}
|
|
128
|
+
/>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Renders `files` as a static display with no clear/remove icons, and the control is no longer
|
|
132
|
+
focusable or clickable.
|
|
133
|
+
|
|
26
134
|
---
|
|
27
135
|
|
|
28
|
-
##
|
|
136
|
+
## 7. Design System Integration
|
|
29
137
|
|
|
30
138
|
All Recursica components in the `@recursica/mui-adapter` package adhere strictly to design system spacing, scaling, and behavior patterns.
|
|
31
139
|
|
|
32
140
|
> [!IMPORTANT]
|
|
33
141
|
>
|
|
34
|
-
> - **Anti-override protection**:
|
|
142
|
+
> - **Anti-override protection**: Rogue style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
|
|
35
143
|
> - **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
144
|
> - **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.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Popover Implementation Notes
|
|
2
|
+
|
|
3
|
+
- **Built on Mui's Tooltip, in click-controlled mode:** Rather than Mui's `Popover`/`Modal` primitives, this component reuses `@mui/material`'s `Tooltip` — the same base HoverCard and Tooltip already use in this adapter — with `disableHoverListener`/`disableFocusListener`/`disableTouchListener` all set, and `open` fully controlled by this component. This keeps the arrow/placement/token-namespace conventions identical to HoverCard and Tooltip instead of introducing a second, unrelated positioning primitive.
|
|
4
|
+
- **Composable API preserved via child extraction:** Like HoverCard, `Popover.Target`/`Popover.Dropdown` are never rendered themselves — `PopoverBase` walks `children` looking for those `displayName`s and extracts their inner content, then renders the target as Tooltip's single child and the dropdown as Tooltip's `title`.
|
|
5
|
+
- **Diverges from HoverCard on missing Target/Dropdown:** HoverCard silently falls back to an empty `<div />` if the expected child isn't found. Popover throws instead — a silent empty popover is a worse failure mode for a click-triggered disclosure than for a hover card, and the repo convention is to let structural misuse fail loudly rather than mask it.
|
|
6
|
+
- **Click toggle + outside-click close is custom:** Mui's Tooltip has no notion of "click to open" or "click outside to close" once its native hover/focus/touch listeners are disabled — Mantine's Popover provides both natively. This component clones the target element to attach an `onClick` toggle handler and a ref, and closes on outside click via a `mousedown` listener on `document` that ignores clicks inside either the target (`targetRef`) or the dropdown content (`dropdownRef`, a wrapper `<div>` around the dropdown children solely for this hit-test — not a styling root). Escape-to-close comes for free from Tooltip's own internal Escape handling once `onClose` is wired up.
|
|
7
|
+
- **`beak-size` token exemption:** Mui's Tooltip arrow is a fixed CSS shape (`1em`/`0.71em`, relative to the tooltip's own `font-size`) with no JS size prop, unlike Mantine's `arrowSize`. There's no hook to bind `--recursica_ui-kit_components_hover-card-popover_properties_beak-size` to, so it's `recursica-ignore`d here (same conceptual gap as HoverCard/Tooltip's existing exemptions for this token family, just for a different reason — those wrote a comment referencing Mantine's inline-pixel arrow calculations, which doesn't apply in this adapter; this file's comment describes the actual Mui-specific reason).
|
|
8
|
+
- **Arrow fill uses `color`, not `border-color`:** HoverCard's and Tooltip's `.arrow` rules in this adapter set `border-color`, which has no visual effect — Mui's Tooltip arrow is a solid rotated-square filled via `background-color: currentColor` in its `::before`, not a bordered shape. This component's `.arrow` instead sets `color` to the panel's `background-color` token, which actually renders. Intentional deviation from the HoverCard/Tooltip precedent for correctness; not fixed there as it's out of scope for this change.
|
|
9
|
+
- **Shared token namespace:** Same as HoverCard, this component's CSS module exclusively uses `--recursica_ui-kit_components_hover-card-popover_*` tokens (geometry, typography, elevation, layer-aware colors) — no Popover-specific token namespace exists in the schema.
|
|
10
|
+
- **`width` and controlled `opened`/`onChange`:** Not present in the shared `RecursicaPopoverProps` (adapter-common only defines `withBeak`), these are defined locally in `PopoverOwnProps` to match Mantine's own `PopoverProps` surface, since there is no single Mui library type that already provides them.
|
|
11
|
+
|
|
12
|
+
## Gap larger than Mantine's on `withBeak={false}` (Matt Massey, 2026-08-19)
|
|
13
|
+
|
|
14
|
+
**Root cause:** Mui's `Tooltip` styled component ships its own hardcoded per-placement margin (`marginTop`/`marginBottom`: `14px`, or `24px` in touch mode) on the tooltip content div, applied whenever `arrow` is falsy (the `arrow` variant resets it to `margin: 0`). This stacked on top of the `offset` popper modifier already applied in `Popover.tsx` (which alone reproduces Mantine's own gap — Mantine's Popover defaults to an 8px `offset`, the same value used here), so the visible gap was `8 + 14 = 22px` instead of `8px` with `withBeak={false}`.
|
|
15
|
+
|
|
16
|
+
**Fix:** neutralize Mui's built-in margin in `Popover.module.css` via `:global(.MuiTooltip-popper[data-popper-placement]) .dropdown { margin: 0; }`, matching Mui's own rule's specificity (class + attribute + class) so it wins on source order under `injectFirst`. The `offset` modifier is now the single source of truth for the gap, matching Mantine.
|
|
17
|
+
|
|
18
|
+
## Beak had no visible edge against the dropdown body (Matt Massey, 2026-08-19)
|
|
19
|
+
|
|
20
|
+
**Root cause:** Mantine's arrow is a bordered shape — `.arrow { border-color: ... }` — visible because Mantine's own arrow implementation sets `border-width` natively. Mui's arrow `::before` pseudo-element has no border at all by default (just `background-color: currentColor`), so setting only `color` (as this component already did, to fill the triangle) left it with zero visible edge against a similarly-colored panel.
|
|
21
|
+
|
|
22
|
+
**Fix:** added `.arrow::before { border-style: solid; border-width: ...border-size; border-color: ...colors_border-color; }`, the same border tokens the `.dropdown` container itself uses — verified live (Playwright) that the arrow now shows a visible border matching Mantine's.
|