diff --git a/packages/css/src/file-upload.css b/packages/css/src/file-upload.css new file mode 100644 index 0000000000..67add584b8 --- /dev/null +++ b/packages/css/src/file-upload.css @@ -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; + } +} diff --git a/packages/css/src/index.css b/packages/css/src/index.css index 23fb4ab15d..2ae7846935 100644 --- a/packages/css/src/index.css +++ b/packages/css/src/index.css @@ -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); diff --git a/packages/react/src/components/file-upload/file-upload-button.tsx b/packages/react/src/components/file-upload/file-upload-button.tsx new file mode 100644 index 0000000000..c987a8f8ce --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-button.tsx @@ -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 & + HTMLAttributes; + +export const FileUploadButton = forwardRef< + HTMLSpanElement, + FileUploadButtonProps +>(function FileUploadButton( + { className, variant = 'secondary', children, ...rest }, + ref, +) { + return ( + + ); +}); diff --git a/packages/react/src/components/file-upload/file-upload-description.tsx b/packages/react/src/components/file-upload/file-upload-description.tsx new file mode 100644 index 0000000000..17698c3e09 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-description.tsx @@ -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; + +export const FileUploadDescription = forwardRef< + HTMLParagraphElement, + FileUploadDescriptionProps +>(function FileUploadDescription({ className, ...rest }, ref) { + return ( + + ); +}); diff --git a/packages/react/src/components/file-upload/file-upload-label.tsx b/packages/react/src/components/file-upload/file-upload-label.tsx new file mode 100644 index 0000000000..f8ca345115 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-label.tsx @@ -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; + +export const FileUploadLabel = forwardRef< + HTMLParagraphElement, + FileUploadLabelProps +>(function FileUploadLabel({ className, ...rest }, ref) { + return ( + + ); +}); diff --git a/packages/react/src/components/file-upload/file-upload.mdx b/packages/react/src/components/file-upload/file-upload.mdx new file mode 100644 index 0000000000..cb4d3edb19 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.mdx @@ -0,0 +1,26 @@ +import { Meta, Canvas, Controls, Primary } from '@storybook/addon-docs/blocks'; +import * as FileStories from './file-upload.stories'; + + + +**Dokumentasjonen har blitt flyttet til [Designsystemet.no](https://designsystemet.no/no/components).** + + + + +### working example for testing + + + + +### Link variant + + +### Inside Field + + +### readOnly test + + +### Disabled test + diff --git a/packages/react/src/components/file-upload/file-upload.stories.tsx b/packages/react/src/components/file-upload/file-upload.stories.tsx new file mode 100644 index 0000000000..cf53b9f6dd --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.stories.tsx @@ -0,0 +1,228 @@ +import { CircleSlashIcon, CloudUpIcon } from '@navikt/aksel-icons'; +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; + +const meta: Meta = { + 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 ( + + + ); + }, +}; + +export const Variants: StoryFn = () => ( + <> + + + + + +); +export const LinkAlt: StoryFn = () => ( + + +); + +export const FieldTest: StoryFn = () => ( + + + Inside Field + + + Invalid file format + +); + +export const ReadOnly: StoryFn = () => ( + <> + + + Inside Field + + + Invalid file format + + + + +); + +export const Disabled: StoryFn = () => ( + <> + + + Inside Field + + + Invalid file format + + + + +); + +export const WorkingExample: StoryFn = () => { + const fileInputRef = useRef(null); + const [uploadedFiles, setUploadedFiles] = useState([]); + 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) => { + event.preventDefault(); + setIsDragging(false); + if (event.dataTransfer.files.length > 0) { + addFiles(event.dataTransfer.files); + } + }; + + const handleChange = (event: ChangeEvent) => { + 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 ( +
+ { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onChange={handleChange} + > + {isReadOnly && ( + <> + + {uploadedFiles.length > 0 && ( + <> +
    + {uploadedFiles.map((file, index) => ( +
  • + {file.name} +
  • + ))} +
+ + + )} +
+ ); +}; diff --git a/packages/react/src/components/file-upload/file-upload.tsx b/packages/react/src/components/file-upload/file-upload.tsx new file mode 100644 index 0000000000..4a5818f49a --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.tsx @@ -0,0 +1,77 @@ +import cl from 'clsx/lite'; +import type { HTMLAttributes, ReactNode } from 'react'; +import { forwardRef } from 'react'; +import type { DefaultProps } from '../../types'; +import type { MergeRight } from '../../utilities'; + +export type FileUploadProps = MergeRight< + DefaultProps & HTMLAttributes, + { + /** Instances of `FileUpload.Button`, `FileUpload.Label`, `FileUpload.Description` or other React nodes */ + children: ReactNode; + /** File input accept attribute */ + accept?: string; + /** Allow multiple file selection */ + multiple?: boolean; + /** Disable file input */ + disabled?: boolean; + /** Readonly file input */ + readOnly?: boolean; + /** Capture attribute for file input */ + capture?: boolean | 'user' | 'environment'; + } +>; + +/** + * FileUpload component to present a file upload area. + * + * @example + * + * + */ +export const FileUpload = forwardRef( + function FileUpload( + { + className, + style, + 'data-size': size, + 'data-color': color, + children, + accept, + multiple, + disabled, + readOnly, + capture, + ...rest + }, + ref, + ) { + return ( + + ); + }, +); diff --git a/packages/react/src/components/file-upload/index.ts b/packages/react/src/components/file-upload/index.ts new file mode 100644 index 0000000000..8f64e0d8eb --- /dev/null +++ b/packages/react/src/components/file-upload/index.ts @@ -0,0 +1,65 @@ +import { FileUpload as FileUploadParent } from './file-upload'; +import { FileUploadButton } from './file-upload-button'; +import { FileUploadDescription } from './file-upload-description'; +import { FileUploadLabel } from './file-upload-label'; + +type FileUpload = typeof FileUploadParent & { + /** + * Use `FileUpload.Button` to add a fake button for click affordance + * + * Place as a descendant of `FileUpload` + * + * @example + * + * Upload file + * + */ + Button: typeof FileUploadButton; + /** + * Use `FileUpload.Label` to add primary text inside FileUpload + * + * Place as a descendant of `FileUpload` + * + * @example + * + * Drop file here + * + */ + Label: typeof FileUploadLabel; + /** + * Use `FileUpload.Description` to add secondary text inside FileUpload + * + * Place as a descendant of `FileUpload` + * + * @example + * + * File must be in csv format and less than 2MB + * + */ + Description: typeof FileUploadDescription; +}; +/** + * FileUpload component to present a file upload area. + * + * @example + * + * Drop file here + * File must be in csv format + * Upload file + * + */ +const FileUploadComponent: FileUpload = Object.assign(FileUploadParent, { + Button: FileUploadButton, + Label: FileUploadLabel, + Description: FileUploadDescription, +}); + +FileUploadComponent.Button.displayName = 'FileUpload.Button'; +FileUploadComponent.Label.displayName = 'FileUpload.Label'; +FileUploadComponent.Description.displayName = 'FileUpload.Description'; + +export type { FileUploadProps } from './file-upload'; +export type { FileUploadButtonProps } from './file-upload-button'; +export type { FileUploadDescriptionProps } from './file-upload-description'; +export type { FileUploadLabelProps } from './file-upload-label'; +export { FileUploadComponent as FileUpload };