Skip to content
51 changes: 51 additions & 0 deletions packages/css/src/file-upload.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*double class to increase specificity above .ds-field :is() (0.2.0 + order)*/
.ds-file-upload.ds-file-upload {
/*todo: component variables*/
border: 2px dashed var(--ds-color-border-default);
width: auto;
border-radius: var(--ds-border-radius-md);
padding-block: var(--ds-size-8);
padding-inline: var(--ds-size-6);
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--ds-color-surface-default);
user-select: none;
@composes ds-print-preserve from './base.css';

&:focus-within {
@composes ds-focus--visible from './base.css';
}

&:hover,
&:has([readonly]) {
border-style: solid;
background-color: var(--ds-color-surface-tinted);
cursor: pointer;
.ds-button {
/*link hover to button*/
--dsc-button-background: var(--dsc-button-background--hover);
--dsc-button-color: var(--dsc-button-color--hover);
}
}
&:has([readonly]) {
cursor: not-allowed;
}

> * + * {
margin-top: var(--ds-size-4);
}
svg {
color: var(--ds-color-text-subtle);
height: var(--ds-size-8);
width: auto;
}
.ds-file-upload__label {
margin-top: var(--ds-size-2);
color: var(--ds-color-text-default);
}
.ds-file-upload__description {
color: var(--ds-color-neutral-text-subtle);
margin-top: 0;
}
}
1 change: 1 addition & 0 deletions packages/css/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
@import url('./input.css') layer(ds.components);
@import url('./field.css') layer(ds.components);
@import url('./fieldset.css') layer(ds.components);
@import url('./file-upload.css') layer(ds.components);
@import url('./alert.css') layer(ds.components);
@import url('./popover.css') layer(ds.components);
@import url('./skip-link.css') layer(ds.components);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { forwardRef, type HTMLAttributes } from 'react';
import type { ButtonProps } from '../button/button';
import { Button } from '../button/button';

export type FileUploadButtonProps = Pick<ButtonProps, 'variant'> &
HTMLAttributes<HTMLSpanElement>;

