diff --git a/src/elements/button/common/common-stories.ts b/src/elements/button/common/common-stories.ts index 82179405e4..d4cce7c369 100644 --- a/src/elements/button/common/common-stories.ts +++ b/src/elements/button/common/common-stories.ts @@ -107,6 +107,11 @@ export const commonDefaultArgTypes: ArgTypes = { 'icon-name': iconName, }; +/** + * NOTE + * The tag is the tagName of the component to display in stories, + * so it must be overridden before use. + */ export const commonDefaultArgs: Args = { tag: 'TBD', text: 'Button', diff --git a/src/elements/file-selector.ts b/src/elements/file-selector.ts index e7ed86ce1f..15f6e8c5ee 100644 --- a/src/elements/file-selector.ts +++ b/src/elements/file-selector.ts @@ -1 +1,3 @@ +export * from './file-selector/common.js'; +export * from './file-selector/file-selector-dropzone.js'; export * from './file-selector/file-selector.js'; diff --git a/src/elements/file-selector/common.ts b/src/elements/file-selector/common.ts new file mode 100644 index 0000000000..b350d9f229 --- /dev/null +++ b/src/elements/file-selector/common.ts @@ -0,0 +1,3 @@ +export * from './common/file-selector-common.js'; + +export { default as fileSelectorCommonStyle } from './common/file-selector-common.scss?lit&inline'; diff --git a/src/elements/file-selector/common/file-selector-common-stories.ts b/src/elements/file-selector/common/file-selector-common-stories.ts new file mode 100644 index 0000000000..706cd906f4 --- /dev/null +++ b/src/elements/file-selector/common/file-selector-common-stories.ts @@ -0,0 +1,147 @@ +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, StoryObj } from '@storybook/web-components'; +import { type TemplateResult } from 'lit'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; +import type { SbbFormErrorElement } from '../../form-error.js'; +import type { SbbFileSelectorDropzoneElement } from '../file-selector-dropzone.js'; +import type { SbbFileSelectorElement } from '../file-selector.js'; + +/* eslint-disable lit/binding-positions, @typescript-eslint/naming-convention */ +export const FileSelectorTemplate = ({ tag, ...args }: Args): TemplateResult => + html`<${unsafeStatic(tag)} ${sbbSpread(args)}>`; + +export const FileSelectorTemplateWithError = ({ tag, ...args }: Args): TemplateResult => { + const sbbFormError: SbbFormErrorElement = document.createElement('sbb-form-error'); + sbbFormError.setAttribute('slot', 'error'); + sbbFormError.textContent = 'There has been an error.'; + + return html` + <${unsafeStatic(tag)} + ${sbbSpread(args)} + id="sbb-file-selector" + @fileChanged=${(event: CustomEvent) => { + if (event.detail && event.detail.length > 0) { + (event.target as SbbFileSelectorElement | SbbFileSelectorDropzoneElement)!.append( + sbbFormError, + ); + } else { + sbbFormError.remove(); + } + }} + > + `; +}; +/* eslint-enable lit/binding-positions, @typescript-eslint/naming-convention */ + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const multiple: InputType = { + control: { + type: 'boolean', + }, +}; + +const multipleMode: InputType = { + control: { + type: 'inline-radio', + }, + options: ['default', 'persistent'], +}; + +const accept: InputType = { + control: { + type: 'text', + }, +}; + +const accessibilityLabel: InputType = { + control: { + type: 'text', + }, +}; + +const tag: InputType = { + control: { + type: 'text', + }, + table: { + disable: true, + }, +}; + +export const fileSelectorDefaultArgTypes: ArgTypes = { + tag, + size, + disabled, + multiple, + 'multiple-mode': multipleMode, + accept, + 'accessibility-label': accessibilityLabel, +}; + +/** + * NOTE + * The tag is the tagName of the component to display in stories, + * so it must be overridden before use. + */ +export const fileSelectorDefaultArgs: Args = { + tag: 'TBD', + size: size.options![0], + disabled: false, + multiple: false, + 'multiple-mode': multipleMode.options![0], + accept: undefined, + 'accessibility-label': 'Select from hard disk', +}; + +export const fileSelectorMultipleDefaultArgs: Args = { + ...fileSelectorDefaultArgs, + multiple: true, + 'accessibility-label': 'Select from hard disk - multiple files allowed', +}; + +export const defaultFileSelector: StoryObj = { + render: FileSelectorTemplate, +}; + +export const defaultDisabled: StoryObj = { + render: FileSelectorTemplate, + args: { disabled: true }, +}; + +export const defaultMulti: StoryObj = { + render: FileSelectorTemplate, + args: fileSelectorMultipleDefaultArgs, +}; + +export const defaultMultiPersistent: StoryObj = { + render: FileSelectorTemplate, + args: { ...fileSelectorMultipleDefaultArgs, 'multiple-mode': 'persistent' }, +}; + +export const defaultWithError: StoryObj = { + render: FileSelectorTemplateWithError, +}; + +export const defaultOnlyPDF: StoryObj = { + render: FileSelectorTemplate, + args: { accept: '.pdf' }, +}; + +export const defaultMultiSizeS: StoryObj = { + render: FileSelectorTemplate, + args: { ...fileSelectorMultipleDefaultArgs, size: 's' }, +}; diff --git a/src/elements/file-selector/file-selector.scss b/src/elements/file-selector/common/file-selector-common.scss similarity index 66% rename from src/elements/file-selector/file-selector.scss rename to src/elements/file-selector/common/file-selector-common.scss index c69225cc99..1cb1651112 100644 --- a/src/elements/file-selector/file-selector.scss +++ b/src/elements/file-selector/common/file-selector-common.scss @@ -1,4 +1,4 @@ -@use '../core/styles' as sbb; +@use '../../core/styles' as sbb; // Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. @include sbb.box-sizing; @@ -44,40 +44,6 @@ @include sbb.screen-reader-only; } -.sbb-file-selector__dropzone-area { - display: flex; - flex-direction: column; - align-items: center; - padding: var(--sbb-spacing-responsive-s); - background-color: var(--sbb-file-selector-background-color); - border: var(--sbb-border-width-1x) dashed var(--sbb-file-selector-border-color); - border-radius: var(--sbb-border-radius-4x); - transition-duration: var(--sbb-file-selector-transition-duration); - transition-timing-function: var(--sbb-file-selector-transition-easing-function); - transition-property: background-color, border-color; -} - -.sbb-file-selector__dropzone-area--icon { - color: var(--sbb-file-selector-color); - line-height: 0; -} - -.sbb-file-selector__dropzone-area--title { - @include sbb.text--bold; - @include sbb.title-6($exclude-spacing: true); - - text-align: center; - color: var(--sbb-file-selector-color); -} - -.sbb-file-selector__dropzone-area--subtitle { - @include sbb.text-xs--regular; - - text-align: center; - color: var(--sbb-file-selector-subtitle-color); - margin-block-end: var(--sbb-spacing-fixed-4x); -} - .sbb-file-selector__file-list { display: flex; flex-direction: column; diff --git a/src/elements/file-selector/common/file-selector-common.spec.ts b/src/elements/file-selector/common/file-selector-common.spec.ts new file mode 100644 index 0000000000..e97df52c5d --- /dev/null +++ b/src/elements/file-selector/common/file-selector-common.spec.ts @@ -0,0 +1,323 @@ +import { expect } from '@open-wc/testing'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import type { SbbSecondaryButtonElement } from '../../button.js'; +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForLitRender } from '../../core/testing.js'; +import type { SbbFileSelectorDropzoneElement } from '../file-selector-dropzone.js'; +import type { SbbFileSelectorElement } from '../file-selector.js'; + +import '../file-selector-dropzone.js'; +import '../file-selector.js'; + +import '../../button/secondary-button.js'; + +function createDataTransfer( + numberOfFiles: number, + filesContent: string | string[] = 'Hello world', +): DataTransfer { + const dataTransfer: DataTransfer = new DataTransfer(); + for (let i: number = 0; i < numberOfFiles; i++) { + const content = filesContent instanceof Array ? filesContent[i] : filesContent; + dataTransfer.items.add( + new File([`${content} - ${i}`], `hello${i}.txt`, { + type: 'text/plain', + lastModified: new Date(i).getMilliseconds(), + }), + ); + } + return dataTransfer; +} + +function addFiles( + element: HTMLInputElement | SbbFileSelectorElement | SbbFileSelectorDropzoneElement, + numberOfFiles: number, + filesContent: string | string[] = 'Hello world', +): void { + const dt = createDataTransfer(numberOfFiles, filesContent); + let nativeInput: HTMLInputElement; + + if (element instanceof HTMLInputElement) { + nativeInput = element; + element.files = dt.files; + } else { + nativeInput = element.shadowRoot!.querySelector('input')!; + nativeInput.files = dt.files; + } + + // Manually dispatch events to simulate a user interaction + nativeInput.dispatchEvent(new Event('input', { composed: true, bubbles: true })); + nativeInput.dispatchEvent(new Event('change')); +} + +describe('sbb-file-selector common', () => { + ['sbb-file-selector', 'sbb-file-selector-dropzone'].forEach((selector) => { + const tagSingle = unsafeStatic(selector); + + describe(selector, () => { + let form: HTMLFormElement; + let element: SbbFileSelectorElement | SbbFileSelectorDropzoneElement; + let input: HTMLInputElement; + let fieldSet: HTMLFieldSetElement; + let elemChangeEvent: EventSpy, + elemInputEvent: EventSpy, + nativeChangeEvent: EventSpy, + nativeInputEvent: EventSpy; + + beforeEach(async () => { + /* eslint-disable lit/binding-positions */ + form = await fixture(html` +
+
+ <${tagSingle} name='fs'> + +
+
+ `); + /* eslint-enable lit/binding-positions */ + + element = form.querySelector(selector)!; + input = form.querySelector('input')!; + fieldSet = form.querySelector('fieldset')!; + + // event spies + elemChangeEvent = new EventSpy('change', element); + elemInputEvent = new EventSpy('input', element); + nativeChangeEvent = new EventSpy('change', input); + nativeInputEvent = new EventSpy('input', input); + + await waitForLitRender(form); + }); + + function compareToNativeInput(): void { + // Compare files + expect(element.files.length, 'files - length').to.be.equal(Array.from(input.files!).length); + element.files.forEach((e, i) => { + expect(e.name, `file - name - ${i}`).to.be.equal(Array.from(input.files!)[i].name); + expect(e.type, `file - type - ${i}`).to.be.equal(Array.from(input.files!)[i].type); + expect(e.size, `file - size - ${i}`).to.be.equal(Array.from(input.files!)[i].size); + expect(e.lastModified, `file - lastModified - ${i}`).to.be.equal( + Array.from(input.files!)[i].lastModified, + ); + }); + + // Compare formData + const formData = new FormData(form); + const fileSelectorFormData = formData.getAll('fs'); + const inputFormData = formData.getAll('native'); + + if (fileSelectorFormData.length === 0) { + /** + * Custom implementation + * If empty, the native input adds an 'empty' file to the FormData (we don't). + * So the equivalent of an empty 'sbb-file-selector' is a native with an empty file + */ + expect(inputFormData.length, 'formData - no file').to.be.equal(1); + expect((inputFormData[0] as File).size, 'formData - no file - size').to.be.equal(0); + expect((inputFormData[0] as File).name, 'formData - no file - name').to.be.equal(''); + } else { + expect(fileSelectorFormData.length, 'formData - files').to.be.equal(inputFormData.length); + fileSelectorFormData.forEach((e, i) => { + expect((e as File).name, `formData - file name - ${i}`).to.be.equal( + (inputFormData[i] as File).name, + ); + expect((e as File).type, `formData - file type - ${i}`).to.be.equal( + (inputFormData[i] as File).type, + ); + expect((e as File).size, `formData - file size - ${i}`).to.be.equal( + (inputFormData[i] as File).size, + ); + expect((e as File).lastModified, `formData - file lastModified - ${i}`).to.be.equal( + (inputFormData[i] as File).lastModified, + ); + }); + } + + expect(elemChangeEvent.count, 'change event').to.be.equal(nativeChangeEvent.count); + expect(elemInputEvent.count, 'input event').to.be.equal(nativeInputEvent.count); + } + + it('renders', () => { + compareToNativeInput(); + }); + + it('loads a file, then deletes it', async () => { + const fileChangedSpy = new EventSpy('fileChanged'); + addFiles(element, 1); + addFiles(input, 1); + await waitForLitRender(form); + + expect(fileChangedSpy.count).to.be.equal(1); + expect(element.files.length).to.be.equal(1); + compareToNativeInput(); + + const listItems = element.shadowRoot!.querySelector( + '.sbb-file-selector__file-list', + ); + expect(listItems).dom.to.be.equal(` +
+ + + hello0.txt + 15 B + + + + +
+ `); + + const button: SbbSecondaryButtonElement = + element.shadowRoot!.querySelector( + 'sbb-secondary-button[icon-name="trash-small"]', + )!; + expect(button).not.to.be.null; + button.click(); + addFiles(input, 0); + await waitForLitRender(form); + + const files = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file'); + expect(fileChangedSpy.count).to.be.equal(2); + expect(files.length).to.be.equal(0); + compareToNativeInput(); + }); + + it('loads a file, then reset the form', async () => { + const fileChangedSpy = new EventSpy('fileChanged'); + addFiles(element, 1); + addFiles(input, 1); + await waitForLitRender(form); + + expect(fileChangedSpy.count).to.be.equal(1); + expect(element.files.length).to.be.equal(1); + compareToNativeInput(); + + form.reset(); + await waitForLitRender(form); + compareToNativeInput(); + }); + + it('restore formState', async () => { + const dt = createDataTransfer(2); + const formRestoreState: [string, FormDataEntryValue][] = Array.from(dt.files).map((e) => [ + 'fs', + e, + ]); + element.formStateRestoreCallback(formRestoreState, 'restore'); + await waitForLitRender(form); + expect(element.files.length).to.be.equal(2); + }); + + it('loads more than one file in multiple mode', async () => { + const fileChangedSpy = new EventSpy('fileChanged'); + element.multiple = true; + input.multiple = true; + await waitForLitRender(form); + addFiles(element, 2); + addFiles(input, 2); + await waitForLitRender(form); + expect(fileChangedSpy.count).to.be.equal(1); + compareToNativeInput(); + + const listItems = element.shadowRoot!.querySelectorAll('li'); + const filesDetails = element.shadowRoot!.querySelectorAll( + '.sbb-file-selector__file-details', + ); + const filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); + const filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); + + expect(listItems.length).to.be.equal(2); + expect(filesDetails.length).to.be.equal(2); + expect(filesName[0]).dom.text('hello0.txt'); + expect(filesName[1]).dom.text('hello1.txt'); + expect(filesSize[0]).dom.text('15 B'); + expect(filesSize[1]).dom.text('15 B'); + }); + + it('loads files in multiple persistent mode', async () => { + const fileChangedSpy = new EventSpy('fileChanged'); + element.multiple = true; + element.multipleMode = 'persistent'; + await waitForLitRender(form); + addFiles(element, 1); + await waitForLitRender(form); + expect(fileChangedSpy.count).to.be.equal(1); + + const filesDetails = element.shadowRoot!.querySelectorAll( + '.sbb-file-selector__file-details', + ); + let filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); + let filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); + + expect(element.files).not.to.be.null; + expect(filesName.length).to.be.equal(1); + expect(filesDetails.length).to.be.equal(1); + expect(filesName[0]).dom.text('hello0.txt'); + expect(filesSize[0]).dom.text('15 B'); + + const longContent = 'Lorem ipsum dolor sit amet. '.repeat(100); + addFiles(element, 2, ['Hello world', longContent]); + + await waitForLitRender(form); + + const files = element.shadowRoot!.querySelectorAll('li'); + filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); + filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); + + expect(fileChangedSpy.count).to.be.equal(2); + expect(files.length).to.be.equal(2); + expect(filesName[0]).dom.text('hello1.txt'); + expect(filesSize[0]).dom.text('3 kB'); + expect(filesName[1]).dom.text('hello0.txt'); + expect(filesSize[1]).dom.text('15 B'); + }); + + it('should update formValue on name change', async () => { + addFiles(element, 1); + await waitForLitRender(form); + + let formData = new FormData(form); + const fileSelectorFormData = formData.getAll('fs'); + expect(fileSelectorFormData.length).to.be.equal(1); + + element.name = 'new-fs'; + await waitForLitRender(form); + + formData = new FormData(form); + expect(formData.getAll('fs').length).to.be.equal(0); + expect(formData.getAll('new-fs').length).to.be.equal(1); + }); + + it('should result as :disabled', async () => { + element.disabled = true; + await waitForLitRender(form); + + expect(element).to.match(':disabled'); + + element.disabled = false; + await waitForLitRender(form); + + expect(element).not.to.match(':disabled'); + }); + + it('should result :disabled if a fieldSet is', async () => { + fieldSet.disabled = true; + + await waitForLitRender(form); + + expect(element).to.match(':disabled'); + + fieldSet.disabled = false; + await waitForLitRender(form); + + expect(element).not.to.match(':disabled'); + }); + }); + }); +}); diff --git a/src/elements/file-selector/common/file-selector-common.ts b/src/elements/file-selector/common/file-selector-common.ts new file mode 100644 index 0000000000..96951e24c9 --- /dev/null +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -0,0 +1,283 @@ +import type { LitElement, TemplateResult } from 'lit'; +import { nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import type { SbbSecondaryButtonStaticElement } from '../../button.js'; +import { sbbInputModalityDetector } from '../../core/a11y.js'; +import { SbbLanguageController } from '../../core/controllers.js'; +import { forceType } from '../../core/decorators.js'; +import { EventEmitter, forwardEventToHost } from '../../core/eventing.js'; +import { + i18nFileSelectorButtonLabel, + i18nFileSelectorCurrentlySelected, + i18nFileSelectorDeleteFile, +} from '../../core/i18n.js'; +import { + type FormRestoreReason, + type FormRestoreState, + SbbDisabledMixin, + SbbFormAssociatedMixin, + type SbbFormAssociatedMixinType, + type Constructor, +} from '../../core/mixins.js'; + +import '../../button/secondary-button.js'; +import '../../button/secondary-button-static.js'; +import '../../icon.js'; + +export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbFormAssociatedMixinType { + public accessor size: 's' | 'm'; + public accessor multiple: boolean; + public accessor multipleMode: 'default' | 'persistent'; + public accessor accept: string; + public accessor accessibilityLabel: string; + public accessor disabled: boolean; + public accessor files: Readonly[]; + protected formDisabled: boolean; + protected loadButton: SbbSecondaryButtonStaticElement; + protected language: SbbLanguageController; + protected abstract renderTemplate(input: TemplateResult): TemplateResult; + protected createFileList(files: FileList): void; + protected updateFormValue(): void; + public formResetCallback(): void; + public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbFileSelectorCommonElementMixin = >( + superclass: T, +): Constructor & T => { + abstract class SbbFileSelectorCommonElement + extends SbbDisabledMixin(SbbFormAssociatedMixin(superclass)) + implements Partial + { + public static readonly events = { + fileChangedEvent: 'fileChanged', + } as const; + + /** Size variant, either s or m. */ + @property({ reflect: true }) public accessor size: 's' | 'm' = 'm'; + + /** Whether more than one file can be selected. */ + @forceType() + @property({ type: Boolean }) + public accessor multiple: boolean = false; + + /** Whether the newly added files should override the previously added ones. */ + @property({ attribute: 'multiple-mode' }) + public accessor multipleMode: 'default' | 'persistent' = 'default'; + + /** A comma-separated list of allowed unique file type specifiers. */ + @forceType() + @property() + public accessor accept: string = ''; + + /** This will be forwarded as aria-label to the native input element. */ + @forceType() + @property({ attribute: 'accessibility-label' }) + public accessor accessibilityLabel: string = ''; + + /** The path of the first selected file. Empty string ('') if no file is selected */ + @property({ attribute: false }) + public override set value(value: string | null) { + this._hiddenInput.value = value ?? ''; + + if (!value) { + this.files = []; + } + } + + public override get value(): string | null { + return this._hiddenInput?.value; + } + + /** The list of selected files. */ + @property({ attribute: false }) + public set files(value: Readonly[]) { + this._files = value ?? []; + + // update the inner input + const dt: DataTransfer = new DataTransfer(); + this.files.forEach((e: Readonly) => dt.items.add(e)); + this._hiddenInput.files = dt.files; + + this.updateFormValue(); + } + public get files(): Readonly[] { + return this._files; + } + private _files: Readonly[] = []; + + /** An event which is emitted each time the file list changes. */ + private _fileChangedEvent: EventEmitter[]> = new EventEmitter( + this, + SbbFileSelectorCommonElement.events.fileChangedEvent, + ); + + private _hiddenInput!: HTMLInputElement; + private _suffixes: string[] = ['B', 'kB', 'MB', 'GB', 'TB']; + private _liveRegion!: HTMLParagraphElement; + protected loadButton!: SbbSecondaryButtonStaticElement; + protected language = new SbbLanguageController(this); + + protected abstract renderTemplate(input: TemplateResult): TemplateResult; + + public override formResetCallback(): void { + this.files = []; + } + + public override formStateRestoreCallback( + state: FormRestoreState | null, + _reason?: FormRestoreReason, + ): void { + if (!state) { + return; + } + this.files = (state as [string, FormDataEntryValue][]).map( + ([_, value]) => value as Readonly, + ); + } + + protected override updateFormValue(): void { + const formValue = new FormData(); + this.files.forEach((file) => formValue.append(this.name, file)); + this.internals.setFormValue(formValue); + } + + private _checkFileEquality(file1: Readonly, file2: Readonly): boolean { + return ( + file1.name === file2.name && + file1.size === file2.size && + file1.lastModified === file2.lastModified + ); + } + + private _onFocus(): void { + if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { + this.loadButton.toggleAttribute('data-focus-visible', true); + } + } + + private _onBlur(): void { + if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { + this.loadButton.removeAttribute('data-focus-visible'); + } + } + + private _readFiles(event: Event): void { + const fileInput = event.target as HTMLInputElement; + if (fileInput.files) { + this.createFileList(fileInput.files); + } + forwardEventToHost(event, this); + } + + protected createFileList(files: FileList): void { + if (!this.multiple || this.multipleMode !== 'persistent' || this.files.length === 0) { + this.files = Array.from(files); + } else { + this.files = Array.from(files) + .filter( + // Remove duplicates + (newFile: Readonly): boolean => + this.files!.findIndex((oldFile: Readonly) => + this._checkFileEquality(newFile, oldFile), + ) === -1, + ) + .concat(this.files); + } + this._updateA11yLiveRegion(); + this._fileChangedEvent.emit(this.files); + } + + private _removeFile(file: Readonly): void { + this.files = this.files.filter((f: Readonly) => !this._checkFileEquality(file, f)); + this._updateA11yLiveRegion(); + + // Dispatch native events as if the reset is done via the file selection window. + this.dispatchEvent(new Event('input', { composed: true, bubbles: true })); + this.dispatchEvent(new Event('change', { bubbles: true })); + this._fileChangedEvent.emit(this.files); + } + + /** Calculates the correct unit for the file's size. */ + private _formatFileSize(size: number): string { + const i: number = Math.floor(Math.log(size) / Math.log(1024)); + return `${(size / Math.pow(1024, i)).toFixed(0)} ${this._suffixes[i]}`; + } + + private _updateA11yLiveRegion(): void { + this._liveRegion.innerText = i18nFileSelectorCurrentlySelected(this.files.map((e) => e.name))[ + this.language.current + ]; + } + + private _renderFileList(): TemplateResult { + const TAG_NAME: Record = + this.files.length > 1 + ? { WRAPPER: 'ul', ELEMENT: 'li' } + : { WRAPPER: 'div', ELEMENT: 'span' }; + + /* eslint-disable lit/binding-positions */ + return html` + <${unsafeStatic(TAG_NAME.WRAPPER)} class='sbb-file-selector__file-list'> + ${this.files.map( + (file: Readonly) => html` + <${unsafeStatic(TAG_NAME.ELEMENT)} class='sbb-file-selector__file'> + + ${file.name} + ${this._formatFileSize(file.size)} + + + `, + )} + + `; + /* eslint-enable lit/binding-positions */ + } + + protected override render(): TemplateResult { + const ariaLabel = this.accessibilityLabel + ? `${i18nFileSelectorButtonLabel[this.language.current]} - ${this.accessibilityLabel}` + : undefined; + return html` +
+ ${this.renderTemplate( + html` { + this._hiddenInput = el as HTMLInputElement; + })} + />`, + )} +

