diff --git a/packages/css/src/components/file-list/README.md b/packages/css/src/components/file-list/README.md new file mode 100644 index 0000000000..76d64e4540 --- /dev/null +++ b/packages/css/src/components/file-list/README.md @@ -0,0 +1,5 @@ + + +# File List + +An overview of files, showing their name, type, size, and a preview. diff --git a/packages/css/src/components/file-list/file-list.scss b/packages/css/src/components/file-list/file-list.scss new file mode 100644 index 0000000000..af4045d11d --- /dev/null +++ b/packages/css/src/components/file-list/file-list.scss @@ -0,0 +1,55 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@use "../../common/text-rendering" as *; + +@mixin reset-ul { + list-style: none; + margin-block: 0; + padding-inline: 0; +} + +.ams-file-list { + display: flex; + flex-direction: column; + gap: var(--ams-file-list-gap); + padding-block: var(--ams-file-list-padding-block); + + @include text-rendering; + @include reset-ul; +} + +.ams-file-list__item { + display: flex; + flex-direction: row; + font-family: var(--ams-file-list-file-font-family); + font-size: var(--ams-file-list-file-font-size); + font-weight: var(--ams-file-list-file-font-weight); + gap: var(--ams-file-list-file-gap); + line-height: var(--ams-file-list-file-line-height); +} + +.ams-file-list__item-preview { + display: grid; + flex: 0 0 var(--ams-file-list-file-preview-width); + place-items: center; + + img { + inline-size: 100%; + min-block-size: auto; + } +} + +.ams-file-list__item-info { + flex: 1; + gap: var(--ams-file-list-file-gap); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ams-file-input__item-details { + color: var(--ams-file-list-file-details-color); +} diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index e74f7e2d99..f8417eaa30 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@use "file-list/file-list"; @use "action-group/action-group"; @use "breakout/breakout"; @use "hint/hint"; diff --git a/packages/react/src/FileList/FileList.test.tsx b/packages/react/src/FileList/FileList.test.tsx new file mode 100644 index 0000000000..d280a4c907 --- /dev/null +++ b/packages/react/src/FileList/FileList.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { FileList } from './FileList' +import '@testing-library/jest-dom' + +describe('FileList', () => { + it('renders', () => { + render() + + const component = screen.getByRole('list') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('list') + + expect(component).toHaveClass('ams-file-list') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('list') + + expect(component).toHaveClass('ams-file-list extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('list') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/FileList/FileList.tsx b/packages/react/src/FileList/FileList.tsx new file mode 100644 index 0000000000..26ded755e5 --- /dev/null +++ b/packages/react/src/FileList/FileList.tsx @@ -0,0 +1,25 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' +import { FileListItem } from './FileListItem' + +export type FileListProps = {} & PropsWithChildren> + +export const FileListRoot = forwardRef( + ({ children, className, ...restProps }: FileListProps, ref: ForwardedRef) => ( +
    + {children} +