export const FileUploadButton = forwardRef<
HTMLSpanElement,
FileUploadButtonProps
>(function FileUploadButton(
{ className, variant = 'secondary', children, ...rest },
ref,
) {
return (
<Button variant={variant} asChild>
<span className={className} ref={ref} {...rest}>
{children}
</span>
</Button>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import cl from 'clsx/lite';
import { forwardRef, type HTMLAttributes } from 'react';
import { Paragraph, type ParagraphProps } from '../paragraph/paragraph';

export type FileUploadDescriptionProps = ParagraphProps &
HTMLAttributes<HTMLParagraphElement>;

export const FileUploadDescription = forwardRef<
HTMLParagraphElement,
FileUploadDescriptionProps
>(function FileUploadDescription({ className, ...rest }, ref) {
return (
<Paragraph
className={cl(className, 'ds-file-upload__description')}
ref={ref}
{...rest}
/>
);
});
19 changes: 19 additions & 0 deletions packages/react/src/components/file-upload/file-upload-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import cl from 'clsx/lite';
import { forwardRef, type HTMLAttributes } from 'react';
import { Paragraph, type ParagraphProps } from '../paragraph/paragraph';

export type FileUploadLabelProps = ParagraphProps &
HTMLAttributes<HTMLParagraphElement>;

export const FileUploadLabel = forwardRef<
HTMLParagraphElement,
FileUploadLabelProps
>(function FileUploadLabel({ className, ...rest }, ref) {
return (
<Paragraph
className={cl(className, 'ds-file-upload__label')}
ref={ref}
{...rest}
/>
);
});
26 changes: 26 additions & 0 deletions packages/react/src/components/file-upload/file-upload.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Meta, Canvas, Controls, Primary } from '@storybook/addon-docs/blocks';
import * as FileStories from './file-upload.stories';

<Meta of={FileStories} />

**Dokumentasjonen har blitt flyttet til [Designsystemet.no](https://designsystemet.no/no/components).**

<Primary />
<Controls />

### working example for testing
<Canvas of={FileStories.WorkingExample} />

<Canvas of={FileStories.Variants} />

### Link variant
<Canvas of={FileStories.LinkAlt} />

### Inside Field
<Canvas of={FileStories.FieldTest} />

### readOnly test
<Canvas of={FileStories.ReadOnly} />

### Disabled test
<Canvas of={FileStories.Disabled} />
228 changes: 228 additions & 0 deletions packages/react/src/components/file-upload/file-upload.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { CircleSlashIcon, CloudUpIcon } from '@navikt/aksel-icons';

Check failure on line 1 in packages/react/src/components/file-upload/file-upload.stories.tsx

View workflow job for this annotation

GitHub Actions / Storybook Test Report

packages/react/src/components/file-upload/file-upload.stories.tsx / Disabled

[34mClick to debug the error directly in Storybook: http://localhost:6006/?path=/story/komponenter-fileupload--disabled&addonPanel=storybook/interactions/panel[39m [2mexpect([22m[31mreceived[39m[2m).toHaveNoViolations([22m[32mexpected[39m[2m)[22m Expected the HTML found at $('#mm4mfonsek89') to have no violations: [90m<div data-field="description" id="mm4mfonsek89">Inside Field</div>[39m Received: [31m"Elements must meet minimum color contrast ratio thresholds (color-contrast)"[39m [33mFix any of the following:[39m [33m Element has insufficient color contrast of 1.57 (foreground color: #cececf, background color: #ffffff, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1[39m You can find more information on this issue here: [34mhttps://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI[39m Expected the HTML found at $('#mm4mfonsek88') to have no violations: [90m<p class="ds-validation-message" data-field="validation" id="mm4mfonsek88">Invalid file format</p>[39m Received: [31m"Elements must meet minimum color contrast ratio thresholds (color-contrast)"[39m [33mFix any of the following:[39m [33m Element has insufficient color contrast of 1.71 (foreground color: #eababa, background color: #ffffff, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1[39m You can find more information on this issue here: [34mhttps://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI[39m
Raw output
Error: 
Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/komponenter-fileupload--disabled&addonPanel=storybook/interactions/panel

expect(received).toHaveNoViolations(expected)

Expected the HTML found at $('#mm4mfonsek89') to have no violations:

<div data-field="description" id="mm4mfonsek89">Inside Field</div>

Received:

"Elements must meet minimum color contrast ratio thresholds (color-contrast)"

Fix any of the following:
  Element has insufficient color contrast of 1.57 (foreground color: #cececf, background color: #ffffff, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1

You can find more information on this issue here: 
https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI

Expected the HTML found at $('#mm4mfonsek88') to have no violations:

<p class="ds-validation-message" data-field="validation" id="mm4mfonsek88">Invalid file format</p>

Received:

"Elements must meet minimum color contrast ratio thresholds (color-contrast)"

Fix any of the following:
  Element has insufficient color contrast of 1.71 (foreground color: #eababa, background color: #ffffff, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1

You can find more information on this issue here: 
https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI
  afterEach node_modules/.cache/storybook/10.2.12/d781837addd1ac0bf3b9cb7ff5c6428110f4eba1906206fdbf4be29b32b60e9d/sb-vitest/deps/@storybook_addon-a11y_preview.js?v=16ee8205:274:26
import type { Meta, StoryFn, StoryObj } from '@storybook/react-vite';
import { type ChangeEvent, type DragEvent, useRef, useState } from 'react';
import { Button, Field, Label, ValidationMessage } from '../';
import { FileUpload } from './';

type Story = StoryObj<typeof FileUpload>;

const meta: Meta<typeof FileUpload> = {
title: 'Komponenter/FileUpload',
component: FileUpload,
parameters: {
customStyles: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
gap: 'var(--ds-size-4)',
},
},
};

export default meta;

export const Preview: Story = {
render: ({ ...args }) => {
return (
<FileUpload {...args}>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
);
},
};

export const Variants: StoryFn<typeof FileUpload> = () => (
<>
<FileUpload>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
<FileUpload data-color='neutral'>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
</>
);
export const LinkAlt: StoryFn<typeof FileUpload> = () => (
<FileUpload>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>
Drop files or <span className='ds-link'>click to browse</span>
</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
</FileUpload>
);

export const FieldTest: StoryFn<typeof FileUpload> = () => (
<Field>
<Label>Upload files</Label>
<Field.Description>Inside Field</Field.Description>
<FileUpload>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
<ValidationMessage>Invalid file format</ValidationMessage>
</Field>
);

export const ReadOnly: StoryFn<typeof FileUpload> = () => (
<>
<Field>
<Label>Upload files</Label>
<Field.Description>Inside Field</Field.Description>
<FileUpload readOnly={true}>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
<ValidationMessage>Invalid file format</ValidationMessage>
</Field>
<FileUpload readOnly={true}>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>
Drop files or <span className='ds-link'>click to browse</span>
</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
</FileUpload>
</>
);

export const Disabled: StoryFn<typeof FileUpload> = () => (
<>
<Field>
<Label>Upload files</Label>
<Field.Description>Inside Field</Field.Description>
<FileUpload disabled={true}>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>Drop file here</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</FileUpload>
<ValidationMessage>Invalid file format</ValidationMessage>
</Field>
<FileUpload disabled={true}>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>
Drop files or <span className='ds-link'>click to browse</span>
</FileUpload.Label>
<FileUpload.Description>
File must be in csv format and less than 2MB
</FileUpload.Description>
</FileUpload>
</>
);

export const WorkingExample: StoryFn<typeof FileUpload> = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isReadOnly, setIsReadOnly] = useState(false);

const addFiles = (files: FileList) => {
setUploadedFiles((prev) => [...prev, ...Array.from(files)]);
setIsReadOnly(true);
};

const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDragging(false);
if (event.dataTransfer.files.length > 0) {
addFiles(event.dataTransfer.files);
}
};

const handleChange = (event: ChangeEvent<HTMLLabelElement>) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}

if (target.files && target.files.length > 0) {
addFiles(target.files);
}
};

const handleReset = () => {
setUploadedFiles([]);
setIsReadOnly(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};

return (
<div style={{ minWidth: 'max(50vw, 300px)' }}>
<FileUpload
ref={fileInputRef}
accept='.svg'
readOnly={isReadOnly}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onChange={handleChange}
>
{isReadOnly && (
<>
<CircleSlashIcon aria-hidden='true' />
<FileUpload.Label>You can not upload more files</FileUpload.Label>
</>
)}
{!isReadOnly && (
<>
<CloudUpIcon aria-hidden='true' />
<FileUpload.Label>
{isDragging ? 'Drop file to upload' : 'Drop file here'}
</FileUpload.Label>
<FileUpload.Description>
File must be in svg format
</FileUpload.Description>
<FileUpload.Button>Upload file</FileUpload.Button>
</>
)}
</FileUpload>
{uploadedFiles.length > 0 && (
<>
<ul>
{uploadedFiles.map((file, index) => (
<li key={`${file.name}-${file.lastModified}-${index}`}>
{file.name}
</li>
))}
</ul>
<Button onClick={handleReset}>Clear file</Button>
</>
)}
</div>
);
};
Loading
Loading