(this._liveRegion = p as HTMLParagraphElement))} + >

+ ${this.files.length > 0 ? this._renderFileList() : nothing} +
+ +
+
+ `; + } + } + return SbbFileSelectorCommonElement as unknown as Constructor & + T; +}; diff --git a/src/elements/file-selector/file-selector-dropzone.ts b/src/elements/file-selector/file-selector-dropzone.ts new file mode 100644 index 0000000000..6fea0ceca0 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone.ts @@ -0,0 +1 @@ +export * from './file-selector-dropzone/file-selector-dropzone.js'; diff --git a/src/elements/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js b/src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js similarity index 52% rename from src/elements/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js rename to src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js index 6f68f55637..d8844c8b57 100644 --- a/src/elements/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js +++ b/src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js @@ -1,54 +1,13 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["sbb-file-selector renders DOM"] = -` - +snapshots["sbb-file-selector-dropzone renders DOM"] = +` + `; -/* end snapshot sbb-file-selector renders DOM */ +/* end snapshot sbb-file-selector-dropzone renders DOM */ -snapshots["sbb-file-selector renders Shadow DOM"] = -`
-
- -
-

-

-
- - -
-
-`; -/* end snapshot sbb-file-selector renders Shadow DOM */ - -snapshots["sbb-file-selector renders with dropzone area and size s DOM"] = -` - -`; -/* end snapshot sbb-file-selector renders with dropzone area and size s DOM */ - -snapshots["sbb-file-selector renders with dropzone area and size s Shadow DOM"] = +snapshots["sbb-file-selector-dropzone renders Shadow DOM"] = `
`; -/* end snapshot sbb-file-selector renders with dropzone area and size s Shadow DOM */ +/* end snapshot sbb-file-selector-dropzone renders Shadow DOM */ -snapshots["sbb-file-selector renders with dropzone area and size s A11y tree Chrome"] = +snapshots["sbb-file-selector-dropzone renders A11y tree Chrome"] = `

{ "role": "WebArea", @@ -120,9 +79,9 @@ snapshots["sbb-file-selector renders with dropzone area and size s A11y tree Chr }

`; -/* end snapshot sbb-file-selector renders with dropzone area and size s A11y tree Chrome */ +/* end snapshot sbb-file-selector-dropzone renders A11y tree Chrome */ -snapshots["sbb-file-selector renders with dropzone area and size s A11y tree Firefox"] = +snapshots["sbb-file-selector-dropzone renders A11y tree Firefox"] = `

{ "role": "document", @@ -144,5 +103,5 @@ snapshots["sbb-file-selector renders with dropzone area and size s A11y tree Fir }

`; -/* end snapshot sbb-file-selector renders with dropzone area and size s A11y tree Firefox */ +/* end snapshot sbb-file-selector-dropzone renders A11y tree Firefox */ diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.scss b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.scss new file mode 100644 index 0000000000..27a9118f58 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.scss @@ -0,0 +1,38 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +.sbb-file-selector__dropzone-area { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--sbb-spacing-responsive-s); + background-color: var(--sbb-file-selector-background-color); + border: var(--sbb-border-width-1x) dashed var(--sbb-file-selector-border-color); + border-radius: var(--sbb-border-radius-4x); + transition-duration: var(--sbb-file-selector-transition-duration); + transition-timing-function: var(--sbb-file-selector-transition-easing-function); + transition-property: background-color, border-color; +} + +.sbb-file-selector__dropzone-area--icon { + color: var(--sbb-file-selector-color); + line-height: 0; +} + +.sbb-file-selector__dropzone-area--title { + @include sbb.text--bold; + @include sbb.title-6($exclude-spacing: true); + + text-align: center; + color: var(--sbb-file-selector-color); +} + +.sbb-file-selector__dropzone-area--subtitle { + @include sbb.text-xs--regular; + + text-align: center; + color: var(--sbb-file-selector-subtitle-color); + margin-block-end: var(--sbb-spacing-fixed-4x); +} diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.snapshot.spec.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.snapshot.spec.ts new file mode 100644 index 0000000000..34367e8472 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.snapshot.spec.ts @@ -0,0 +1,28 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; +import './file-selector-dropzone.js'; + +describe(`sbb-file-selector-dropzone`, () => { + describe('renders', () => { + let element: SbbFileSelectorDropzoneElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + // We skip safari because it has an inconsistent behavior on ci environment + testA11yTreeSnapshot(undefined, undefined, { safari: true }); + }); +}); diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.spec.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.spec.ts new file mode 100644 index 0000000000..8f16066924 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.spec.ts @@ -0,0 +1,32 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { waitForLitRender } from '../../core/testing.js'; + +import { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; + +describe(`sbb-file-selector-dropzone`, () => { + let form: HTMLFormElement; + let element: SbbFileSelectorDropzoneElement; + + beforeEach(async () => { + form = await fixture(html` +
+
+ + +
+
+ `); + element = form.querySelector('sbb-file-selector-dropzone')!; + + await waitForLitRender(form); + }); + + it('renders', () => { + assert.instanceOf(element, SbbFileSelectorDropzoneElement); + }); + + // All the functionalities of sbb-file-selector-dropzone are tested in file-selector-common.spec.ts file +}); diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ssr.spec.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ssr.spec.ts new file mode 100644 index 0000000000..7713be2058 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ssr.spec.ts @@ -0,0 +1,23 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; + +describe(`sbb-file-selector-dropzone ssr`, () => { + let root: SbbFileSelectorDropzoneElement; + + beforeEach(async () => { + root = await ssrHydratedFixture( + html``, + { + modules: ['./file-selector-dropzone.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbFileSelectorDropzoneElement); + }); +}); diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts new file mode 100644 index 0000000000..3934a33673 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts @@ -0,0 +1,60 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; + +import { + defaultDisabled, + defaultFileSelector, + defaultMulti, + defaultMultiPersistent, + defaultMultiSizeS, + defaultOnlyPDF, + defaultWithError, + fileSelectorDefaultArgs, + fileSelectorDefaultArgTypes, +} from '../common/file-selector-common-stories.js'; + +import { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; +import readme from './readme.md?raw'; + +const titleContent: InputType = { + control: { + type: 'text', + }, +}; + +const fileSelectorDropzoneArgTypes: ArgTypes = { + ...fileSelectorDefaultArgTypes, + 'title-content': titleContent, +}; + +const fileSelectorDropzoneArgs: Args = { + ...fileSelectorDefaultArgs, + 'title-content': 'Title', + tag: 'sbb-file-selector-dropzone', +}; + +export const FileSelectorDropzone: StoryObj = defaultFileSelector; +export const Disabled: StoryObj = defaultDisabled; +export const Multi: StoryObj = defaultMulti; +export const MultiPersistent: StoryObj = defaultMultiPersistent; +export const WithError: StoryObj = defaultWithError; +export const OnlyPDF: StoryObj = defaultOnlyPDF; +export const MultiSizeS: StoryObj = defaultMultiSizeS; + +const meta: Meta = { + args: fileSelectorDropzoneArgs, + argTypes: fileSelectorDropzoneArgTypes, + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: [SbbFileSelectorDropzoneElement.events.fileChangedEvent], + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-file-selector/sbb-file-selector-dropzone', +}; + +export default meta; diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts new file mode 100644 index 0000000000..5e699b3e61 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts @@ -0,0 +1,132 @@ +import { type CSSResultGroup, LitElement, type TemplateResult } from 'lit'; +import { property, customElement } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { SbbSecondaryButtonStaticElement } from '../../button.js'; +import { forceType, slotState } from '../../core/decorators.js'; +import { i18nFileSelectorButtonLabel, i18nFileSelectorSubtitleLabel } from '../../core/i18n.js'; +import { fileSelectorCommonStyle, SbbFileSelectorCommonElementMixin } from '../common.js'; + +import '../../button/secondary-button.js'; +import '../../button/secondary-button-static.js'; +import '../../icon.js'; + +import style from './file-selector-dropzone.scss?lit&inline'; + +/** + * It allows to select one or more file from storage devices via button click or drag and drop, and display them. + * + * @slot error - Use this to provide a `sbb-form-error` to show an error message. + * @event {CustomEvent[]>} fileChanged - An event which is emitted each time the file list changes. + * @event change - An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value + * @event input - An event which is emitted each time the value changes as a direct result of a user action. + */ +export +@customElement('sbb-file-selector-dropzone') +@slotState() +class SbbFileSelectorDropzoneElement extends SbbFileSelectorCommonElementMixin(LitElement) { + public static override styles: CSSResultGroup = [fileSelectorCommonStyle, style]; + public static readonly events = { + fileChangedEvent: 'fileChanged', + } as const; + + /** The title displayed in `dropzone` variant. */ + @forceType() + @property({ attribute: 'title-content' }) + public accessor titleContent: string = ''; + + // Safari has a peculiar behavior when dragging files on the inner button in 'dropzone' variant; + // this will require a counter to correctly handle the dragEnter/dragLeave. + private _counter: number = 0; + private _dragTarget?: HTMLElement; + + private _blockEvent(event: DragEvent): void { + event.stopPropagation(); + event.preventDefault(); + } + + private _onDragEnter(event: DragEvent): void { + this._counter++; + if (!this.disabled && !this.formDisabled) { + this._setDragState(event.target as HTMLElement, true); + this._blockEvent(event); + } + } + + private _onDragLeave(event: DragEvent): void { + this._counter--; + if ( + !this.disabled && + !this.formDisabled && + event.target === this._dragTarget && + this._counter === 0 + ) { + this._setDragState(); + this._blockEvent(event); + } + } + + private _onFileDrop(event: DragEvent): void { + this._counter = 0; + if (!this.disabled && !this.formDisabled) { + this._setDragState(); + this._blockEvent(event); + this.createFileList(event.dataTransfer!.files); + } + } + + private _setDragState( + dragTarget: HTMLElement | undefined = undefined, + isDragEnter: boolean = false, + ): void { + this._dragTarget = dragTarget; + this.toggleAttribute('data-active', isDragEnter); + this.loadButton.toggleAttribute('data-active', isDragEnter); + } + + protected override renderTemplate(input: TemplateResult): TemplateResult { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-file-selector-dropzone': SbbFileSelectorDropzoneElement; + } +} diff --git a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.visual.spec.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.visual.spec.ts new file mode 100644 index 0000000000..a6b97b01a7 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.visual.spec.ts @@ -0,0 +1,91 @@ +import { html, nothing } from 'lit'; + +import { + describeEach, + describeViewports, + visualDiffDefault, + visualDiffFocus, + visualRegressionFixture, +} from '../../core/testing/private.js'; + +import '../../form-error.js'; +import './file-selector-dropzone.js'; +import type { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; + +describe(`sbb-file-selector-dropzone`, () => { + function addFilesToComponentInput(elem: SbbFileSelectorDropzoneElement): void { + const dataTransfer: DataTransfer = new DataTransfer(); + for (let i: number = 0; i < 5; i++) { + dataTransfer.items.add( + new File([`Hello world - ${i}`], `hello${i}.txt`, { + type: 'text/plain', + lastModified: new Date(i).getMilliseconds(), + }), + ); + } + const input: HTMLInputElement = elem.shadowRoot!.querySelector('input')!; + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change')); + } + + let root: HTMLElement; + + const states = { + state: [ + { disabled: false, error: false }, + { disabled: true, error: false }, + { disabled: false, error: true }, + ], + }; + + describeViewports({ viewports: ['small', 'medium'] }, () => { + describeEach(states, ({ state }) => { + beforeEach(async function () { + root = await visualRegressionFixture(html` + + ${state.error + ? html`There has been an error.` + : nothing} + `); + }); + + it( + visualDiffDefault.name, + visualDiffDefault.with((setup) => { + if (!state.disabled && state.error) { + addFilesToComponentInput(root.querySelector('#fs')!); + } + setup.withSnapshotElement(root); + }), + ); + }); + + describeEach({ size: ['s', 'm'] }, ({ size }) => { + beforeEach(async function () { + root = await visualRegressionFixture(html` + + `); + }); + + for (const visualDiffState of [visualDiffDefault, visualDiffFocus]) { + it( + visualDiffState.name, + visualDiffState.with((setup) => { + addFilesToComponentInput(root.querySelector('#fs')!); + setup.withSnapshotElement(root); + }), + ); + } + }); + }); +}); diff --git a/src/elements/file-selector/file-selector-dropzone/readme.md b/src/elements/file-selector/file-selector-dropzone/readme.md new file mode 100644 index 0000000000..845b83854d --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/readme.md @@ -0,0 +1,119 @@ +The `sbb-file-selector-dropzone` is a component which allows user to select one or more files from storage devices. +When files are selected, they appear as a list below the button/dropzone area. +For each file, the name and the size are displayed and an icon allows for deletion. +The component mimics the native `` with an additional "drag & drop" area: +it's possible to customize the area's title via the `titleContent` property. +For the basic variant, see [sbb-file-selector](/docs/elements-sbb-file-selector-sbb-file-selector--docs) + +```html + +``` + +## Slots + +The `error` named slot can be used to display an error message using the `sbb-form-error` component. + +```html + + An error occurred during file upload. + +``` + +## States + +User interaction can be disabled using the `disabled` property. + +```html + +``` + +### Multiple and multipleMode + +A single file can be selected by default; this can be changed setting the `multiple` property to `true`. + +```html + +``` + +The value of the `multipleMode` property determines whether added files should overwrite existing files (`default`) or be appended to them (`persistent`). + +```html + +``` + +### Accept + +The `accept` property can be used to force the user to select one or more specific file types; +in the next example, only images are allowed. + +```html + +``` + +## Style + +The component has also two different sizes, `m` (default) and `s`, which can be changed using the `size` property. + +```html + +``` + +### Events + +Whenever the selection changes, a `fileChanged` event is fired, whose `event.detail` property contains the list +of currently selected files. The list can also be retrieved using the public `files` getter. + +## Accessibility + +It's possible to improve the component accessibility using the `accessibilityLabel` property; this will be set +as `aria-label` of the inner native input and read together with the visible button text. +It's suggested to have a different value for each variant, e.g.: + +```html + + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | --------------------- | ------- | --------------------------- | ----------- | ----------------------------------------------------------------------------- | +| `accept` | `accept` | public | `string` | `''` | A comma-separated list of allowed unique file type specifiers. | +| `accessibilityLabel` | `accessibility-label` | public | `string` | `''` | This will be forwarded as aria-label to the native input element. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `files` | - | public | `Readonly[]` | `[]` | The list of selected files. | +| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. | +| `multiple` | `multiple` | public | `boolean` | `false` | Whether more than one file can be selected. | +| `multipleMode` | `multiple-mode` | public | `'default' \| 'persistent'` | `'default'` | Whether the newly added files should override the previously added ones. | +| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | +| `size` | `size` | public | `'s' \| 'm'` | `'m'` | Size variant, either s or m. | +| `titleContent` | `title-content` | public | `string` | `''` | The title displayed in `dropzone` variant. | +| `value` | `value` | public | `string \| null` | `null` | The path of the first selected file. Empty string ('') if no file is selected | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | + +## Events + +| Name | Type | Description | Inherited From | +| ------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `change` | `Event` | An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value | SbbFileSelectorCommonElementMixin | +| `fileChanged` | `CustomEvent[]>` | An event which is emitted each time the file list changes. | | +| `input` | `Event` | An event which is emitted each time the value changes as a direct result of a user action. | SbbFileSelectorCommonElementMixin | + +## Slots + +| Name | Description | +| ------- | ---------------------------------------------------------------- | +| `error` | Use this to provide a `sbb-form-error` to show an error message. | diff --git a/src/elements/file-selector/file-selector.spec.ts b/src/elements/file-selector/file-selector.spec.ts deleted file mode 100644 index 36de689f15..0000000000 --- a/src/elements/file-selector/file-selector.spec.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { assert, expect } from '@open-wc/testing'; -import { html } from 'lit/static-html.js'; - -import type { SbbSecondaryButtonElement } from '../button.js'; -import { fixture } from '../core/testing/private.js'; -import { EventSpy, waitForLitRender } from '../core/testing.js'; - -import { SbbFileSelectorElement } from './file-selector.js'; -import '../button/secondary-button.js'; - -function createDataTransfer( - numberOfFiles: number, - filesContent: string | string[] = 'Hello world', -): DataTransfer { - const dataTransfer: DataTransfer = new DataTransfer(); - for (let i: number = 0; i < numberOfFiles; i++) { - const content = filesContent instanceof Array ? filesContent[i] : filesContent; - dataTransfer.items.add( - new File([`${content} - ${i}`], `hello${i}.txt`, { - type: 'text/plain', - lastModified: new Date(i).getMilliseconds(), - }), - ); - } - return dataTransfer; -} - -function addFiles( - element: HTMLInputElement | SbbFileSelectorElement, - numberOfFiles: number, - filesContent: string | string[] = 'Hello world', -): void { - const dt = createDataTransfer(numberOfFiles, filesContent); - let nativeInput: HTMLInputElement; - - if (element instanceof HTMLInputElement) { - nativeInput = element; - element.files = dt.files; - } else { - nativeInput = element.shadowRoot!.querySelector('input')!; - nativeInput.files = dt.files; - } - - // Manually dispatch events to simulate a user interaction - nativeInput.dispatchEvent(new Event('input', { composed: true, bubbles: true })); - nativeInput.dispatchEvent(new Event('change')); -} - -describe(`sbb-file-selector`, () => { - let form: HTMLFormElement; - let element: SbbFileSelectorElement; - let input: HTMLInputElement; - let fieldSet: HTMLFieldSetElement; - let elemChangeEvent: EventSpy, - elemInputEvent: EventSpy, - nativeChangeEvent: EventSpy, - nativeInputEvent: EventSpy; - - beforeEach(async () => { - form = await fixture(html` -
-
- - -
-
- `); - element = form.querySelector('sbb-file-selector')!; - input = form.querySelector('input')!; - fieldSet = form.querySelector('fieldset')!; - - // event spies - elemChangeEvent = new EventSpy('change', element); - elemInputEvent = new EventSpy('input', element); - nativeChangeEvent = new EventSpy('change', input); - nativeInputEvent = new EventSpy('input', input); - - await waitForLitRender(form); - }); - - function compareToNativeInput(): void { - // Compare files - expect(element.files.length, 'files - length').to.be.equal(Array.from(input.files!).length); - element.files.forEach((e, i) => { - expect(e.name, `file - name - ${i}`).to.be.equal(Array.from(input.files!)[i].name); - expect(e.type, `file - type - ${i}`).to.be.equal(Array.from(input.files!)[i].type); - expect(e.size, `file - size - ${i}`).to.be.equal(Array.from(input.files!)[i].size); - expect(e.lastModified, `file - lastModified - ${i}`).to.be.equal( - Array.from(input.files!)[i].lastModified, - ); - }); - - // Compare formData - const formData = new FormData(form); - const fileSelectorFormData = formData.getAll('fs'); - const inputFormData = formData.getAll('native'); - - if (fileSelectorFormData.length === 0) { - /** - * Custom implementation - * If empty, the native input adds an 'empty' file to the FormData (we don't). - * So the equivalent of an empty 'sbb-file-selector' is a native with an empty file - */ - expect(inputFormData.length, 'formData - no file').to.be.equal(1); - expect((inputFormData[0] as File).size, 'formData - no file - size').to.be.equal(0); - expect((inputFormData[0] as File).name, 'formData - no file - name').to.be.equal(''); - } else { - expect(fileSelectorFormData.length, 'formData - files').to.be.equal(inputFormData.length); - fileSelectorFormData.forEach((e, i) => { - expect((e as File).name, `formData - file name - ${i}`).to.be.equal( - (inputFormData[i] as File).name, - ); - expect((e as File).type, `formData - file type - ${i}`).to.be.equal( - (inputFormData[i] as File).type, - ); - expect((e as File).size, `formData - file size - ${i}`).to.be.equal( - (inputFormData[i] as File).size, - ); - expect((e as File).lastModified, `formData - file lastModified - ${i}`).to.be.equal( - (inputFormData[i] as File).lastModified, - ); - }); - } - - expect(elemChangeEvent.count, 'change event').to.be.equal(nativeChangeEvent.count); - expect(elemInputEvent.count, 'input event').to.be.equal(nativeInputEvent.count); - } - - it('renders', () => { - assert.instanceOf(element, SbbFileSelectorElement); - compareToNativeInput(); - }); - - it('loads a file, then deletes it', async () => { - const fileChangedSpy = new EventSpy(SbbFileSelectorElement.events.fileChangedEvent); - addFiles(element, 1); - addFiles(input, 1); - await waitForLitRender(form); - - expect(fileChangedSpy.count).to.be.equal(1); - expect(element.files.length).to.be.equal(1); - compareToNativeInput(); - - const listItems = element.shadowRoot!.querySelector( - '.sbb-file-selector__file-list', - ); - expect(listItems).dom.to.be.equal(` -
- - - hello0.txt - 15 B - - - - -
- `); - - const button: SbbSecondaryButtonElement = - element.shadowRoot!.querySelector( - 'sbb-secondary-button[icon-name="trash-small"]', - )!; - expect(button).not.to.be.null; - button.click(); - addFiles(input, 0); - await waitForLitRender(form); - - const files = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file'); - expect(fileChangedSpy.count).to.be.equal(2); - expect(files.length).to.be.equal(0); - compareToNativeInput(); - }); - - it('loads a file, then reset the form', async () => { - const fileChangedSpy = new EventSpy(SbbFileSelectorElement.events.fileChangedEvent); - addFiles(element, 1); - addFiles(input, 1); - await waitForLitRender(form); - - expect(fileChangedSpy.count).to.be.equal(1); - expect(element.files.length).to.be.equal(1); - compareToNativeInput(); - - form.reset(); - await waitForLitRender(form); - compareToNativeInput(); - }); - - it('restore formState', async () => { - const dt = createDataTransfer(2); - const formRestoreState: [string, FormDataEntryValue][] = Array.from(dt.files).map((e) => [ - 'fs', - e, - ]); - element.formStateRestoreCallback(formRestoreState, 'restore'); - await waitForLitRender(form); - expect(element.files.length).to.be.equal(2); - }); - - it('loads more than one file in multiple mode', async () => { - const fileChangedSpy = new EventSpy(SbbFileSelectorElement.events.fileChangedEvent); - element.multiple = true; - input.multiple = true; - await waitForLitRender(form); - addFiles(element, 2); - addFiles(input, 2); - await waitForLitRender(form); - expect(fileChangedSpy.count).to.be.equal(1); - compareToNativeInput(); - - const listItems = element.shadowRoot!.querySelectorAll('li'); - const filesDetails = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-details'); - const filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); - const filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); - - expect(listItems.length).to.be.equal(2); - expect(filesDetails.length).to.be.equal(2); - expect(filesName[0]).dom.text('hello0.txt'); - expect(filesName[1]).dom.text('hello1.txt'); - expect(filesSize[0]).dom.text('15 B'); - expect(filesSize[1]).dom.text('15 B'); - }); - - it('loads files in multiple persistent mode', async () => { - const fileChangedSpy = new EventSpy(SbbFileSelectorElement.events.fileChangedEvent); - element.multiple = true; - element.multipleMode = 'persistent'; - await waitForLitRender(form); - addFiles(element, 1); - await waitForLitRender(form); - expect(fileChangedSpy.count).to.be.equal(1); - - const filesDetails = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-details'); - let filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); - let filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); - - expect(element.files).not.to.be.null; - expect(filesName.length).to.be.equal(1); - expect(filesDetails.length).to.be.equal(1); - expect(filesName[0]).dom.text('hello0.txt'); - expect(filesSize[0]).dom.text('15 B'); - - const longContent = 'Lorem ipsum dolor sit amet. '.repeat(100); - addFiles(element, 2, ['Hello world', longContent]); - - await waitForLitRender(form); - - const files = element.shadowRoot!.querySelectorAll('li'); - filesName = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-name'); - filesSize = element.shadowRoot!.querySelectorAll('.sbb-file-selector__file-size'); - - expect(fileChangedSpy.count).to.be.equal(2); - expect(files.length).to.be.equal(2); - expect(filesName[0]).dom.text('hello1.txt'); - expect(filesSize[0]).dom.text('3 kB'); - expect(filesName[1]).dom.text('hello0.txt'); - expect(filesSize[1]).dom.text('15 B'); - }); - - it('should update formValue on name change', async () => { - addFiles(element, 1); - await waitForLitRender(form); - - let formData = new FormData(form); - const fileSelectorFormData = formData.getAll('fs'); - expect(fileSelectorFormData.length).to.be.equal(1); - - element.name = 'new-fs'; - await waitForLitRender(form); - - formData = new FormData(form); - expect(formData.getAll('fs').length).to.be.equal(0); - expect(formData.getAll('new-fs').length).to.be.equal(1); - }); - - it('should result as :disabled', async () => { - element.disabled = true; - await waitForLitRender(form); - - expect(element).to.match(':disabled'); - - element.disabled = false; - await waitForLitRender(form); - - expect(element).not.to.match(':disabled'); - }); - - it('should result :disabled if a fieldSet is', async () => { - fieldSet.disabled = true; - - await waitForLitRender(form); - - expect(element).to.match(':disabled'); - - fieldSet.disabled = false; - await waitForLitRender(form); - - expect(element).not.to.match(':disabled'); - }); -}); diff --git a/src/elements/file-selector/file-selector.stories.ts b/src/elements/file-selector/file-selector.stories.ts deleted file mode 100644 index e85e9c3a5b..0000000000 --- a/src/elements/file-selector/file-selector.stories.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { withActions } from '@storybook/addon-actions/decorator'; -import type { InputType } from '@storybook/types'; -import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; -import type { TemplateResult } from 'lit'; -import { html } from 'lit'; - -import { sbbSpread } from '../../storybook/helpers/spread.js'; -import type { SbbFormErrorElement } from '../form-error.js'; - -import { SbbFileSelectorElement } from './file-selector.js'; -import readme from './readme.md?raw'; -import '../form-error.js'; - -const variant: InputType = { - control: { - type: 'inline-radio', - }, - options: ['default', 'dropzone'], -}; - -const size: InputType = { - control: { - type: 'inline-radio', - }, - options: ['m', 's'], -}; - -const disabled: InputType = { - control: { - type: 'boolean', - }, -}; - -const titleContent: InputType = { - control: { - type: 'text', - }, -}; - -const multiple: InputType = { - control: { - type: 'boolean', - }, -}; - -const multipleMode: InputType = { - control: { - type: 'inline-radio', - }, - options: ['default', 'persistent'], -}; - -const accept: InputType = { - control: { - type: 'text', - }, -}; - -const accessibilityLabel: InputType = { - control: { - type: 'text', - }, -}; - -const defaultArgTypes: ArgTypes = { - variant, - size, - disabled, - 'title-content': titleContent, - multiple, - 'multiple-mode': multipleMode, - accept, - 'accessibility-label': accessibilityLabel, -}; - -const defaultArgs: Args = { - variant: variant.options![0], - size: size.options![0], - disabled: false, - 'title-content': 'Title', - multiple: false, - 'multiple-mode': multipleMode.options![0], - accept: undefined, - 'accessibility-label': 'Select from hard disk', -}; - -const multipleDefaultArgs: Args = { - ...defaultArgs, - multiple: true, - 'accessibility-label': 'Select from hard disk - multiple files allowed', -}; - -const multipleDefaultArgsSizeS: Args = { - ...multipleDefaultArgs, - size: size.options![1], -}; - -const Template = (args: Args): TemplateResult => - html``; - -const TemplateWithError = (args: Args): TemplateResult => { - const sbbFormError: SbbFormErrorElement = document.createElement('sbb-form-error'); - sbbFormError.setAttribute('slot', 'error'); - sbbFormError.textContent = 'There has been an error.'; - - return html` - ) => { - if (event.detail && event.detail.length > 0) { - (event.target as SbbFileSelectorElement)!.append(sbbFormError); - } else { - sbbFormError.remove(); - } - }} - > - `; -}; - -export const Default: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const DefaultDisabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, disabled: true }, -}; - -export const DefaultMulti: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs }, -}; - -export const DefaultMultiPersistent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs, 'multiple-mode': multipleMode.options![1] }, -}; - -export const Dropzone: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options![1] }, -}; - -export const DropzoneDisabled: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options![1], disabled: true }, -}; - -export const DropzoneMulti: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgs, variant: variant.options![1] }, -}; - -export const DropzoneMultiPersistent: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { - ...multipleDefaultArgs, - variant: variant.options![1], - 'multiple-mode': multipleMode.options![1], - }, -}; - -export const DefaultWithError: StoryObj = { - render: TemplateWithError, - argTypes: defaultArgTypes, - args: { ...defaultArgs }, -}; - -export const DropzoneWithError: StoryObj = { - render: TemplateWithError, - argTypes: defaultArgTypes, - args: { ...defaultArgs, variant: variant.options![1] }, -}; - -export const DefaultOnlyPDF: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...defaultArgs, accept: '.pdf' }, -}; - -export const DefaultMultiSizeS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgsSizeS }, -}; - -export const DropzoneMultiSizeS: StoryObj = { - render: Template, - argTypes: defaultArgTypes, - args: { ...multipleDefaultArgsSizeS, variant: variant.options![1] }, -}; - -const meta: Meta = { - decorators: [withActions as Decorator], - parameters: { - actions: { - handles: [SbbFileSelectorElement.events.fileChangedEvent], - }, - docs: { - extractComponentDescription: () => readme, - }, - }, - title: 'elements/sbb-file-selector', -}; - -export default meta; diff --git a/src/elements/file-selector/file-selector.ts b/src/elements/file-selector/file-selector.ts index c6d5d6c30d..e7ed86ce1f 100644 --- a/src/elements/file-selector/file-selector.ts +++ b/src/elements/file-selector/file-selector.ts @@ -1,387 +1 @@ -import { type CSSResultGroup, LitElement, nothing, type TemplateResult } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; -import { html, unsafeStatic } from 'lit/static-html.js'; - -import type { SbbSecondaryButtonStaticElement } from '../button.js'; -import { sbbInputModalityDetector } from '../core/a11y.js'; -import { SbbLanguageController } from '../core/controllers.js'; -import { forceType, slotState } from '../core/decorators.js'; -import { EventEmitter, forwardEventToHost } from '../core/eventing.js'; -import { - i18nFileSelectorButtonLabel, - i18nFileSelectorCurrentlySelected, - i18nFileSelectorDeleteFile, - i18nFileSelectorSubtitleLabel, -} from '../core/i18n.js'; -import { - type FormRestoreReason, - type FormRestoreState, - SbbDisabledMixin, - SbbFormAssociatedMixin, -} from '../core/mixins.js'; - -import style from './file-selector.scss?lit&inline'; - -import '../button/secondary-button.js'; -import '../button/secondary-button-static.js'; -import '../icon.js'; - -export type DOMEvent = globalThis.Event; - -/** - * It allows to select one or more file from storage devices and display them. - * - * @slot error - Use this to provide a `sbb-form-error` to show an error message. - * @event {CustomEvent} fileChanged - An event which is emitted each time the file list changes. - * @event change - An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value - * @event input - An event which is emitted each time the value changes as a direct result of a user action. - */ -export -@customElement('sbb-file-selector') -@slotState() -class SbbFileSelectorElement extends SbbDisabledMixin(SbbFormAssociatedMixin(LitElement)) { - public static override styles: CSSResultGroup = style; - public static readonly events = { - fileChangedEvent: 'fileChanged', - } as const; - - /** Whether the component has a dropzone area or not. */ - @property() public accessor variant: 'default' | 'dropzone' = 'default'; - - /** Size variant, either s or m. */ - @property({ reflect: true }) public accessor size: 's' | 'm' = 'm'; - - /** Whether more than one file can be selected. */ - @forceType() - @property({ type: Boolean }) - public accessor multiple: boolean = false; - - /** Whether the newly added files should override the previously added ones. */ - @property({ attribute: 'multiple-mode' }) - public accessor multipleMode: 'default' | 'persistent' = 'default'; - - /** A comma-separated list of allowed unique file type specifiers. */ - @forceType() - @property() - public accessor accept: string = ''; - - /** The title displayed in `dropzone` variant. */ - @forceType() - @property({ attribute: 'title-content' }) - public accessor titleContent: string = ''; - - /** This will be forwarded as aria-label to the native input element. */ - @forceType() - @property({ attribute: 'accessibility-label' }) - public accessor accessibilityLabel: string = ''; - - /** The path of the first selected file. Empty string ('') if no file is selected */ - @property({ attribute: false }) - public override set value(value: string | null) { - this._hiddenInput.value = value ?? ''; - - if (!value) { - this.files = []; - } - } - public override get value(): string | null { - return this._hiddenInput?.value; - } - - /** - * The list of selected files. - */ - @property({ attribute: false }) - public set files(value: File[]) { - this._files = value ?? []; - - // update the inner input - const dt: DataTransfer = new DataTransfer(); - this.files.forEach((e: File) => dt.items.add(e)); - this._hiddenInput.files = dt.files; - - this.updateFormValue(); - } - public get files(): File[] { - return this._files; - } - private _files: File[] = []; - - /** An event which is emitted each time the file list changes. */ - private _fileChangedEvent: EventEmitter = new EventEmitter( - this, - SbbFileSelectorElement.events.fileChangedEvent, - ); - - // Safari has a peculiar behavior when dragging files on the inner button in 'dropzone' variant; - // this will require a counter to correctly handle the dragEnter/dragLeave. - private _counter: number = 0; - - private _loadButton!: SbbSecondaryButtonStaticElement; - private _dragTarget?: HTMLElement; - private _hiddenInput!: HTMLInputElement; - private _suffixes: string[] = ['B', 'kB', 'MB', 'GB', 'TB']; - private _liveRegion!: HTMLParagraphElement; - - private _language = new SbbLanguageController(this); - - /** - * @deprecated use the 'files' property instead - */ - public getFiles(): File[] { - return this.files; - } - - public override formResetCallback(): void { - this.files = []; - } - - public override formStateRestoreCallback( - state: FormRestoreState | null, - _reason?: FormRestoreReason, - ): void { - if (!state) { - return; - } - this.files = (state as [string, FormDataEntryValue][]).map(([_, value]) => value as File); - } - - protected override updateFormValue(): void { - const formValue = new FormData(); - this.files.forEach((file) => formValue.append(this.name, file)); - this.internals.setFormValue(formValue); - } - - private _blockEvent(event: DragEvent): void { - event.stopPropagation(); - event.preventDefault(); - } - - private _checkFileEquality(file1: File, file2: File): boolean { - return ( - file1.name === file2.name && - file1.size === file2.size && - file1.lastModified === file2.lastModified - ); - } - - private _onDragEnter(event: DragEvent): void { - this._counter++; - if (!this.disabled && !this.formDisabled) { - this._setDragState(event.target as HTMLElement, true); - this._blockEvent(event); - } - } - - private _onDragLeave(event: DragEvent): void { - this._counter--; - if ( - !this.disabled && - !this.formDisabled && - event.target === this._dragTarget && - this._counter === 0 - ) { - this._setDragState(); - this._blockEvent(event); - } - } - - private _onFileDrop(event: DragEvent): void { - this._counter = 0; - if (!this.disabled && !this.formDisabled) { - this._setDragState(); - this._blockEvent(event); - this._createFileList(event.dataTransfer!.files); - } - } - - private _onFocus(): void { - if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { - this._loadButton.toggleAttribute('data-focus-visible', true); - } - } - - private _onBlur(): void { - if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { - this._loadButton.removeAttribute('data-focus-visible'); - } - } - - private _setDragState( - dragTarget: HTMLElement | undefined = undefined, - isDragEnter: boolean = false, - ): void { - this._dragTarget = dragTarget; - this.toggleAttribute('data-active', isDragEnter); - this._loadButton.toggleAttribute('data-active', isDragEnter); - } - - private _readFiles(event: DOMEvent): void { - const fileInput = event.target as HTMLInputElement; - if (fileInput.files) { - this._createFileList(fileInput.files); - } - forwardEventToHost(event, this); - } - - private _createFileList(files: FileList): void { - if (!this.multiple || this.multipleMode !== 'persistent' || this.files.length === 0) { - this.files = Array.from(files); - } else { - this.files = Array.from(files) - .filter( - // Remove duplicates - (newFile: File): boolean => - this.files!.findIndex((oldFile: File) => this._checkFileEquality(newFile, oldFile)) === - -1, - ) - .concat(this.files); - } - this._updateA11yLiveRegion(); - this._fileChangedEvent.emit(this.files); - } - - private _removeFile(file: File): void { - this.files = this.files.filter((f: File) => !this._checkFileEquality(file, f)); - this._updateA11yLiveRegion(); - - // Dispatch native events as if the reset is done via the file selection window. - this.dispatchEvent(new Event('input', { composed: true, bubbles: true })); - this.dispatchEvent(new Event('change', { bubbles: true })); - this._fileChangedEvent.emit(this.files); - } - - /** Calculates the correct unit for the file's size. */ - private _formatFileSize(size: number): string { - const i: number = Math.floor(Math.log(size) / Math.log(1024)); - return `${(size / Math.pow(1024, i)).toFixed(0)} ${this._suffixes[i]}`; - } - - private _updateA11yLiveRegion(): void { - this._liveRegion.innerText = i18nFileSelectorCurrentlySelected(this.files.map((e) => e.name))[ - this._language.current - ]; - } - - private _renderDefaultMode(): TemplateResult { - return html` - { - this._loadButton = el as SbbSecondaryButtonStaticElement; - })} - > - ${i18nFileSelectorButtonLabel[this._language.current]} - - `; - } - - private _renderDropzoneArea(): TemplateResult { - return html` - - - - - ${this.titleContent} - - ${i18nFileSelectorSubtitleLabel[this._language.current]} - - - { - this._loadButton = el as SbbSecondaryButtonStaticElement; - })} - > - ${i18nFileSelectorButtonLabel[this._language.current]} - - - - `; - } - - private _renderFileList(): TemplateResult { - const TAG_NAME: Record = - this.files.length > 1 - ? { WRAPPER: 'ul', ELEMENT: 'li' } - : { WRAPPER: 'div', ELEMENT: 'span' }; - - /* eslint-disable lit/binding-positions */ - return html` - <${unsafeStatic(TAG_NAME.WRAPPER)} class="sbb-file-selector__file-list"> - ${this.files.map( - (file: File) => html` - <${unsafeStatic(TAG_NAME.ELEMENT)} class="sbb-file-selector__file"> - - ${file.name} - ${this._formatFileSize(file.size)} - - this._removeFile(file)} - aria-label=${`${i18nFileSelectorDeleteFile[this._language.current]} - ${file.name}`} - > - `, - )} - - `; - } - - protected override render(): TemplateResult { - const ariaLabel = this.accessibilityLabel - ? `${i18nFileSelectorButtonLabel[this._language.current]} - ${this.accessibilityLabel}` - : undefined; - return html` -
-
- -
-

