@recursica/mantine-adapter 0.38.1 → 0.40.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 +51 -0
- package/dist/index.d.ts +186 -20
- package/dist/mantine-adapter.cjs +2 -2
- package/dist/mantine-adapter.cjs.map +1 -1
- package/dist/mantine-adapter.css +1 -1
- package/dist/mantine-adapter.js +2724 -2491
- package/dist/mantine-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +16 -2
- package/src/components/Accordion/Accordion.module.css +10 -1
- package/src/components/Accordion/Accordion.stories.tsx +36 -0
- package/src/components/Accordion/Accordion.tsx +40 -7
- package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +45 -0
- package/src/components/AssistiveElement/AssistiveElement.tsx +8 -1
- package/src/components/AutoComplete/AutoComplete.tsx +1 -4
- package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +10 -0
- package/src/components/Button/Button.module.css +13 -0
- package/src/components/Chip/CHIP_IMPLEMENTATION_NOTES.md +59 -0
- package/src/components/Chip/Chip.module.css +36 -4
- package/src/components/Chip/Chip.tsx +14 -5
- package/src/components/Chip/USAGE.md +7 -0
- package/src/components/FileUpload/FILEUPLOAD_IMPLEMENTATION_NOTES.md +244 -0
- package/src/components/FileUpload/FileUpload.module.css +204 -42
- package/src/components/FileUpload/FileUpload.stories.tsx +348 -4
- package/src/components/FileUpload/FileUpload.tsx +352 -5
- package/src/components/FileUpload/USAGE.md +163 -5
- package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +36 -0
- package/src/components/Switch/Switch.module.css +19 -2
- package/src/components/Switch/SwitchGroup.tsx +6 -3
- package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +10 -0
- package/src/components/TimePicker/TimePicker.tsx +1 -1
|
@@ -1,7 +1,354 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { type InputWrapperProps } from "@mantine/core";
|
|
3
|
+
import {
|
|
4
|
+
filterStylingProps,
|
|
5
|
+
type RecursicaOverStyled,
|
|
6
|
+
} from "../../utils/filterStylingProps";
|
|
7
|
+
import { Button } from "../Button/Button";
|
|
8
|
+
import { Chip } from "../Chip/Chip";
|
|
9
|
+
import {
|
|
10
|
+
FormControlWrapper,
|
|
11
|
+
type RecursicaFormControlWrapperProps,
|
|
12
|
+
} from "../FormControlWrapper/FormControlWrapper";
|
|
13
|
+
import styles from "./FileUpload.module.css";
|
|
2
14
|
|
|
3
|
-
|
|
15
|
+
import {
|
|
16
|
+
fileMatchesAccept,
|
|
17
|
+
type RecursicaFileUploadItem,
|
|
18
|
+
type RecursicaFileUploadProps as BaseRecursicaFileUploadProps,
|
|
19
|
+
} from "@recursica/adapter-common";
|
|
20
|
+
export type { RecursicaFileUploadItem };
|
|
4
21
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
22
|
+
export interface RecursicaFileUploadProps
|
|
23
|
+
extends Omit<
|
|
24
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
25
|
+
"children" | "onDrop" | "onChange"
|
|
26
|
+
>,
|
|
27
|
+
Pick<
|
|
28
|
+
InputWrapperProps,
|
|
29
|
+
"label" | "error" | "required" | "withAsterisk" | "id"
|
|
30
|
+
>,
|
|
31
|
+
Omit<
|
|
32
|
+
RecursicaFormControlWrapperProps,
|
|
33
|
+
"controlMaxWidth" | "controlMinWidth"
|
|
34
|
+
>,
|
|
35
|
+
BaseRecursicaFileUploadProps {}
|
|
36
|
+
|
|
37
|
+
export type FileUploadProps = RecursicaOverStyled<RecursicaFileUploadProps>;
|
|
38
|
+
|
|
39
|
+
function UploadIcon() {
|
|
40
|
+
return (
|
|
41
|
+
<svg
|
|
42
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
43
|
+
viewBox="0 0 24 24"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
strokeWidth="2"
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
strokeLinejoin="round"
|
|
49
|
+
aria-hidden
|
|
50
|
+
>
|
|
51
|
+
<path d="M12 3v12" />
|
|
52
|
+
<path d="M7 8l5-5 5 5" />
|
|
53
|
+
<path d="M4 21h16" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A dropzone for uploading files, with a native browse-button fallback and a
|
|
60
|
+
* removable-chip list of the currently selected files.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* <FileUpload
|
|
64
|
+
* label="Upload Files"
|
|
65
|
+
* assistiveText="Max file size 5MB"
|
|
66
|
+
* files={files}
|
|
67
|
+
* onFilesAdded={(added) => setFiles((f) => [...f, ...added.map((file) => ({ file }))])}
|
|
68
|
+
* onFileRemove={(id) => setFiles((f) => f.filter((item) => (item.id ?? item.file.name) !== id))}
|
|
69
|
+
* />
|
|
70
|
+
*/
|
|
71
|
+
export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
|
72
|
+
function FileUpload(props, ref) {
|
|
73
|
+
const {
|
|
74
|
+
overStyled = false,
|
|
75
|
+
formLayout = "stacked",
|
|
76
|
+
|
|
77
|
+
// Label & Wrapper Maps
|
|
78
|
+
labelSize,
|
|
79
|
+
labelAlignment,
|
|
80
|
+
labelOptionalText,
|
|
81
|
+
labelWithEditIcon,
|
|
82
|
+
labelActionArea,
|
|
83
|
+
onLabelEditClick,
|
|
84
|
+
|
|
85
|
+
label,
|
|
86
|
+
assistiveText,
|
|
87
|
+
assistiveWithIcon,
|
|
88
|
+
error,
|
|
89
|
+
required,
|
|
90
|
+
withAsterisk,
|
|
91
|
+
id,
|
|
92
|
+
className,
|
|
93
|
+
style,
|
|
94
|
+
disabled,
|
|
95
|
+
readOnly,
|
|
96
|
+
|
|
97
|
+
files,
|
|
98
|
+
onFilesAdded,
|
|
99
|
+
onFileRemove,
|
|
100
|
+
accept,
|
|
101
|
+
multiple = true,
|
|
102
|
+
maxSize,
|
|
103
|
+
maxFiles,
|
|
104
|
+
onFilesRejected,
|
|
105
|
+
invalidFileTypeMessage = "File type not accepted",
|
|
106
|
+
maxFilesMessage = `Maximum of ${maxFiles} files allowed`,
|
|
107
|
+
icon,
|
|
108
|
+
dropzoneLabel = "Drag and drop files here to upload",
|
|
109
|
+
browseButtonLabel = "Browse files",
|
|
110
|
+
removeFileLabel = "Remove",
|
|
111
|
+
...rest
|
|
112
|
+
} = props;
|
|
113
|
+
|
|
114
|
+
const sanitizedProps = filterStylingProps(rest, overStyled);
|
|
115
|
+
const restRecord = sanitizedProps as Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
118
|
+
|
|
119
|
+
// Whether the most recent drop/pick attempt included a file that failed the `accept` check —
|
|
120
|
+
// surfaced as the control's error state (see `effectiveError` below) rather than left for the
|
|
121
|
+
// integrator to wire up themselves.
|
|
122
|
+
const [invalidTypeRejected, setInvalidTypeRejected] = useState(false);
|
|
123
|
+
// Whether the most recent drop/pick attempt included a file that would have pushed the total
|
|
124
|
+
// past `maxFiles` — surfaced the same way as `invalidTypeRejected` (see `effectiveError`).
|
|
125
|
+
const [tooManyFilesRejected, setTooManyFilesRejected] = useState(false);
|
|
126
|
+
|
|
127
|
+
const handleFiles = (incoming: FileList | File[]) => {
|
|
128
|
+
if (disabled) return;
|
|
129
|
+
const list = Array.from(incoming);
|
|
130
|
+
if (list.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const currentCount = files?.length ?? 0;
|
|
133
|
+
const accepted: File[] = [];
|
|
134
|
+
const rejected: File[] = [];
|
|
135
|
+
let hasInvalidType = false;
|
|
136
|
+
let hasTooMany = false;
|
|
137
|
+
for (const file of list) {
|
|
138
|
+
const isInvalidType = !fileMatchesAccept(file, accept);
|
|
139
|
+
if (isInvalidType) hasInvalidType = true;
|
|
140
|
+
const isTooLarge = maxSize !== undefined && file.size > maxSize;
|
|
141
|
+
const wouldExceedMax =
|
|
142
|
+
maxFiles !== undefined && currentCount + accepted.length >= maxFiles;
|
|
143
|
+
if (wouldExceedMax) hasTooMany = true;
|
|
144
|
+
const isRejected = isInvalidType || isTooLarge || wouldExceedMax;
|
|
145
|
+
(isRejected ? rejected : accepted).push(file);
|
|
146
|
+
}
|
|
147
|
+
setInvalidTypeRejected(hasInvalidType);
|
|
148
|
+
setTooManyFilesRejected(hasTooMany);
|
|
149
|
+
if (accepted.length > 0) onFilesAdded?.(accepted);
|
|
150
|
+
if (rejected.length > 0) onFilesRejected?.(rejected);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Counts nested dragenter/dragleave pairs (they fire for every child element the pointer
|
|
154
|
+
// crosses, not just the dropzone itself) so the drag-over visual state only clears once the
|
|
155
|
+
// pointer has actually left the dropzone, not just moved between its children.
|
|
156
|
+
const dragCounterRef = useRef(0);
|
|
157
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
158
|
+
|
|
159
|
+
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
dragCounterRef.current += 1;
|
|
162
|
+
setIsDragging(true);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
dragCounterRef.current -= 1;
|
|
168
|
+
if (dragCounterRef.current <= 0) {
|
|
169
|
+
dragCounterRef.current = 0;
|
|
170
|
+
setIsDragging(false);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
175
|
+
// Required so the browser treats this element as a valid drop target.
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
dragCounterRef.current = 0;
|
|
182
|
+
setIsDragging(false);
|
|
183
|
+
handleFiles(event.dataTransfer.files);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const openFilePicker = () => {
|
|
187
|
+
if (disabled) return;
|
|
188
|
+
inputRef.current?.click();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
192
|
+
if (event.target.files) handleFiles(event.target.files);
|
|
193
|
+
// Reset so picking the same file again still fires a change event.
|
|
194
|
+
event.target.value = "";
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Roving tabindex across the file chip list: only the "active" chip's remove icon is a tab
|
|
198
|
+
// stop (Tab lands on the first chip), and Left/Right/Up/Down move it — see
|
|
199
|
+
// FILEUPLOAD_IMPLEMENTATION_NOTES.md.
|
|
200
|
+
const [activeChipIndex, setActiveChipIndex] = useState(0);
|
|
201
|
+
const removeIconRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
|
202
|
+
const prevFileCountRef = useRef(files?.length ?? 0);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const count = files?.length ?? 0;
|
|
206
|
+
// A chip was removed (via keyboard or otherwise) — keep focus in the list, clamped to the
|
|
207
|
+
// new length, rather than letting it fall back to the document body.
|
|
208
|
+
if (count > 0 && count < prevFileCountRef.current) {
|
|
209
|
+
const nextIndex = Math.min(activeChipIndex, count - 1);
|
|
210
|
+
setActiveChipIndex(nextIndex);
|
|
211
|
+
removeIconRefs.current[nextIndex]?.focus();
|
|
212
|
+
}
|
|
213
|
+
prevFileCountRef.current = count;
|
|
214
|
+
// Only react to the file list itself shrinking/growing, not to activeChipIndex changes.
|
|
215
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
216
|
+
}, [files]);
|
|
217
|
+
|
|
218
|
+
const handleFileListKeyDown = (
|
|
219
|
+
event: React.KeyboardEvent<HTMLDivElement>,
|
|
220
|
+
) => {
|
|
221
|
+
const count = files?.length ?? 0;
|
|
222
|
+
if (count === 0) return;
|
|
223
|
+
let nextIndex: number | undefined;
|
|
224
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
225
|
+
nextIndex = (activeChipIndex + 1) % count;
|
|
226
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
227
|
+
nextIndex = (activeChipIndex - 1 + count) % count;
|
|
228
|
+
}
|
|
229
|
+
if (nextIndex === undefined) return;
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
setActiveChipIndex(nextIndex);
|
|
232
|
+
removeIconRefs.current[nextIndex]?.focus();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// The built-in `accept`-mismatch message is only shown when the integrator hasn't supplied
|
|
236
|
+
// their own `error` — an explicit error always wins.
|
|
237
|
+
const effectiveError =
|
|
238
|
+
error ??
|
|
239
|
+
(invalidTypeRejected
|
|
240
|
+
? invalidFileTypeMessage
|
|
241
|
+
: tooManyFilesRejected
|
|
242
|
+
? maxFilesMessage
|
|
243
|
+
: undefined);
|
|
244
|
+
|
|
245
|
+
const wrapperClass = className
|
|
246
|
+
? `${styles.layoutOverride} ${className}`
|
|
247
|
+
: styles.layoutOverride;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<FormControlWrapper
|
|
251
|
+
overStyled={overStyled as true}
|
|
252
|
+
className={wrapperClass}
|
|
253
|
+
style={style}
|
|
254
|
+
formLayout={formLayout}
|
|
255
|
+
labelSize={labelSize}
|
|
256
|
+
labelAlignment={labelAlignment}
|
|
257
|
+
labelOptionalText={labelOptionalText}
|
|
258
|
+
labelWithEditIcon={labelWithEditIcon}
|
|
259
|
+
labelActionArea={labelActionArea}
|
|
260
|
+
onLabelEditClick={onLabelEditClick}
|
|
261
|
+
label={label}
|
|
262
|
+
assistiveText={assistiveText}
|
|
263
|
+
assistiveWithIcon={assistiveWithIcon}
|
|
264
|
+
error={effectiveError}
|
|
265
|
+
required={required}
|
|
266
|
+
withAsterisk={withAsterisk}
|
|
267
|
+
id={id}
|
|
268
|
+
>
|
|
269
|
+
<div
|
|
270
|
+
ref={ref}
|
|
271
|
+
className={styles.root}
|
|
272
|
+
data-disabled={disabled ? "true" : undefined}
|
|
273
|
+
data-error={effectiveError ? "true" : undefined}
|
|
274
|
+
{...restRecord}
|
|
275
|
+
>
|
|
276
|
+
{!readOnly && (
|
|
277
|
+
<div
|
|
278
|
+
className={styles.dropzone}
|
|
279
|
+
data-dragging={isDragging ? "true" : undefined}
|
|
280
|
+
onDragEnter={disabled ? undefined : handleDragEnter}
|
|
281
|
+
onDragLeave={disabled ? undefined : handleDragLeave}
|
|
282
|
+
onDragOver={disabled ? undefined : handleDragOver}
|
|
283
|
+
onDrop={disabled ? undefined : handleDrop}
|
|
284
|
+
>
|
|
285
|
+
<span className={styles.uploadIcon}>
|
|
286
|
+
{icon ?? <UploadIcon />}
|
|
287
|
+
</span>
|
|
288
|
+
<span className={styles.dropzoneText}>{dropzoneLabel}</span>
|
|
289
|
+
<Button
|
|
290
|
+
variant="outline"
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
onClick={openFilePicker}
|
|
293
|
+
>
|
|
294
|
+
{browseButtonLabel}
|
|
295
|
+
</Button>
|
|
296
|
+
<input
|
|
297
|
+
ref={inputRef}
|
|
298
|
+
type="file"
|
|
299
|
+
hidden
|
|
300
|
+
accept={accept}
|
|
301
|
+
multiple={multiple}
|
|
302
|
+
disabled={disabled}
|
|
303
|
+
onChange={handleInputChange}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{files &&
|
|
309
|
+
files.length > 0 &&
|
|
310
|
+
(readOnly ? (
|
|
311
|
+
<div className={styles.fileList}>
|
|
312
|
+
{files.map((item: RecursicaFileUploadItem) => {
|
|
313
|
+
const itemId = item.id ?? item.file.name;
|
|
314
|
+
return (
|
|
315
|
+
<Chip key={itemId} checked={false} tabIndex={-1}>
|
|
316
|
+
{item.file.name}
|
|
317
|
+
</Chip>
|
|
318
|
+
);
|
|
319
|
+
})}
|
|
320
|
+
</div>
|
|
321
|
+
) : (
|
|
322
|
+
<div
|
|
323
|
+
className={styles.fileList}
|
|
324
|
+
onKeyDown={handleFileListKeyDown}
|
|
325
|
+
>
|
|
326
|
+
{files.map((item: RecursicaFileUploadItem, index) => {
|
|
327
|
+
const itemId = item.id ?? item.file.name;
|
|
328
|
+
return (
|
|
329
|
+
<Chip
|
|
330
|
+
key={itemId}
|
|
331
|
+
checked={false}
|
|
332
|
+
tabIndex={-1}
|
|
333
|
+
removeLabel={removeFileLabel}
|
|
334
|
+
removeTabIndex={index === activeChipIndex ? 0 : -1}
|
|
335
|
+
removeIconRef={(el) => {
|
|
336
|
+
removeIconRefs.current[index] = el;
|
|
337
|
+
}}
|
|
338
|
+
onRemove={
|
|
339
|
+
disabled ? undefined : () => onFileRemove?.(itemId)
|
|
340
|
+
}
|
|
341
|
+
>
|
|
342
|
+
{item.file.name}
|
|
343
|
+
</Chip>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
</FormControlWrapper>
|
|
350
|
+
);
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
FileUpload.displayName = "FileUpload";
|
|
@@ -14,15 +14,29 @@ import { FileUpload } from "@recursica/mantine-adapter";
|
|
|
14
14
|
|
|
15
15
|
## 2. Basic Example
|
|
16
16
|
|
|
17
|
+
`FileUpload` is a **controlled** component: it never stores the selected files itself. `onFilesAdded` reports newly dropped/picked files, `onFileRemove` reports which file was removed, and you own the `files` array in between.
|
|
18
|
+
|
|
17
19
|
```tsx
|
|
18
|
-
import React from "react";
|
|
20
|
+
import React, { useState } from "react";
|
|
19
21
|
import { FileUpload } from "@recursica/mantine-adapter";
|
|
22
|
+
import { type RecursicaFileUploadItem } from "@recursica/adapter-common";
|
|
20
23
|
|
|
21
24
|
export default function Demo() {
|
|
25
|
+
const [files, setFiles] = useState<RecursicaFileUploadItem[]>([]);
|
|
26
|
+
|
|
22
27
|
return (
|
|
23
28
|
<FileUpload
|
|
24
|
-
label="
|
|
25
|
-
|
|
29
|
+
label="Upload Files"
|
|
30
|
+
assistiveText="Max file size 5MB"
|
|
31
|
+
files={files}
|
|
32
|
+
onFilesAdded={(added) =>
|
|
33
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
34
|
+
}
|
|
35
|
+
onFileRemove={(id) =>
|
|
36
|
+
setFiles((prev) =>
|
|
37
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
26
40
|
/>
|
|
27
41
|
);
|
|
28
42
|
}
|
|
@@ -30,12 +44,156 @@ export default function Demo() {
|
|
|
30
44
|
|
|
31
45
|
---
|
|
32
46
|
|
|
33
|
-
## 3.
|
|
47
|
+
## 3. Props Reference
|
|
48
|
+
|
|
49
|
+
| Prop | Type | Description |
|
|
50
|
+
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
51
|
+
| `files` | `RecursicaFileUploadItem[]` | Files currently selected, rendered as removable chips below the dropzone. Each item is `{ file: File; id?: string }`. |
|
|
52
|
+
| `onFilesAdded` | `(files: File[]) => void` | Called with newly dropped/picked files. Only the new files — merge them into `files` yourself. |
|
|
53
|
+
| `onFileRemove` | `(id: string) => void` | Called with a file's `id` (or `file.name` if no `id` was given) when its remove (X) icon is activated. |
|
|
54
|
+
| `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. |
|
|
55
|
+
| `multiple` | `boolean` | Whether more than one file can be selected/dropped at once. Defaults to `true`. |
|
|
56
|
+
| `maxSize` | `number` | Maximum size per file, in bytes. Oversized files go to `onFilesRejected` instead of `onFilesAdded`. |
|
|
57
|
+
| `maxFiles` | `number` | Maximum total number of files allowed in `files`. Files that would exceed it go to `onFilesRejected` instead of `onFilesAdded`. |
|
|
58
|
+
| `onFilesRejected` | `(files: File[]) => void` | Called with files rejected for exceeding `maxSize`/`maxFiles` or not matching `accept`. |
|
|
59
|
+
| `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. |
|
|
60
|
+
| `maxFilesMessage` | `React.ReactNode` | Error message shown when a file is rejected for exceeding `maxFiles`. Defaults to `"Maximum of {maxFiles} files allowed"`. An explicit `error` prop always takes priority over this. |
|
|
61
|
+
| `icon` | `React.ReactNode` | Icon shown above the dropzone label. Defaults to the built-in upload icon. |
|
|
62
|
+
| `dropzoneLabel` | `React.ReactNode` | Text shown inside the dropzone. Defaults to `"Drag and drop files here to upload"`. |
|
|
63
|
+
| `browseButtonLabel` | `React.ReactNode` | Label for the button that opens the native file picker. Defaults to `"Browse files"`. |
|
|
64
|
+
| `removeFileLabel` | `string` | Screen-reader label for each file chip's remove button. Defaults to `"Remove"`. |
|
|
65
|
+
| `disabled` | `boolean` | Disables the dropzone, browse button, and every file chip's remove icon. |
|
|
66
|
+
| `readOnly` | `boolean` | Renders `files` as a static chip list with no remove icon, and omits the dropzone/browse button entirely. |
|
|
67
|
+
|
|
68
|
+
`FileUpload` 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.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Rejecting Oversized Files
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<FileUpload
|
|
76
|
+
label="Upload Files"
|
|
77
|
+
assistiveText="Max file size 5MB"
|
|
78
|
+
maxSize={5 * 1024 * 1024}
|
|
79
|
+
files={files}
|
|
80
|
+
onFilesAdded={(added) =>
|
|
81
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
82
|
+
}
|
|
83
|
+
onFilesRejected={(rejected) =>
|
|
84
|
+
setError(`${rejected.length} file(s) exceeded the 5MB limit.`)
|
|
85
|
+
}
|
|
86
|
+
onFileRemove={(id) =>
|
|
87
|
+
setFiles((prev) =>
|
|
88
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 5. Rejecting Files by Extension/MIME Type
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<FileUpload
|
|
100
|
+
label="Upload Files"
|
|
101
|
+
assistiveText="Only .pdf and .png files are accepted"
|
|
102
|
+
accept=".pdf,.png"
|
|
103
|
+
files={files}
|
|
104
|
+
onFilesAdded={(added) =>
|
|
105
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
106
|
+
}
|
|
107
|
+
onFileRemove={(id) =>
|
|
108
|
+
setFiles((prev) =>
|
|
109
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This applies equally to a file picked via "Browse files" and one dragged directly onto the
|
|
116
|
+
dropzone — the browser's own `accept` filtering never covers drag-and-drop, so `FileUpload`
|
|
117
|
+
re-validates it itself before calling `onFilesAdded`/`onFilesRejected`.
|
|
118
|
+
|
|
119
|
+
A mismatched file automatically puts the control into its error state with the message
|
|
120
|
+
`"File type not accepted"` — no need to wire `onFilesRejected` into your own `error` prop just to
|
|
121
|
+
show something. Override the message with `invalidFileTypeMessage`, or pass your own `error` prop
|
|
122
|
+
to take over the error state entirely:
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
<FileUpload
|
|
126
|
+
label="Upload Files"
|
|
127
|
+
accept=".pdf,.png"
|
|
128
|
+
invalidFileTypeMessage="Only PDF and PNG files are supported"
|
|
129
|
+
files={files}
|
|
130
|
+
onFilesAdded={(added) =>
|
|
131
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
132
|
+
}
|
|
133
|
+
onFileRemove={(id) =>
|
|
134
|
+
setFiles((prev) =>
|
|
135
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
/>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 6. Limiting the Number of Files
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
<FileUpload
|
|
147
|
+
label="Upload Files"
|
|
148
|
+
assistiveText="Up to 2 files allowed"
|
|
149
|
+
maxFiles={2}
|
|
150
|
+
files={files}
|
|
151
|
+
onFilesAdded={(added) =>
|
|
152
|
+
setFiles((prev) => [...prev, ...added.map((file) => ({ file }))])
|
|
153
|
+
}
|
|
154
|
+
onFileRemove={(id) =>
|
|
155
|
+
setFiles((prev) =>
|
|
156
|
+
prev.filter((item) => (item.id ?? item.file.name) !== id),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
/>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Once `files` reaches `maxFiles`, further dropped/picked files are rejected the same way an
|
|
163
|
+
`accept` mismatch is — automatically switching the control into its error state with the default
|
|
164
|
+
`maxFilesMessage` ("Maximum of 2 files allowed" for the example above). Override the message with
|
|
165
|
+
`maxFilesMessage`, or pass your own `error` prop to take over the error state entirely.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 7. Read-only Mode
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
<FileUpload label="Uploaded Files" readOnly files={files} />
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Renders `files` as a plain, non-removable chip list with no dropzone or Browse button — for
|
|
176
|
+
displaying files that were already submitted and can no longer be changed. `onFilesAdded`/
|
|
177
|
+
`onFileRemove` aren't called in this mode since there's nothing to add or remove.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 8. Keyboard Navigation of the File List
|
|
182
|
+
|
|
183
|
+
Once files are selected, their chips form a single roving-tabindex group, not one tab stop per
|
|
184
|
+
chip: `Tab` lands on the first chip's remove icon, `Enter`/`Space` removes the focused chip, and
|
|
185
|
+
`ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown` move focus between chips (wrapping at both ends).
|
|
186
|
+
This is a group-level pattern, not an option on `Chip` used standalone elsewhere. (Not applicable
|
|
187
|
+
in `readOnly` mode, which has no remove icons to navigate to.)
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 9. Design System Integration
|
|
34
192
|
|
|
35
193
|
All Recursica components in the `@recursica/mantine-adapter` package adhere strictly to design system spacing, scaling, and behavior patterns.
|
|
36
194
|
|
|
37
195
|
> [!IMPORTANT]
|
|
38
196
|
>
|
|
39
|
-
> - **Anti-override protection**:
|
|
197
|
+
> - **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.
|
|
40
198
|
> - **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.
|
|
41
199
|
> - **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.
|
|
@@ -26,3 +26,39 @@ Similar to `Checkbox`, the `Switch` component handles `readOnly` presentation by
|
|
|
26
26
|
## 4. Hover State Reset
|
|
27
27
|
|
|
28
28
|
Mantine forcefully triggers track hover color states globally. Since Recursica currently does not map specific hover states to switch backgrounds across themes (falling back to standard unselected tokens or simply providing a cursor), we structurally wipe out Mantine's `.track:hover` class block inside `Switch.module.css`.
|
|
29
|
+
|
|
30
|
+
## 5. `RecursicaSwitchGroupProps.value`/`onChange` shape
|
|
31
|
+
|
|
32
|
+
Tightened to `string[]`/`(value: string[]) => void` to match Mantine's real `Switch.Group` value
|
|
33
|
+
type exactly (previously `unknown[]`, which was accurate but forced `as any` casts everywhere
|
|
34
|
+
this component's `value`/`defaultValue` were handed to `<MantineSwitch.Group>`). Now a genuine
|
|
35
|
+
"Shared" prop — same name and shape as native, no reshaping needed.
|
|
36
|
+
|
|
37
|
+
## 6. `onChange` shape mismatch (Forge's report) doesn't apply
|
|
38
|
+
|
|
39
|
+
Neither `Switch` nor `SwitchGroup` reshapes native `onChange` — it flows straight through as
|
|
40
|
+
Mantine's real event-based signature (`(event: ChangeEvent<HTMLInputElement>) => void` for
|
|
41
|
+
`Switch`, `(value: string[]) => void` for `SwitchGroup`). Forge's report flagged an
|
|
42
|
+
event-vs-boolean mismatch, but that's Forge's own dispatcher typing against the old deleted
|
|
43
|
+
local components — our contract never claimed a boolean shape to begin with.
|
|
44
|
+
|
|
45
|
+
## 7. Focus ring was Mantine's own default blue, not a Recursica color
|
|
46
|
+
|
|
47
|
+
**Found 2026-08-14:** the hidden `<input>` was left with Mantine's own default focus outline
|
|
48
|
+
(no override existed despite this file previously claiming "focus rings handled internally by
|
|
49
|
+
Mantine" as an intentional decision — verified live, it renders Mantine's theme blue, not a
|
|
50
|
+
Recursica token). Fixed: `.root input:focus-visible { outline: none }` suppresses the native
|
|
51
|
+
outline on the input, and `.root input:focus-visible + .track { ... }` draws the real ring
|
|
52
|
+
(same `--recursica_brand_states_focus_*` tokens used elsewhere) on the visible track instead —
|
|
53
|
+
matching the fix made to `mui-adapter`'s Switch for parity.
|
|
54
|
+
|
|
55
|
+
## `SwitchGroup` side-by-side layout always rendered as if stacked
|
|
56
|
+
|
|
57
|
+
**Found 2026-08-14, reported by Matt (against mui-adapter, reproduced here too):** `SwitchGroup`
|
|
58
|
+
passed the switch-item's own inline label max-width token (200px) as the group's
|
|
59
|
+
`controlMaxWidth` — but the mandatory side-by-side label column is a fixed 224px, wider than
|
|
60
|
+
that cap, so the label always overflowed onto its own line regardless of layout mode. Not a
|
|
61
|
+
mui-only bug: same code pattern, same result, here. Fixed by not capping the group's control
|
|
62
|
+
width at all — each switch's own label already wraps at 200px via `.labelWrapper`, so no
|
|
63
|
+
group-level cap is needed. Verified live in both adapters: side-by-side now shows the label
|
|
64
|
+
column at left with the switches beside it; stacked layout unaffected.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
/* HARDCODED VALUES
|
|
1
|
+
/* HARDCODED VALUES
|
|
2
2
|
*
|
|
3
3
|
* 1. track border: none; (we do not use border for the switch track)
|
|
4
4
|
* 2. thumb border: none; (we do not use border for the thumb)
|
|
5
|
-
* 3. outline: none; (
|
|
5
|
+
* 3. input outline: none; (the real ring is drawn on .track instead, below, from Recursica
|
|
6
|
+
* focus tokens — previously left to Mantine's own internal default outline, which renders
|
|
7
|
+
* Mantine's theme blue, not a Recursica color)
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
.root {
|
|
@@ -93,6 +95,21 @@
|
|
|
93
95
|
border-color: transparent;
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
/* HARDCODE #3: the input itself is visually hidden (Mantine's usual pattern) but still
|
|
99
|
+
receives real keyboard focus and Mantine's own default focus outline — suppressed here so
|
|
100
|
+
the ring below (drawn on .track, the visible pill) is the only one that shows. */
|
|
101
|
+
.root input:focus-visible {
|
|
102
|
+
outline: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.root input:focus-visible + .track {
|
|
106
|
+
outline: var(--recursica_brand_states_focus_border-size) solid
|
|
107
|
+
var(--recursica_brand_states_focus_color);
|
|
108
|
+
outline-offset: var(--recursica_brand_states_focus_margin);
|
|
109
|
+
box-shadow: 0 0 var(--recursica_brand_states_focus_blur)
|
|
110
|
+
var(--recursica_brand_states_focus_color);
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
.thumb {
|
|
97
114
|
width: var(--switch-thumb-width);
|
|
98
115
|
height: var(--switch-thumb-height);
|
|
@@ -71,7 +71,10 @@ export const SwitchGroup = forwardRef<HTMLDivElement, SwitchGroupProps>(
|
|
|
71
71
|
<WithReadOnlyWrapper
|
|
72
72
|
className={className}
|
|
73
73
|
style={style as React.CSSProperties}
|
|
74
|
-
|
|
74
|
+
// No group-level max-width: switch-item label-max-width (200px) caps a single switch's
|
|
75
|
+
// own label text, not the whole group — reusing it here made the side-by-side label
|
|
76
|
+
// column (fixed 224px) wider than its container, so it always wrapped onto its own line.
|
|
77
|
+
controlMaxWidth={undefined}
|
|
75
78
|
controlMinWidth={undefined}
|
|
76
79
|
overStyled={overStyled as true}
|
|
77
80
|
labelElement="div" // Strictly override. ARIA grouping prohibits interactive switches nested natively inside <label>.
|
|
@@ -101,8 +104,8 @@ export const SwitchGroup = forwardRef<HTMLDivElement, SwitchGroupProps>(
|
|
|
101
104
|
/* Natively bind local disabled lock dynamically */
|
|
102
105
|
{...(sanitizedProps as unknown as MantineSwitchGroupProps)}
|
|
103
106
|
disabled={readOnly || (restRecord as any).disabled}
|
|
104
|
-
value={value
|
|
105
|
-
defaultValue={defaultValue
|
|
107
|
+
value={value}
|
|
108
|
+
defaultValue={defaultValue}
|
|
106
109
|
>
|
|
107
110
|
<div className={styles.groupRoot} data-layout={formLayout}>
|
|
108
111
|
{children}
|
|
@@ -66,6 +66,16 @@ Previously flagged as a known limitation, now fixed: Mantine's own `TimePicker`
|
|
|
66
66
|
|
|
67
67
|
Implementation: a `useEffect` on mount, gated to only run when there's no real initial `value`/`defaultValue` already (Mantine already derives the correct AM/PM from a real value on its own — this only fixes the genuinely-empty-start case). It sets the native select's `.value` via `Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set` (required to make a React-controlled element pick up a value set outside of React) and dispatches a `change` event — access to the native element comes via `amPmRef`, a prop omitted from this component's own public API but still usable internally when calling Mantine's `<TimePicker>` directly.
|
|
68
68
|
|
|
69
|
+
## Visual review round 6 (Matt Massey, 2026-08-17) — AM/PM dropdown blank until an hour is typed
|
|
70
|
+
|
|
71
|
+
Round 3 fixed Mantine's internal `amPm` state never becoming valid (the deadlock preventing `onChange` from ever firing). It did not fix a separate problem: the visible `BareDropdown`'s own `value` prop was computed as `hour === undefined ? null : isPM ? "PM" : "AM"` — explicitly `null` whenever no hour had been typed yet, so the control displayed empty on mount even though round 3's mount effect had already seeded Mantine's hidden native `<select>` to "AM". Those are two different pieces of state: the hidden native select (round 3's fix target) and `internalValue`/`hour` (what `BareDropdown` actually renders), and writing to one never touched the other.
|
|
72
|
+
|
|
73
|
+
The MUI adapter's equivalent (`mui-adapter/src/components/TimePicker/TimePicker.tsx`) never had this problem — its `isPM` is a plain boolean (`internalValue ? internalValue.hour() >= 12 : false`) with no `null`/"unset" branch, so its dropdown always renders a concrete "AM"/"PM".
|
|
74
|
+
|
|
75
|
+
**Fix**: dropped the `null` branch — `BareDropdown`'s `value` is now just `isPM ? "PM" : "AM"`, matching MUI. `isPM` already evaluates `false` when `hour` is `undefined`, so this alone makes the dropdown default to "AM" with no other changes.
|
|
76
|
+
|
|
77
|
+
Note: selecting AM/PM before any hour is typed is still a no-op in both adapters (`handleMeridiemChange` bails out when `hour`/`internalValue` is undefined) — that's pre-existing, shared behavior in both adapters, not something this fix touches.
|
|
78
|
+
|
|
69
79
|
## Visual review round 5 (Matt Massey, 2026-08-08)
|
|
70
80
|
|
|
71
81
|
- **AM/PM error-state border wasn't changing**: `BareDropdown` set `data-error`/`data-disabled` via Mantine's `wrapperProps` — which targets the _outer_ `Input.Wrapper` (the label/description/error stacking element), a different, ancestor element from the "wrapper" styles-api slot that actually carries `styles.root`'s border. `Dropdown.module.css`'s `.root[data-error]`/`[data-disabled]` rules never matched as a result. This is the exact same "two different things both called 'wrapper'" trap as the earlier `style` vs `styles.wrapper` bug. Fixed by using `attributes={{ wrapper: {...} }}` instead — the styles-api hook that actually targets the same slot as `classNames.wrapper`. **This is a shared, pre-existing bug** — the real `Dropdown.tsx` had the identical mistake, so its error/disabled states never applied a border color either; fixed there too (low-risk, purely-additive, same reasoning as the `data-selected` fix above).
|