Skip to content

Commit 5e88755

Browse files
File upload (#1231)
* Add file-upload component * WIP file-upload * Prevent form submission on invalid field * Fix custom validation error on submit * Fix RAC auto focus on invalid form * Fix upload button style * Fix error message bug * Add changeset * Update installs after merge * Upgrade package * Format * Change label API * Move comment * Positon hidden input even safer * Fix typo * Add patch changest * Fix focus trap * Updat comment * Up file size limit for component props auto gen
1 parent 7c83f09 commit 5e88755

File tree

11 files changed

+1341
-195
lines changed

11 files changed

+1341
-195
lines changed

.changeset/eight-brooms-tie.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@obosbbl/grunnmuren-react": minor
3+
---
4+
5+
New FileUpload component in beta. Can be used to upload one or multiple files.

.changeset/free-bars-bet.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@obosbbl/grunnmuren-react": patch
3+
---
4+
5+
Expose `<Label>`, `<Description>` and `<ErrorMessage>` components

apps/docs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "pnpm build:props && pnpm build:tsc && pnpm build:assets && pnpm build:app",
1010
"build:app": "vinxi build",
1111
"build:assets": "mkdir -p public/resources/icons && cp -r node_modules/@obosbbl/grunnmuren-icons-svg/src/ public/resources/icons",
12-
"build:props": "node extract-component-props.js && biome lint --write component-props.ts --files-max-size=3000000",
12+
"build:props": "node extract-component-props.js && biome lint --write component-props.ts --files-max-size=4000000",
1313
"build:tsc": "tsc",
1414
"dev": "vinxi dev",
1515
"start": "vinxi start",

packages/react/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
},
2323
"dependencies": {
2424
"@obosbbl/grunnmuren-icons-react": "workspace:^2.0.0-canary.7",
25+
"@react-aria/form": "^3.0.14",
26+
"@react-aria/interactions": "^3.24.1",
2527
"@react-aria/utils": "^3.28.1",
28+
"@react-stately/form": "^3.1.2",
29+
"@react-stately/utils": "^3.10.5",
2630
"@types/node": "^22.0.0",
2731
"cva": "^1.0.0-0",
2832
"react-aria": "^3.38.1",

packages/react/src/classes.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { cva, cx } from 'cva';
22

33
const formField = cx('group flex flex-col gap-2');
44
const formFieldError = cx(
5-
'w-fit rounded-sm bg-red-light px-2 py-1 text-red text-sm leading-6',
5+
'w-fit bg-red-light px-2 py-1 text-red text-sm leading-6',
6+
'group-data-[slot=file-upload]:rounded-lg',
67
);
78

89
const input = cva({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* This is a modified version of the original file-trigger from react-aria-components.
3+
* We need to modify it to support it in forms (e.g. adding a name prop).
4+
* We also modify the hiding of it, so that it works with the built in auto focusing of RAC.
5+
*/
6+
import { PressResponder } from '@react-aria/interactions';
7+
import { useObjectRef } from '@react-aria/utils';
8+
import type { FormValidationProps } from '@react-stately/form';
9+
import type { HTMLAttributes, RefObject } from 'react';
10+
import {
11+
Input,
12+
type FileTriggerProps as RACFileTriggerProps,
13+
} from 'react-aria-components';
14+
15+
export type FileTriggerProps = Partial<FormValidationProps<File>> &
16+
RACFileTriggerProps &
17+
Omit<
18+
HTMLAttributes<HTMLInputElement>,
19+
'onSelect' | 'onChange' | 'required' | 'className'
20+
> & {
21+
ref?: RefObject<HTMLInputElement | null>;
22+
isInvalid?: boolean;
23+
isRequired?: boolean;
24+
};
25+
26+
/**
27+
* A FileTrigger allows a user to access the file system with any pressable React Aria or React Spectrum component, or custom components built with usePress.
28+
*/
29+
export const FileTrigger = (props: FileTriggerProps) => {
30+
const {
31+
onSelect,
32+
acceptedFileTypes,
33+
allowsMultiple,
34+
defaultCamera,
35+
children,
36+
acceptDirectory,
37+
ref,
38+
isInvalid,
39+
isRequired,
40+
name,
41+
value,
42+
...rest
43+
} = props;
44+
const inputRef = useObjectRef(ref);
45+
46+
return (
47+
<>
48+
<PressResponder
49+
onPress={() => {
50+
if (inputRef.current?.value) {
51+
inputRef.current.value = '';
52+
}
53+
inputRef.current?.click();
54+
}}
55+
>
56+
{children}
57+
</PressResponder>
58+
<Input
59+
{...rest}
60+
required={isRequired}
61+
aria-invalid={isInvalid}
62+
data-invalid={isInvalid}
63+
data-rac
64+
name={Array.isArray(name) ? name.join(' ') : name}
65+
type="file"
66+
ref={inputRef}
67+
accept={acceptedFileTypes?.toString()}
68+
onChange={(e) => onSelect?.(e.target.files)}
69+
capture={defaultCamera}
70+
multiple={allowsMultiple}
71+
// @ts-expect-error
72+
webkitdirectory={acceptDirectory ? '' : undefined}
73+
// This is a work around to prevent error in the console when attempting to submit a form with a required and empty file input
74+
// RAC uses display: none, which prevents the file input from being focused.
75+
// What we do instead is to hide it visually using custom CSS, so that the native HTML validation messages are still hidden. Which is why
76+
// we don't use the sr-only class.
77+
className="absolute left-[-1000vw] opacity-0"
78+
// Finally, we add aria-hidden to prevent the file input from being read by screen readers
79+
aria-hidden
80+
// Prevent focus trap when tabbing (since focus is delegated to the button)
81+
tabIndex={-1}
82+
// We also attach an onFocus event listener to the file upload button (in the FileUpload component), which we use to delagate focus from this input to.
83+
/>
84+
</>
85+
);
86+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { useState } from 'react';
3+
import { Button } from '../button';
4+
import { Description, Label } from '../label';
5+
import { UNSAFE_FileUpload as FileUpload } from './file-upload';
6+
7+
const meta: Meta<typeof FileUpload> = {
8+
title: 'FileUpload',
9+
component: FileUpload,
10+
parameters: {
11+
// disable built in padding in story, because we provide our own
12+
layout: 'fullscreen',
13+
},
14+
render: () => {
15+
return (
16+
<div className="p-4">
17+
<FileUpload>
18+
<Label>Last opp fil</Label>
19+
<Description>Du kan laste opp én fil på opptil 10 mB.</Description>
20+
<Button className="w-fit">Velg fil</Button>
21+
</FileUpload>
22+
</div>
23+
);
24+
},
25+
};
26+
27+
export default meta;
28+
29+
type Story = StoryObj<typeof FileUpload>;
30+
31+
export const FileUploadStory: Story = {
32+
args: {},
33+
};
34+
35+
export const AllowsMultiple: Story = {
36+
render: () => {
37+
return (
38+
<div className="p-4">
39+
<FileUpload allowsMultiple>
40+
<Label>Last opp filer</Label>
41+
<Description>
42+
Du kan laste opp flere filer. Du kan laste de opp samtidig.
43+
</Description>
44+
<Button className="w-fit">Velg filer</Button>
45+
</FileUpload>
46+
</div>
47+
);
48+
},
49+
};
50+
51+
export const LimitFileTypes: Story = {
52+
render: () => {
53+
return (
54+
<div className="p-4">
55+
<FileUpload acceptedFileTypes={['.pdf']}>
56+
<Label>Last opp PDF</Label>
57+
<Description>Du kan kun laste opp PDF-er.</Description>
58+
<Button className="w-fit">Velg PDF</Button>
59+
</FileUpload>
60+
</div>
61+
);
62+
},
63+
};
64+
65+
export const AcceptDirectory: Story = {
66+
render: () => {
67+
return (
68+
<div className="p-4">
69+
<FileUpload acceptDirectory>
70+
<Label>Last opp mappe</Label>
71+
<Description>Du kan laste opp en mappe.</Description>
72+
<Button className="w-fit">Velg mappe</Button>
73+
</FileUpload>
74+
</div>
75+
);
76+
},
77+
};
78+
79+
export const Controlled: Story = {
80+
render: () => {
81+
const [files, setFiles] = useState<File[]>([]);
82+
return (
83+
<div className="p-4">
84+
<FileUpload files={files} onChange={setFiles} allowsMultiple>
85+
<Label>Last opp filer</Label>
86+
<Description>Du kan laste opp flere filer.</Description>
87+
<Button className="w-fit">Velg filer</Button>
88+
</FileUpload>
89+
Filer: {files?.map((file) => file.name).join(', ')}
90+
</div>
91+
);
92+
},
93+
};
94+
95+
export const Required: Story = {
96+
render: () => {
97+
return (
98+
<form
99+
encType="multipart/form-data"
100+
className="flex flex-col items-start gap-4 p-4"
101+
onSubmit={(e) => {
102+
e.preventDefault();
103+
const formData = new FormData(e.target as HTMLFormElement);
104+
alert(
105+
`Lastet opp ${formData
106+
.getAll('files')
107+
.map((file) => (file as File).name)
108+
.join(', ')}`,
109+
);
110+
}}
111+
>
112+
<FileUpload isRequired name="file">
113+
<Label>Last opp medlemsbevis</Label>
114+
<Description>Du må laste opp medlemsbevis.</Description>
115+
<Button className="w-fit" variant="secondary">
116+
Velg fil
117+
</Button>
118+
</FileUpload>
119+
<Button type="submit">Send inn</Button>
120+
</form>
121+
);
122+
},
123+
};
124+
125+
export const Validation: Story = {
126+
render: () => {
127+
return (
128+
<div className="p-4">
129+
<FileUpload
130+
validate={(file) => file.size < 1000000 || 'Filen er for stor'}
131+
>
132+
<Label>Last opp fil</Label>
133+
<Description>Du kan laste opp en fil på maksimalt 1 MB.</Description>
134+
<Button className="w-fit">Velg fil</Button>
135+
</FileUpload>
136+
</div>
137+
);
138+
},
139+
};
140+
141+
export const InForm = () => (
142+
<form
143+
className="flex flex-col items-start gap-4 p-4"
144+
encType="multipart/form-data"
145+
onSubmit={(e) => {
146+
e.preventDefault();
147+
const formData = new FormData(e.target as HTMLFormElement);
148+
alert(
149+
`Lastet opp ${formData
150+
.getAll('files')
151+
.map((file) => (file as File).name)
152+
.join(', ')}`,
153+
);
154+
}}
155+
>
156+
<FileUpload
157+
validate={(file) => file.size < 1000000 || 'Filen er for stor'}
158+
isRequired
159+
allowsMultiple
160+
name="files"
161+
>
162+
<Label>Last opp filer</Label>
163+
<Description>
164+
Du må laste opp én fil. Filen kan ikke være større enn 1 MB
165+
</Description>
166+
<Button className="w-fit" variant="secondary">
167+
Velg fil
168+
</Button>
169+
</FileUpload>
170+
<Button type="submit">Send inn</Button>
171+
</form>
172+
);

0 commit comments

Comments
 (0)