(this._liveRegion = p as HTMLParagraphElement))} - >

- ${this.files.length > 0 ? this._renderFileList() : nothing} -
- -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'sbb-file-selector': SbbFileSelectorElement; - } -} +export * from './file-selector/file-selector.js'; diff --git a/src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js b/src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js new file mode 100644 index 0000000000..dc679c681f --- /dev/null +++ b/src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js @@ -0,0 +1,82 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-file-selector renders DOM"] = +` + +`; +/* end snapshot sbb-file-selector renders DOM */ + +snapshots["sbb-file-selector renders Shadow DOM"] = +`
+
+ +
+

+

+
+ + +
+
+`; +/* end snapshot sbb-file-selector renders Shadow DOM */ + +snapshots["sbb-file-selector renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Choose a file" + }, + { + "role": "button", + "name": "Choose a file", + "value": "No file chosen" + } + ] +} +

+`; +/* end snapshot sbb-file-selector renders A11y tree Chrome */ + +snapshots["sbb-file-selector renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Choose a file" + }, + { + "role": "button", + "name": "Choose a file Browse… …" + } + ] +} +

+`; +/* end snapshot sbb-file-selector renders A11y tree Firefox */ + diff --git a/src/elements/file-selector/file-selector.snapshot.spec.ts b/src/elements/file-selector/file-selector/file-selector.snapshot.spec.ts similarity index 58% rename from src/elements/file-selector/file-selector.snapshot.spec.ts rename to src/elements/file-selector/file-selector/file-selector.snapshot.spec.ts index 134001ab21..e525f25113 100644 --- a/src/elements/file-selector/file-selector.snapshot.spec.ts +++ b/src/elements/file-selector/file-selector/file-selector.snapshot.spec.ts @@ -1,7 +1,7 @@ import { expect } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; -import { fixture, testA11yTreeSnapshot } from '../core/testing/private.js'; +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; import type { SbbFileSelectorElement } from './file-selector.js'; import './file-selector.js'; @@ -21,24 +21,6 @@ describe(`sbb-file-selector`, () => { it('Shadow DOM', async () => { await expect(element).shadowDom.to.be.equalSnapshot(); }); - }); - - describe('renders with dropzone area and size s', () => { - let element: SbbFileSelectorElement; - - beforeEach(async () => { - element = await fixture( - html``, - ); - }); - - it('DOM', async () => { - await expect(element).dom.to.be.equalSnapshot(); - }); - - it('Shadow DOM', async () => { - await expect(element).shadowDom.to.be.equalSnapshot(); - }); // We skip safari because it has an inconsistent behavior on ci environment testA11yTreeSnapshot(undefined, undefined, { safari: true }); diff --git a/src/elements/file-selector/file-selector/file-selector.spec.ts b/src/elements/file-selector/file-selector/file-selector.spec.ts new file mode 100644 index 0000000000..ccead652c8 --- /dev/null +++ b/src/elements/file-selector/file-selector/file-selector.spec.ts @@ -0,0 +1,32 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { waitForLitRender } from '../../core/testing.js'; + +import { SbbFileSelectorElement } from './file-selector.js'; + +describe(`sbb-file-selector-dropzone`, () => { + let form: HTMLFormElement; + let element: SbbFileSelectorElement; + + beforeEach(async () => { + form = await fixture(html` +
+
+ + +
+
+ `); + element = form.querySelector('sbb-file-selector')!; + + await waitForLitRender(form); + }); + + it('renders', () => { + assert.instanceOf(element, SbbFileSelectorElement); + }); + + // All the functionalities of sbb-file-selector are tested in file-selector-common.spec.ts file +}); diff --git a/src/elements/file-selector/file-selector.ssr.spec.ts b/src/elements/file-selector/file-selector/file-selector.ssr.spec.ts similarity index 87% rename from src/elements/file-selector/file-selector.ssr.spec.ts rename to src/elements/file-selector/file-selector/file-selector.ssr.spec.ts index 8cf3a4f093..304ebc6973 100644 --- a/src/elements/file-selector/file-selector.ssr.spec.ts +++ b/src/elements/file-selector/file-selector/file-selector.ssr.spec.ts @@ -1,7 +1,7 @@ import { assert } from '@open-wc/testing'; import { html } from 'lit'; -import { ssrHydratedFixture } from '../core/testing/private.js'; +import { ssrHydratedFixture } from '../../core/testing/private.js'; import { SbbFileSelectorElement } from './file-selector.js'; diff --git a/src/elements/file-selector/file-selector/file-selector.stories.ts b/src/elements/file-selector/file-selector/file-selector.stories.ts new file mode 100644 index 0000000000..97c0979c5c --- /dev/null +++ b/src/elements/file-selector/file-selector/file-selector.stories.ts @@ -0,0 +1,45 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; + +import { + defaultDisabled, + defaultFileSelector, + defaultMulti, + defaultMultiPersistent, + defaultMultiSizeS, + defaultOnlyPDF, + defaultWithError, + fileSelectorDefaultArgs, + fileSelectorDefaultArgTypes, +} from '../common/file-selector-common-stories.js'; + +import { SbbFileSelectorElement } from './file-selector.js'; +import readme from './readme.md?raw'; + +const defaultArgTypes: ArgTypes = { ...fileSelectorDefaultArgTypes }; +const defaultArgs: Args = { ...fileSelectorDefaultArgs, tag: 'sbb-file-selector' }; + +export const FileSelector: StoryObj = defaultFileSelector; +export const Disabled: StoryObj = defaultDisabled; +export const Multi: StoryObj = defaultMulti; +export const MultiPersistent: StoryObj = defaultMultiPersistent; +export const WithError: StoryObj = defaultWithError; +export const OnlyPDF: StoryObj = defaultOnlyPDF; +export const MultiSizeS: StoryObj = defaultMultiSizeS; + +const meta: Meta = { + args: defaultArgs, + argTypes: defaultArgTypes, + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: [SbbFileSelectorElement.events.fileChangedEvent], + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-file-selector/sbb-file-selector', +}; + +export default meta; diff --git a/src/elements/file-selector/file-selector/file-selector.ts b/src/elements/file-selector/file-selector/file-selector.ts new file mode 100644 index 0000000000..a80ef08cfb --- /dev/null +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -0,0 +1,58 @@ +import { type CSSResultGroup, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { SbbSecondaryButtonStaticElement } from '../../button.js'; +import { slotState } from '../../core/decorators.js'; +import { i18nFileSelectorButtonLabel } from '../../core/i18n.js'; +import { fileSelectorCommonStyle, SbbFileSelectorCommonElementMixin } from '../common.js'; + +import '../../button/secondary-button.js'; +import '../../button/secondary-button-static.js'; +import '../../icon.js'; + +/** + * It allows to select one or more file from storage devices and display them. + * + * @slot error - Use this to provide a `sbb-form-error` to show an error message. + * @event {CustomEvent[]>} fileChanged - An event which is emitted each time the file list changes. + * @event change - An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value + * @event input - An event which is emitted each time the value changes as a direct result of a user action. + */ +export +@customElement('sbb-file-selector') +@slotState() +class SbbFileSelectorElement extends SbbFileSelectorCommonElementMixin(LitElement) { + public static override styles: CSSResultGroup = fileSelectorCommonStyle; + public static readonly events = { + fileChangedEvent: 'fileChanged', + } as const; + + protected override renderTemplate(input: TemplateResult): TemplateResult { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-file-selector': SbbFileSelectorElement; + } +} diff --git a/src/elements/file-selector/file-selector.visual.spec.ts b/src/elements/file-selector/file-selector/file-selector.visual.spec.ts similarity index 75% rename from src/elements/file-selector/file-selector.visual.spec.ts rename to src/elements/file-selector/file-selector/file-selector.visual.spec.ts index edc790eaeb..3d03ee29e4 100644 --- a/src/elements/file-selector/file-selector.visual.spec.ts +++ b/src/elements/file-selector/file-selector/file-selector.visual.spec.ts @@ -6,9 +6,9 @@ import { visualDiffDefault, visualDiffFocus, visualRegressionFixture, -} from '../core/testing/private.js'; +} from '../../core/testing/private.js'; -import '../form-error.js'; +import '../../form-error.js'; import './file-selector.js'; import type { SbbFileSelectorElement } from './file-selector.js'; @@ -31,7 +31,6 @@ describe(`sbb-file-selector`, () => { let root: HTMLElement; const states = { - variant: ['default', 'dropzone'], state: [ { disabled: false, error: false }, { disabled: true, error: false }, @@ -39,22 +38,11 @@ describe(`sbb-file-selector`, () => { ], }; - const sizes = { - variant: ['default', 'dropzone'], - size: ['s', 'm'], - }; - describeViewports({ viewports: ['small', 'medium'] }, () => { - describeEach(states, ({ variant, state }) => { + describeEach(states, ({ state }) => { beforeEach(async function () { root = await visualRegressionFixture(html` - + ${state.error ? html`There has been an error.` : nothing} @@ -72,16 +60,10 @@ describe(`sbb-file-selector`, () => { ); }); - describeEach(sizes, ({ variant, size }) => { + describeEach({ size: ['s', 'm'] }, ({ size }) => { beforeEach(async function () { root = await visualRegressionFixture(html` - + `); }); diff --git a/src/elements/file-selector/readme.md b/src/elements/file-selector/file-selector/readme.md similarity index 65% rename from src/elements/file-selector/readme.md rename to src/elements/file-selector/file-selector/readme.md index c6842e925d..e6f3765eee 100644 --- a/src/elements/file-selector/readme.md +++ b/src/elements/file-selector/file-selector/readme.md @@ -1,32 +1,34 @@ The `sbb-file-selector` is a component which allows user to select one or more files from storage devices. -When files are selected, they appear as a list below the button/dropzone area. +When files are selected, they appear as a list below the button. For each file, the name and the size are displayed and an icon allows for deletion. - -### Variants - -It has two different display options based on the value of the `variant` property: -by default, a `sbb-button` is displayed, which mimics the native ``. +The component mimics the native ``; for the drag-and-drop variant, see +[sbb-file-selector-dropzone](/docs/elements-sbb-file-selector-sbb-file-selector-dropzone--docs) ```html ``` -Instead, if the `variant` property is set to `dropzone`, the `sbb-button` is shown within a "drag & drop" area. -In this case, it's possible to customize the area's title via the `titleContent` property. +## Slots + +The `error` named slot can be used to display an error message using the `sbb-form-error` component. ```html - + + An error occurred during file upload. + ``` -The component has also two different sizes, `m` (default) and `s`, which can be changed using the `size` property. +## States + +User interaction can be disabled using the `disabled` property. ```html - + ``` ### Multiple and multipleMode -In both variants, a single file can be selected by default; this can be changed setting the `multiple` property to `true`. +A single file can be selected by default; this can be changed setting the `multiple` property to `true`. ```html @@ -47,28 +49,18 @@ in the next example, only images are allowed. ``` -### Disabled +## Style -User interaction can be disabled using the `disabled` property. - -```html - -``` - -### Error slot - -The `error` named slot can be used to display an error message using the `sbb-form-error` component. +The component has also two different sizes, `m` (default) and `s`, which can be changed using the `size` property. ```html - - An error occurred during file upload. - + ``` ### Events Whenever the selection changes, a `fileChanged` event is fired, whose `event.detail` property contains the list -of currently selected files. The list can also be retrieved using the `getFiles()` method. +of currently selected files. The list can also be retrieved using the public `files` getter. ## Accessibility @@ -93,31 +85,28 @@ It's suggested to have a different value for each variant, e.g.: | `accept` | `accept` | public | `string` | `''` | A comma-separated list of allowed unique file type specifiers. | | `accessibilityLabel` | `accessibility-label` | public | `string` | `''` | This will be forwarded as aria-label to the native input element. | | `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `files` | - | public | `File[]` | `[]` | The list of selected files. | +| `files` | - | public | `Readonly[]` | `[]` | The list of selected files. | | `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. | | `multiple` | `multiple` | public | `boolean` | `false` | Whether more than one file can be selected. | | `multipleMode` | `multiple-mode` | public | `'default' \| 'persistent'` | `'default'` | Whether the newly added files should override the previously added ones. | | `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | | `size` | `size` | public | `'s' \| 'm'` | `'m'` | Size variant, either s or m. | -| `titleContent` | `title-content` | public | `string` | `''` | The title displayed in `dropzone` variant. | | `value` | `value` | public | `string \| null` | `null` | The path of the first selected file. Empty string ('') if no file is selected | -| `variant` | `variant` | public | `'default' \| 'dropzone'` | `'default'` | Whether the component has a dropzone area or not. | ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | -------------- | -| `formResetCallback` | public | | | `void` | | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | | -| `getFiles` | public | | | `File[]` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | ## Events -| Name | Type | Description | Inherited From | -| ------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `change` | `Event` | An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value | | -| `fileChanged` | `CustomEvent` | An event which is emitted each time the file list changes. | | -| `input` | `Event` | An event which is emitted each time the value changes as a direct result of a user action. | | +| Name | Type | Description | Inherited From | +| ------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `change` | `Event` | An event which is emitted each time the user modifies the value. Unlike the input event, the change event is not necessarily fired for each alteration to an element's value | SbbFileSelectorCommonElementMixin | +| `fileChanged` | `CustomEvent[]>` | An event which is emitted each time the file list changes. | | +| `input` | `Event` | An event which is emitted each time the value changes as a direct result of a user action. | SbbFileSelectorCommonElementMixin | ## Slots diff --git a/src/elements/link/common/link-common-stories.ts b/src/elements/link/common/link-common-stories.ts index 3b446f472b..4040bce8e1 100644 --- a/src/elements/link/common/link-common-stories.ts +++ b/src/elements/link/common/link-common-stories.ts @@ -87,6 +87,11 @@ export const linkCommonDefaultArgTypes: ArgTypes = { tag, }; +/** + * NOTE + * The tag is the tagName of the component to display in stories, + * so it must be overridden before use. + */ export const linkCommonDefaultArgs: Args = { text: 'Travelcards & tickets', negative: false,