+ ), +) + +FileListRoot.displayName = 'FileList' + +export const FileList = Object.assign(FileListRoot, { + Item: FileListItem, +}) diff --git a/packages/react/src/FileList/FileListItem.test.tsx b/packages/react/src/FileList/FileListItem.test.tsx new file mode 100644 index 0000000000..ec2e567ba2 --- /dev/null +++ b/packages/react/src/FileList/FileListItem.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import '@testing-library/jest-dom' +import { FileListItem } from './FileListItem' + +describe('FileListItem', () => { + const file = new File(['sample content'], 'sample.txt', { type: 'text/plain' }) + it('renders', () => { + render() + + const component = screen.getByRole('listitem') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('listitem') + + expect(component).toHaveClass('ams-file-list__item') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('listitem') + + expect(component).toHaveClass('ams-file-list__item extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('listitem') + + expect(ref.current).toBe(component) + }) + + it('renders the file name', () => { + render() + + expect(screen.getByText('sample.txt')).toBeInTheDocument() + }) + + it('calls onDelete when the remove button is clicked', () => { + const onDelete = jest.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(onDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react/src/FileList/FileListItem.tsx b/packages/react/src/FileList/FileListItem.tsx new file mode 100644 index 0000000000..1ae2f5efcb --- /dev/null +++ b/packages/react/src/FileList/FileListItem.tsx @@ -0,0 +1,47 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { DocumentIcon } from '@amsterdam/design-system-react-icons' +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes } from 'react' +import { Button } from '../Button' +import { Icon } from '../Icon' +import { formatFileSize } from '../common/formatFileSize' +import { formatFileType } from '../common/formatFileType' + +export type FileListItemProps = { + file: File + onDelete?: () => void +} & HTMLAttributes + +export const FileListItem = forwardRef( + ({ file, onDelete, className, ...restProps }: FileListItemProps, ref: ForwardedRef) => ( +
  • +
    + {file.type.startsWith('image/') ? ( + {file.name} + ) : ( + + )} +
    +
    + {file.name} +
    + ({formatFileType(file.type)}, {formatFileSize(file.size)}) +
    +
    + {onDelete && ( +
    + +
    + )} +
  • + ), +) + +FileListItem.displayName = 'FileList.Item' diff --git a/packages/react/src/FileList/README.md b/packages/react/src/FileList/README.md new file mode 100644 index 0000000000..e149ac3384 --- /dev/null +++ b/packages/react/src/FileList/README.md @@ -0,0 +1,5 @@ + + +# React File List component + +[File List documentation](../../../css/src/components/file-list/README.md) diff --git a/packages/react/src/FileList/index.ts b/packages/react/src/FileList/index.ts new file mode 100644 index 0000000000..8083f1fe0f --- /dev/null +++ b/packages/react/src/FileList/index.ts @@ -0,0 +1,2 @@ +export { FileList } from './FileList' +export type { FileListProps } from './FileList' diff --git a/packages/react/src/common/formatFileSize.test.tsx b/packages/react/src/common/formatFileSize.test.tsx new file mode 100644 index 0000000000..f2bcdab8b5 --- /dev/null +++ b/packages/react/src/common/formatFileSize.test.tsx @@ -0,0 +1,22 @@ +import { formatFileSize } from './formatFileSize' + +describe('formatFileSize', () => { + it('formats bytes correctly', () => { + expect(formatFileSize(500)).toBe('500 bytes') + }) + + it('formats kilobytes correctly', () => { + expect(formatFileSize(1024, 1)).toBe('1 kB') + expect(formatFileSize(2048, 1)).toBe('2 kB') + }) + + it('formats megabytes correctly', () => { + expect(formatFileSize(1048576, 1)).toBe('1 MB') + expect(formatFileSize(2097152, 1)).toBe('2 MB') + }) + + it('formats gigabytes correctly', () => { + expect(formatFileSize(1073741824, 1)).toBe('1 GB') + expect(formatFileSize(2147483648, 1)).toBe('2 GB') + }) +}) diff --git a/packages/react/src/common/formatFileSize.tsx b/packages/react/src/common/formatFileSize.tsx new file mode 100644 index 0000000000..a17666e3e6 --- /dev/null +++ b/packages/react/src/common/formatFileSize.tsx @@ -0,0 +1,20 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +/** + * @param fileSize The size of the file in bytes. + * @param precision The number of significant digits in the output. + * @returns A human readable file size + */ +export const formatFileSize = (fileSize: number, precision = 3) => { + const UNITS = ['bytes', 'kB', 'MB', 'GB'] + + if (fileSize === 0) return '0 bytes' + + const exponent = Math.floor(Math.log10(fileSize) / 3) + const size = (fileSize / Math.pow(1000, exponent)).toPrecision(precision) + + return `${size} ${UNITS[exponent]}` +} diff --git a/packages/react/src/common/formatFileType.test.tsx b/packages/react/src/common/formatFileType.test.tsx new file mode 100644 index 0000000000..3846c8f801 --- /dev/null +++ b/packages/react/src/common/formatFileType.test.tsx @@ -0,0 +1,29 @@ +import { formatFileType } from './formatFileType' + +describe('formatFileType', () => { + it('formats image types correctly', () => { + expect(formatFileType('image/gif')).toBe('gif') + expect(formatFileType('image/jpeg')).toBe('jpg') + expect(formatFileType('image/png')).toBe('png') + }) + + it('formats text types correctly', () => { + expect(formatFileType('text/plain')).toBe('txt') + }) + + it('formats application types correctly', () => { + expect(formatFileType('application/pdf')).toBe('pdf') + expect(formatFileType('application/msword')).toBe('Word') + expect(formatFileType('application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe('Word') + expect(formatFileType('application/vnd.ms-excel')).toBe('Excel') + expect(formatFileType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')).toBe('Excel') + expect(formatFileType('application/vnd.ms-powerpoint')).toBe('PowerPoint') + expect(formatFileType('application/vnd.openxmlformats-officedocument.presentationml.presentation')).toBe( + 'PowerPoint', + ) + }) + + it('returns the original file type for unknown types', () => { + expect(formatFileType('unknown/type')).toBe('Document') + }) +}) diff --git a/packages/react/src/common/formatFileType.tsx b/packages/react/src/common/formatFileType.tsx new file mode 100644 index 0000000000..17e47d5530 --- /dev/null +++ b/packages/react/src/common/formatFileType.tsx @@ -0,0 +1,35 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +/** + * + * @param fileType + * @returns Human readable file type + */ +export const formatFileType = (fileType: string) => { + switch (fileType) { + case 'image/gif': + return 'gif' + case 'image/jpeg': + return 'jpg' + case 'image/png': + return 'png' + case 'text/plain': + return 'txt' + case 'application/pdf': + return 'pdf' + case 'application/msword': + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return 'Word' + case 'application/vnd.ms-excel': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + return 'Excel' + case 'application/vnd.ms-powerpoint': + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + return 'PowerPoint' + default: + return 'Document' + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9b88224382..5bb39cae01 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './FileList' export * from './ActionGroup' export * from './Breakout' export * from './Hint' diff --git a/proprietary/tokens/src/components/ams/file-list.tokens.json b/proprietary/tokens/src/components/ams/file-list.tokens.json new file mode 100644 index 0000000000..e30532d050 --- /dev/null +++ b/proprietary/tokens/src/components/ams/file-list.tokens.json @@ -0,0 +1,21 @@ +{ + "ams": { + "file-list": { + "gap": { "value": "{ams.space.md}" }, + "padding-block": { "value": "{ams.space.md}" }, + "file": { + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.6.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "gap": { "value": "{ams.space.sm}" }, + "line-height": { "value": "{ams.text.level.6.line-height}" }, + "details": { + "color": { "value": "{ams.brand.color.neutral.60}" } + }, + "preview": { + "width": { "value": "clamp(2.5rem, 10vw, 5rem)" } + } + } + } + } +} diff --git a/storybook/src/components/FileList/FileInputWithFileList.tsx b/storybook/src/components/FileList/FileInputWithFileList.tsx new file mode 100644 index 0000000000..9d29f7557c --- /dev/null +++ b/storybook/src/components/FileList/FileInputWithFileList.tsx @@ -0,0 +1,39 @@ +import { FileInput, FileList } from '@amsterdam/design-system-react' +import { useRef, useState } from 'react' + +export const FileInputWithFileList = () => { + const inputRef = useRef(null) + const [files, setFiles] = useState(null) + + const changeFiles = () => { + if (inputRef.current) { + setFiles(inputRef.current.files) + } + } + + const removeFile = (index: number) => { + if (files) { + const newFiles = new DataTransfer() + Array.from(files).forEach((file, i) => { + if (i !== index) newFiles.items.add(file) + }) + if (inputRef.current) { + inputRef.current.files = newFiles.files + } + setFiles(newFiles.files) + } + } + + return ( + <> + + {files && ( + + {Array.from(files).map((file, index) => ( + removeFile(index)} /> + ))} + + )} + + ) +} diff --git a/storybook/src/components/FileList/FileList.docs.mdx b/storybook/src/components/FileList/FileList.docs.mdx new file mode 100644 index 0000000000..a9b6ca795b --- /dev/null +++ b/storybook/src/components/FileList/FileList.docs.mdx @@ -0,0 +1,23 @@ +{/* @license CC0-1.0 */} + +import { Canvas, Markdown, Meta, Primary, Source } from "@storybook/blocks"; +import FileInputWithFileList from "./FileInputWithFileList?raw"; +import * as FileListStories from "./FileList.stories.tsx"; +import README from "../../../../packages/css/src/components/file-list/README.md?raw"; + + + +{README} + + + +## Examples + +### Using a file input + +To connect a File Input to a File List, use the `onChange` event to update the +list of files and use `onDelete` when removing a file from the list. + + + + diff --git a/storybook/src/components/FileList/FileList.stories.tsx b/storybook/src/components/FileList/FileList.stories.tsx new file mode 100644 index 0000000000..47a9a77e28 --- /dev/null +++ b/storybook/src/components/FileList/FileList.stories.tsx @@ -0,0 +1,44 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { FileList } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' +import { FileInputWithFileList } from './FileInputWithFileList' + +const meta = { + title: 'Components/Forms/File List', + component: FileList, + args: { + children: [ + {}} + />, + {}} + />, + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const WithInput: Story = { + parameters: { + docs: { + canvas: { + sourceState: 'none', + }, + }, + }, + render: () => , +}