diff --git a/packages/css/src/components/file-input/README.md b/packages/css/src/components/file-input/README.md new file mode 100644 index 0000000000..4ab50f20f3 --- /dev/null +++ b/packages/css/src/components/file-input/README.md @@ -0,0 +1,9 @@ + + +# File Input + +Allows the user to upload one or more files from their device. + +## Visual considerations + +The filename label and button are displayed in the language of the browser and can vary between browsers and operating systems. diff --git a/packages/css/src/components/file-input/file-input.scss b/packages/css/src/components/file-input/file-input.scss new file mode 100644 index 0000000000..0a8942825b --- /dev/null +++ b/packages/css/src/components/file-input/file-input.scss @@ -0,0 +1,68 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@import "../../common/text-rendering"; + +@mixin reset-button { + border: 0; + border-radius: 0; // Reset rounded borders on iOS devices + box-sizing: border-box; +} + +.ams-file-input { + background-color: var(--ams-file-input-background-color); + border: var(--ams-file-input-border); + color: var(--ams-file-input-color); + cursor: var(--ams-file-input-cursor); + font-family: var(--ams-file-input-font-family); + font-size: var(--ams-file-input-font-size); + font-weight: var(--ams-file-input-font-weight); + line-height: var(--ams-file-input-line-height); + max-inline-size: calc(100% - var(--ams-file-input-padding-inline) * 2); + outline-offset: 0.25rem; // Double the default focus outline offset to compensate for the dashed border + padding-block: var(--ams-file-input-padding-block); + padding-inline: var(--ams-file-input-padding-inline); + touch-action: manipulation; + + @include text-rendering; +} + +.ams-file-input:disabled { + color: var(--ams-file-input-disabled-color); + cursor: var(--ams-file-input-disabled-cursor); +} + +.ams-file-input::file-selector-button { + appearance: none; // Reset default appearance on iOS devices + background-color: var(--ams-file-input-file-selector-button-background-color); + box-shadow: var(--ams-file-input-file-selector-button-box-shadow); + color: var(--ams-file-input-file-selector-button-color); + cursor: var(--ams-file-input-file-selector-button-cursor); + font-family: inherit; + font-size: inherit; // iOS specific fix + font-weight: inherit; + margin-inline-end: var(--ams-file-input-file-selector-button-margin-inline-end); + padding-block: var(--ams-file-input-file-selector-button-padding-block); + padding-inline: var(--ams-file-input-file-selector-button-padding-inline); + + @media screen and (-ms-high-contrast: active), screen and (forced-colors: active) { + border: var( + --ams-file-input-file-selector-button-forced-color-mode-border + ); // add border because forced colors changes box-shadow to none + } + + @include reset-button; +} + +.ams-file-input:disabled::file-selector-button { + box-shadow: var(--ams-file-input-file-selector-button-disabled-box-shadow); + color: var(--ams-file-input-disabled-color); + cursor: var(--ams-file-input-file-selector-button-disabled-cursor); +} + +.ams-file-input:not(:disabled):hover::file-selector-button { + box-shadow: var(--ams-file-input-file-selector-button-hover-box-shadow); + color: var(--ams-file-input-file-selector-button-hover-color); +} diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index a3ac841829..5f7cf2d09a 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./file-input/file-input"; @import "./field/field"; @import "./select/select"; @import "./time-input/time-input"; diff --git a/packages/react/src/FileInput/FileInput.test.tsx b/packages/react/src/FileInput/FileInput.test.tsx new file mode 100644 index 0000000000..8875679be5 --- /dev/null +++ b/packages/react/src/FileInput/FileInput.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { FileInput } from './FileInput' +import '@testing-library/jest-dom' + +describe('File input', () => { + it('renders', () => { + const { container } = render() + const component = container.querySelector('input[type="file"]') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + const component = container.querySelector('input[type="file"]') + + expect(component).toHaveClass('ams-file-input') + }) + + it('renders an additional class name', () => { + const { container } = render() + const component = container.querySelector('input[type="file"]') + + expect(component).toHaveClass('ams-file-input extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + const component = container.querySelector('input[type="file"]') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/FileInput/FileInput.tsx b/packages/react/src/FileInput/FileInput.tsx new file mode 100644 index 0000000000..3df5f8cec0 --- /dev/null +++ b/packages/react/src/FileInput/FileInput.tsx @@ -0,0 +1,18 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, InputHTMLAttributes } from 'react' + +export type FileInputProps = InputHTMLAttributes + +export const FileInput = forwardRef( + ({ className, ...restProps }: FileInputProps, ref: ForwardedRef) => ( + + ), +) + +FileInput.displayName = 'FileInput' diff --git a/packages/react/src/FileInput/README.md b/packages/react/src/FileInput/README.md new file mode 100644 index 0000000000..5fe4f7d710 --- /dev/null +++ b/packages/react/src/FileInput/README.md @@ -0,0 +1,5 @@ + + +# React File Input component + +[File Input documentation](../../../css/src/components/file-input/README.md) diff --git a/packages/react/src/FileInput/index.ts b/packages/react/src/FileInput/index.ts new file mode 100644 index 0000000000..73c0b3a40c --- /dev/null +++ b/packages/react/src/FileInput/index.ts @@ -0,0 +1,2 @@ +export { FileInput } from './FileInput' +export type { FileInputProps } from './FileInput' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9c8a57cd88..4334528431 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './FileInput' export * from './Field' export * from './Select' export * from './TimeInput' diff --git a/proprietary/tokens/src/components/ams/file-input.tokens.json b/proprietary/tokens/src/components/ams/file-input.tokens.json new file mode 100644 index 0000000000..b0f31f9437 --- /dev/null +++ b/proprietary/tokens/src/components/ams/file-input.tokens.json @@ -0,0 +1,42 @@ +{ + "ams": { + "file-input": { + "background-color": { "value": "{ams.color.primary-white}" }, + "border": { "value": "{ams.border.width.sm} dashed {ams.color.neutral-grey3}" }, + "color": { "value": "{ams.color.primary-black}" }, + "cursor": { "value": "{ams.action.activate.cursor}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.5.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "outline-offset": { "value": "{ams.focus.outline-offset}" }, + "padding-block": { "value": "{ams.space.inside.md}" }, + "padding-inline": { "value": "{ams.space.inside.md}" }, + "disabled": { + "color": { "value": "{ams.color.neutral-grey2}" }, + "cursor": { "value": "{ams.action.disabled.cursor}" } + }, + "file-selector-button": { + "background-color": { "value": "{ams.color.primary-white}" }, + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-blue}" }, + "color": { "value": "{ams.color.primary-blue}" }, + "cursor": { "value": "{ams.action.activate.cursor}" }, + "margin-inline-end": { "value": "{ams.space.inside.md}" }, + "padding-block": { "value": "{ams.space.inside.xs}" }, + "padding-inline": { "value": "{ams.space.inside.md}" }, + "hover": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.lg} {ams.color.dark-blue}" }, + "color": { "value": "{ams.color.dark-blue}" } + }, + "disabled": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.neutral-grey2}" }, + "color": { "value": "{ams.color.neutral-grey2}" }, + "cursor": { "value": "{ams.action.disabled.cursor}" } + }, + "forced-color-mode": { + "border": { "value": "{ams.border.width.md} solid" } + } + } + } + } +} diff --git a/storybook/src/components/FileInput/FileInput.docs.mdx b/storybook/src/components/FileInput/FileInput.docs.mdx new file mode 100644 index 0000000000..010919d592 --- /dev/null +++ b/storybook/src/components/FileInput/FileInput.docs.mdx @@ -0,0 +1,29 @@ +import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as FileInputStories from "./FileInput.stories.tsx"; +import README from "../../../../packages/css/src/components/file-input/README.md?raw"; + + + +{README} + + + + + +## Multiple Files + +Allow multiple files to be selected. The label will update to show the number of files selected. + + + +## Accept + +Limit the types of files that can be selected. Some examples are `image/*`, `video/*`, or `audio/*`. To limit to a specific file type, use the MIME type, such as `application/pdf`. + +- [MDN File Input](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#limiting_accepted_file_types): More examples + + + +## Disabled + + diff --git a/storybook/src/components/FileInput/FileInput.stories.tsx b/storybook/src/components/FileInput/FileInput.stories.tsx new file mode 100644 index 0000000000..19758a651d --- /dev/null +++ b/storybook/src/components/FileInput/FileInput.stories.tsx @@ -0,0 +1,52 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { FileInput } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Forms/File Input', + component: FileInput, + args: { + accept: undefined, + multiple: false, + disabled: false, + }, + argTypes: { + accept: { + control: { + type: 'text', + }, + }, + multiple: { + control: { + type: 'boolean', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Multiple: Story = { + args: { multiple: true }, +} + +export const Accept: Story = { + args: { accept: 'application/pdf' }, +} + +export const Disabled: Story = { + args: { disabled: true }, +}