From cef97746707e6023b2f45ed08c6b080d21fcf9b8 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Wed, 6 Nov 2024 14:18:19 +0100 Subject: [PATCH 1/8] feat: split file-selector variants in separate components --- src/elements/file-selector.ts | 2 + .../common/file-selector-common-stories.ts | 98 +++++ .../file-selector-common.scss} | 36 +- .../common/file-selector-common.spec.ts | 323 +++++++++++++++ .../common/file-selector-common.ts | 268 ++++++++++++ .../file-selector/file-selector-common.ts | 3 + .../file-selector/file-selector-dropzone.ts | 1 + ...le-selector-dropzone.snapshot.spec.snap.js | 107 +++++ .../file-selector-dropzone.scss | 38 ++ .../file-selector-dropzone.snapshot.spec.ts | 28 ++ .../file-selector-dropzone.spec.ts | 32 ++ .../file-selector-dropzone.ssr.spec.ts | 23 ++ .../file-selector-dropzone.stories.ts | 99 +++++ .../file-selector-dropzone.ts | 124 ++++++ .../file-selector-dropzone.visual.spec.ts | 91 ++++ .../file-selector-dropzone/readme.md | 120 ++++++ .../file-selector/file-selector.spec.ts | 307 -------------- .../file-selector/file-selector.stories.ts | 216 ---------- src/elements/file-selector/file-selector.ts | 388 +----------------- .../file-selector.snapshot.spec.snap.js | 21 + .../file-selector.snapshot.spec.ts | 20 +- .../file-selector/file-selector.spec.ts | 32 ++ .../file-selector.ssr.spec.ts | 2 +- .../file-selector/file-selector.stories.ts | 72 ++++ .../file-selector/file-selector.ts | 53 +++ .../file-selector.visual.spec.ts | 16 +- .../{ => file-selector}/readme.md | 87 ++-- 27 files changed, 1582 insertions(+), 1025 deletions(-) create mode 100644 src/elements/file-selector/common/file-selector-common-stories.ts rename src/elements/file-selector/{file-selector.scss => common/file-selector-common.scss} (66%) create mode 100644 src/elements/file-selector/common/file-selector-common.spec.ts create mode 100644 src/elements/file-selector/common/file-selector-common.ts create mode 100644 src/elements/file-selector/file-selector-common.ts create mode 100644 src/elements/file-selector/file-selector-dropzone.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.scss create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.snapshot.spec.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.spec.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ssr.spec.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.visual.spec.ts create mode 100644 src/elements/file-selector/file-selector-dropzone/readme.md delete mode 100644 src/elements/file-selector/file-selector.spec.ts delete mode 100644 src/elements/file-selector/file-selector.stories.ts rename src/elements/file-selector/{ => file-selector}/__snapshots__/file-selector.snapshot.spec.snap.js (90%) rename src/elements/file-selector/{ => file-selector}/file-selector.snapshot.spec.ts (58%) create mode 100644 src/elements/file-selector/file-selector/file-selector.spec.ts rename src/elements/file-selector/{ => file-selector}/file-selector.ssr.spec.ts (87%) create mode 100644 src/elements/file-selector/file-selector/file-selector.stories.ts create mode 100644 src/elements/file-selector/file-selector/file-selector.ts rename src/elements/file-selector/{ => file-selector}/file-selector.visual.spec.ts (87%) rename src/elements/file-selector/{ => file-selector}/readme.md (74%) diff --git a/src/elements/file-selector.ts b/src/elements/file-selector.ts index e7ed86ce1f..06f3465c58 100644 --- a/src/elements/file-selector.ts +++ b/src/elements/file-selector.ts @@ -1 +1,3 @@ +export * from './file-selector/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/file-selector-common-stories.ts b/src/elements/file-selector/common/file-selector-common-stories.ts new file mode 100644 index 0000000000..51453e825c --- /dev/null +++ b/src/elements/file-selector/common/file-selector-common-stories.ts @@ -0,0 +1,98 @@ +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes } 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', + }, +}; + +export const fileSelectorDefaultArgTypes: ArgTypes = { + size, + disabled, + multiple, + 'multiple-mode': multipleMode, + accept, + 'accessibility-label': accessibilityLabel, +}; + +export const fileSelectorDefaultArgs: Args = { + 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', +}; 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..e2e0bfb9dc --- /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.only('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..f3e75c9f81 --- /dev/null +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -0,0 +1,268 @@ +import { type CSSResultGroup, LitElement, nothing, type TemplateResult } 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, +} from '../../core/mixins.js'; + +import style from './file-selector-common.scss?lit&inline'; + +import '../../button/secondary-button.js'; +import '../../button/secondary-button-static.js'; +import '../../icon.js'; + +export type DOMEvent = globalThis.Event; + +export abstract class SbbFileSelectorBaseElement extends SbbDisabledMixin( + SbbFormAssociatedMixin(LitElement), +) { + public static override styles: CSSResultGroup = style; + 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 = ''; + + /** 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, + SbbFileSelectorBaseElement.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; + + /** @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 _checkFileEquality(file1: File, file2: File): 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: DOMEvent): 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: 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 _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)} + + + `, + )} + + `; + /* 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} +
+ +
+
+ `; + } +} diff --git a/src/elements/file-selector/file-selector-common.ts b/src/elements/file-selector/file-selector-common.ts new file mode 100644 index 0000000000..b350d9f229 --- /dev/null +++ b/src/elements/file-selector/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/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/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js b/src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js new file mode 100644 index 0000000000..d8844c8b57 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/__snapshots__/file-selector-dropzone.snapshot.spec.snap.js @@ -0,0 +1,107 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-file-selector-dropzone renders DOM"] = +` + +`; +/* end snapshot sbb-file-selector-dropzone renders DOM */ + +snapshots["sbb-file-selector-dropzone renders Shadow DOM"] = +`
+
+ +
+

+

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

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Drag & Drop your files here" + }, + { + "role": "text", + "name": "Choose a file" + }, + { + "role": "button", + "name": "Drag & Drop your files here Choose a file", + "value": "No file chosen" + } + ] +} +

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

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Drag & Drop your files here" + }, + { + "role": "text leaf", + "name": "Choose a file" + }, + { + "role": "button", + "name": "Drag & Drop your files here Choose a file Browse… …" + } + ] +} +

+`; +/* 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..0e31e7899f --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts @@ -0,0 +1,99 @@ +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 { + fileSelectorDefaultArgs, + fileSelectorDefaultArgTypes, + FileSelectorTemplate, + FileSelectorTemplateWithError, +} from '../common/file-selector-common-stories.js'; + +import { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; +import readme from './readme.md?raw'; + +const applyComponentTag = (args: Args): Args => ({ ...args, tag: 'sbb-file-selector-dropzone' }); + +const titleContent: InputType = { + control: { + type: 'text', + }, +}; + +const fileSelectorDropzoneArgTypes: ArgTypes = { + ...fileSelectorDefaultArgTypes, + 'title-content': titleContent, +}; + +const fileSelectorDropzoneArgs: Args = { + fileSelectorDefaultArgs, + 'title-content': 'Title', +}; + +const fileSelectorMultipleDropzoneArgs: Args = { + ...fileSelectorDropzoneArgs, + multiple: true, + 'accessibility-label': 'Select from hard disk - multiple files allowed', +}; + +const fileSelectorMultipleDropzoneArgsSizeS: Args = { + ...fileSelectorMultipleDropzoneArgs, + size: 's', +}; + +export const Default: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag(fileSelectorDropzoneArgs), +}; + +export const DefaultDisabled: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag({ ...fileSelectorDropzoneArgs, disabled: true }), +}; + +export const DefaultMulti: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag(fileSelectorMultipleDropzoneArgs), +}; + +export const DefaultMultiPersistent: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag({ ...fileSelectorMultipleDropzoneArgs, 'multiple-mode': 'persistent' }), +}; + +export const DefaultWithError: StoryObj = { + render: FileSelectorTemplateWithError, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag(fileSelectorDropzoneArgs), +}; + +export const DefaultOnlyPDF: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag({ ...fileSelectorDropzoneArgs, accept: '.pdf' }), +}; + +export const DefaultMultiSizeS: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDropzoneArgTypes, + args: applyComponentTag(fileSelectorMultipleDropzoneArgsSizeS), +}; + +const meta: Meta = { + 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..b41fbf35b1 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts @@ -0,0 +1,124 @@ +import { type CSSResultGroup, 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, i18nFileSelectorSubtitleLabel } from '../../core/i18n.js'; +import { fileSelectorCommonStyle, SbbFileSelectorBaseElement } from '../file-selector-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 SbbFileSelectorBaseElement { + public static override styles: CSSResultGroup = [fileSelectorCommonStyle, style]; + + // 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..78ac5299d0 --- /dev/null +++ b/src/elements/file-selector/file-selector-dropzone/readme.md @@ -0,0 +1,120 @@ +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 `getFiles()` method (NOTE: deprecated method). + +## 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 | `File[]` | `[]` | 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` | Value of the form element. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | -------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorBaseElement | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorBaseElement | +| `getFiles` | public | | | `File[]` | SbbFileSelectorBaseElement | + +## 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 | SbbFileSelectorBaseElement | +| `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. | SbbFileSelectorBaseElement | + +## 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/__snapshots__/file-selector.snapshot.spec.snap.js b/src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js similarity index 90% rename from src/elements/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js rename to src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js index 6f68f55637..be16b1ef14 100644 --- a/src/elements/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js +++ b/src/elements/file-selector/file-selector/__snapshots__/file-selector.snapshot.spec.snap.js @@ -146,3 +146,24 @@ 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 */ +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 */ + 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..ccb7e1a5b4 --- /dev/null +++ b/src/elements/file-selector/file-selector/file-selector.stories.ts @@ -0,0 +1,72 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; + +import { + fileSelectorDefaultArgs, + fileSelectorDefaultArgTypes, + fileSelectorMultipleDefaultArgs, + FileSelectorTemplate, + FileSelectorTemplateWithError, +} from '../common/file-selector-common-stories.js'; + +import { SbbFileSelectorElement } from './file-selector.js'; +import readme from './readme.md?raw'; + +const applyComponentTag = (args: Args): Args => ({ ...args, tag: 'sbb-file-selector' }); + +export const Default: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag(fileSelectorDefaultArgs), +}; + +export const DefaultDisabled: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag({ ...fileSelectorDefaultArgs, disabled: true }), +}; + +export const DefaultMulti: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag(fileSelectorMultipleDefaultArgs), +}; + +export const DefaultMultiPersistent: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag({ ...fileSelectorMultipleDefaultArgs, 'multiple-mode': 'persistent' }), +}; + +export const DefaultWithError: StoryObj = { + render: FileSelectorTemplateWithError, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag(fileSelectorDefaultArgs), +}; + +export const DefaultOnlyPDF: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag({ ...fileSelectorDefaultArgs, accept: '.pdf' }), +}; + +export const DefaultMultiSizeS: StoryObj = { + render: FileSelectorTemplate, + argTypes: fileSelectorDefaultArgTypes, + args: applyComponentTag({ ...fileSelectorMultipleDefaultArgs, size: 's' }), +}; + +const meta: Meta = { + 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..88b2a1aace --- /dev/null +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -0,0 +1,53 @@ +import 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 { SbbFileSelectorBaseElement } from '../common/file-selector-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 SbbFileSelectorBaseElement { + 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 87% 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..4f1fd558fb 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,20 +38,14 @@ 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 @@ -72,14 +65,13 @@ 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 74% rename from src/elements/file-selector/readme.md rename to src/elements/file-selector/file-selector/readme.md index c6842e925d..4420f873ea 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. 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,22 +49,12 @@ 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 @@ -88,36 +80,35 @@ It's suggested to have a different value for each variant, e.g.: ## 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 | `File[]` | `[]` | 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. | +| 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 | `File[]` | `[]` | 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` | Value of the form element. | ## 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` | SbbFileSelectorBaseElement | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorBaseElement | +| `getFiles` | public | | | `File[]` | SbbFileSelectorBaseElement | ## 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 | SbbFileSelectorBaseElement | +| `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. | SbbFileSelectorBaseElement | ## Slots From 33e0760cf1caa1c0240b712cbc39cfdf2834da3d Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 7 Nov 2024 14:40:48 +0100 Subject: [PATCH 2/8] fix: change class to mixin due to custom-elements-manifest bug --- .../common/file-selector-common.ts | 406 ++++++++++-------- .../file-selector-dropzone.ts | 12 +- .../file-selector-dropzone/readme.md | 46 +- .../file-selector/file-selector.ts | 10 +- .../file-selector/file-selector/readme.md | 46 +- 5 files changed, 281 insertions(+), 239 deletions(-) diff --git a/src/elements/file-selector/common/file-selector-common.ts b/src/elements/file-selector/common/file-selector-common.ts index f3e75c9f81..2227109ee1 100644 --- a/src/elements/file-selector/common/file-selector-common.ts +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -1,4 +1,5 @@ -import { type CSSResultGroup, LitElement, nothing, type TemplateResult } from 'lit'; +import type { CSSResultGroup, 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'; @@ -18,6 +19,8 @@ import { type FormRestoreState, SbbDisabledMixin, SbbFormAssociatedMixin, + type SbbFormAssociatedMixinType, + type Constructor, } from '../../core/mixins.js'; import style from './file-selector-common.scss?lit&inline'; @@ -28,188 +31,214 @@ import '../../icon.js'; export type DOMEvent = globalThis.Event; -export abstract class SbbFileSelectorBaseElement extends SbbDisabledMixin( - SbbFormAssociatedMixin(LitElement), -) { - public static override styles: CSSResultGroup = style; - 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 = ''; - - /** 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 = []; +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 titleContent: string; + public accessor accessibilityLabel: string; + public accessor disabled: boolean; + public accessor files: File[]; + 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; + public getFiles(): File[]; +} + +// 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 styles: CSSResultGroup = style; + 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 = ''; + + /** 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; - } + 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 ?? []; + /** 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; + // 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(); - } + this.updateFormValue(); + } - public get files(): File[] { - return this._files; - } + public get files(): File[] { + return this._files; + } - private _files: File[] = []; + private _files: File[] = []; - /** An event which is emitted each time the file list changes. */ - private _fileChangedEvent: EventEmitter = new EventEmitter( - this, - SbbFileSelectorBaseElement.events.fileChangedEvent, - ); + /** 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); + 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; + protected abstract renderTemplate(input: TemplateResult): TemplateResult; - /** @deprecated use the 'files' property instead */ - public getFiles(): File[] { - return this.files; - } + /** @deprecated use the 'files' property instead */ + public getFiles(): File[] { + return this.files; + } - public override formResetCallback(): void { - this.files = []; - } + public override formResetCallback(): void { + this.files = []; + } - public override formStateRestoreCallback( - state: FormRestoreState | null, - _reason?: FormRestoreReason, - ): void { - if (!state) { - return; + public override formStateRestoreCallback( + state: FormRestoreState | null, + _reason?: FormRestoreReason, + ): void { + if (!state) { + return; + } + this.files = (state as [string, FormDataEntryValue][]).map(([_, value]) => value as File); } - 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); - } + protected override updateFormValue(): void { + const formValue = new FormData(); + this.files.forEach((file) => formValue.append(this.name, file)); + this.internals.setFormValue(formValue); + } - private _checkFileEquality(file1: File, file2: File): boolean { - return ( - file1.name === file2.name && - file1.size === file2.size && - file1.lastModified === file2.lastModified - ); - } + private _checkFileEquality(file1: File, file2: File): 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 _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 _onBlur(): void { + if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { + this.loadButton.removeAttribute('data-focus-visible'); + } } - } - private _readFiles(event: DOMEvent): void { - const fileInput = event.target as HTMLInputElement; - if (fileInput.files) { - this.createFileList(fileInput.files); + private _readFiles(event: DOMEvent): void { + const fileInput = event.target as HTMLInputElement; + if (fileInput.files) { + this.createFileList(fileInput.files); + } + forwardEventToHost(event, this); } - 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: File): boolean => - this.files!.findIndex((oldFile: File) => this._checkFileEquality(newFile, oldFile)) === - -1, - ) - .concat(this.files); + 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: File): boolean => + this.files!.findIndex((oldFile: File) => + this._checkFileEquality(newFile, oldFile), + ) === -1, + ) + .concat(this.files); + } + this._updateA11yLiveRegion(); + this._fileChangedEvent.emit(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(); + 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); - } + // 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]}`; - } + /** 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 _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' }; + 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` + /* eslint-disable lit/binding-positions */ + return html` <${unsafeStatic(TAG_NAME.WRAPPER)} class='sbb-file-selector__file-list'> ${this.files.map( (file: File) => html` @@ -228,41 +257,44 @@ export abstract class SbbFileSelectorBaseElement extends SbbDisabledMixin( )} `; - /* eslint-enable lit/binding-positions */ - } + /* 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.renderTemplate( + html` { + this._hiddenInput = el as HTMLInputElement; + })} + />`, + )} +

{ - this._hiddenInput = el as HTMLInputElement; - })} - />`, - )} -

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

- ${this.files.length > 0 ? this._renderFileList() : nothing} -
- + ${ref((p?: Element) => (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/file-selector-dropzone.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts index b41fbf35b1..6019ea0c61 100644 --- a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts @@ -1,4 +1,4 @@ -import { type CSSResultGroup, type TemplateResult } from 'lit'; +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'; @@ -6,7 +6,10 @@ import { html } from 'lit/static-html.js'; import type { SbbSecondaryButtonStaticElement } from '../../button.js'; import { slotState } from '../../core/decorators.js'; import { i18nFileSelectorButtonLabel, i18nFileSelectorSubtitleLabel } from '../../core/i18n.js'; -import { fileSelectorCommonStyle, SbbFileSelectorBaseElement } from '../file-selector-common.js'; +import { + fileSelectorCommonStyle, + SbbFileSelectorCommonElementMixin, +} from '../file-selector-common.js'; import '../../button/secondary-button.js'; import '../../button/secondary-button-static.js'; @@ -25,8 +28,11 @@ import style from './file-selector-dropzone.scss?lit&inline'; export @customElement('sbb-file-selector-dropzone') @slotState() -class SbbFileSelectorDropzoneElement extends SbbFileSelectorBaseElement { +class SbbFileSelectorDropzoneElement extends SbbFileSelectorCommonElementMixin(LitElement) { public static override styles: CSSResultGroup = [fileSelectorCommonStyle, style]; + public static readonly events = { + fileChangedEvent: 'fileChanged', + } as const; // 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. diff --git a/src/elements/file-selector/file-selector-dropzone/readme.md b/src/elements/file-selector/file-selector-dropzone/readme.md index 78ac5299d0..8d9d27edd3 100644 --- a/src/elements/file-selector/file-selector-dropzone/readme.md +++ b/src/elements/file-selector/file-selector-dropzone/readme.md @@ -83,35 +83,35 @@ It's suggested to have a different value for each variant, e.g.: ## 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 | `File[]` | `[]` | 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` | Value of the form element. | +| 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 | `File[]` | `[]` | 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` | SbbFileSelectorBaseElement | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorBaseElement | -| `getFiles` | public | | | `File[]` | SbbFileSelectorBaseElement | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | +| `getFiles` | public | | | `File[]` | 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 | SbbFileSelectorBaseElement | -| `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. | SbbFileSelectorBaseElement | +| 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/file-selector/file-selector/file-selector.ts b/src/elements/file-selector/file-selector/file-selector.ts index 88b2a1aace..5251e54471 100644 --- a/src/elements/file-selector/file-selector/file-selector.ts +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -1,4 +1,4 @@ -import type { TemplateResult } from 'lit'; +import { 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'; @@ -6,7 +6,7 @@ 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 { SbbFileSelectorBaseElement } from '../common/file-selector-common.js'; +import { SbbFileSelectorCommonElementMixin } from '../common/file-selector-common.js'; import '../../button/secondary-button.js'; import '../../button/secondary-button-static.js'; @@ -23,7 +23,11 @@ import '../../icon.js'; export @customElement('sbb-file-selector') @slotState() -class SbbFileSelectorElement extends SbbFileSelectorBaseElement { +class SbbFileSelectorElement extends SbbFileSelectorCommonElementMixin(LitElement) { + public static readonly events = { + fileChangedEvent: 'fileChanged', + } as const; + protected override renderTemplate(input: TemplateResult): TemplateResult { return html`
diff --git a/src/elements/file-selector/file-selector/readme.md b/src/elements/file-selector/file-selector/readme.md index 4420f873ea..a09ade6297 100644 --- a/src/elements/file-selector/file-selector/readme.md +++ b/src/elements/file-selector/file-selector/readme.md @@ -80,35 +80,35 @@ It's suggested to have a different value for each variant, e.g.: ## 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 | `File[]` | `[]` | 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` | Value of the form element. | +| 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 | `File[]` | `[]` | 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` | SbbFileSelectorBaseElement | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorBaseElement | -| `getFiles` | public | | | `File[]` | SbbFileSelectorBaseElement | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | +| `getFiles` | public | | | `File[]` | 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 | SbbFileSelectorBaseElement | -| `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. | SbbFileSelectorBaseElement | +| 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 From 87e888fa9e6721cd06a8fdd6299de36264e756b4 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 7 Nov 2024 15:26:03 +0100 Subject: [PATCH 3/8] fix: build tests --- src/elements/file-selector.ts | 2 +- .../{file-selector-common.ts => common.ts} | 0 .../file-selector-dropzone.ts | 5 +- .../file-selector.snapshot.spec.snap.js | 99 ++----------------- .../file-selector/file-selector.ts | 2 +- 5 files changed, 9 insertions(+), 99 deletions(-) rename src/elements/file-selector/{file-selector-common.ts => common.ts} (100%) diff --git a/src/elements/file-selector.ts b/src/elements/file-selector.ts index 06f3465c58..15f6e8c5ee 100644 --- a/src/elements/file-selector.ts +++ b/src/elements/file-selector.ts @@ -1,3 +1,3 @@ -export * from './file-selector/file-selector-common.js'; +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/file-selector-common.ts b/src/elements/file-selector/common.ts similarity index 100% rename from src/elements/file-selector/file-selector-common.ts rename to src/elements/file-selector/common.ts 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 index 6019ea0c61..483f1f13d8 100644 --- a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts @@ -6,10 +6,7 @@ import { html } from 'lit/static-html.js'; import type { SbbSecondaryButtonStaticElement } from '../../button.js'; import { slotState } from '../../core/decorators.js'; import { i18nFileSelectorButtonLabel, i18nFileSelectorSubtitleLabel } from '../../core/i18n.js'; -import { - fileSelectorCommonStyle, - SbbFileSelectorCommonElementMixin, -} from '../file-selector-common.js'; +import { fileSelectorCommonStyle, SbbFileSelectorCommonElementMixin } from '../common.js'; import '../../button/secondary-button.js'; import '../../button/secondary-button-static.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 index be16b1ef14..dc679c681f 100644 --- 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 @@ -39,131 +39,44 @@ 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"] = -`
-
- -
-

-

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

{ "role": "WebArea", "name": "", "children": [ - { - "role": "text", - "name": "Drag & Drop your files here" - }, { "role": "text", "name": "Choose a file" }, { "role": "button", - "name": "Drag & Drop your files here Choose a file", + "name": "Choose a file", "value": "No file chosen" } ] }

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

{ "role": "document", "name": "", "children": [ - { - "role": "text leaf", - "name": "Drag & Drop your files here" - }, { "role": "text leaf", "name": "Choose a file" }, { "role": "button", - "name": "Drag & Drop your files here Choose a file Browse… …" + "name": "Choose a file Browse… …" } ] }

`; -/* end snapshot sbb-file-selector renders with dropzone area and size s A11y tree Firefox */ - -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 */ +/* end snapshot sbb-file-selector renders A11y tree Firefox */ diff --git a/src/elements/file-selector/file-selector/file-selector.ts b/src/elements/file-selector/file-selector/file-selector.ts index 5251e54471..7cfefc164c 100644 --- a/src/elements/file-selector/file-selector/file-selector.ts +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -6,7 +6,7 @@ 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 { SbbFileSelectorCommonElementMixin } from '../common/file-selector-common.js'; +import { SbbFileSelectorCommonElementMixin } from '../common.js'; import '../../button/secondary-button.js'; import '../../button/secondary-button-static.js'; From 451bfa0ef5c31fe48a5085ed49fb5e2d3d9f0f80 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 7 Nov 2024 16:52:12 +0100 Subject: [PATCH 4/8] fix: move titleContent to dropzone variant --- .../file-selector/common/file-selector-common.ts | 6 ------ .../file-selector-dropzone.ts | 9 +++++++-- .../file-selector/file-selector.visual.spec.ts | 14 ++------------ src/elements/file-selector/file-selector/readme.md | 1 - 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/elements/file-selector/common/file-selector-common.ts b/src/elements/file-selector/common/file-selector-common.ts index 2227109ee1..a4e9cd424b 100644 --- a/src/elements/file-selector/common/file-selector-common.ts +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -36,7 +36,6 @@ export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbF public accessor multiple: boolean; public accessor multipleMode: 'default' | 'persistent'; public accessor accept: string; - public accessor titleContent: string; public accessor accessibilityLabel: string; public accessor disabled: boolean; public accessor files: File[]; @@ -81,11 +80,6 @@ export const SbbFileSelectorCommonElementMixin = { describeEach(states, ({ state }) => { beforeEach(async function () { root = await visualRegressionFixture(html` - + ${state.error ? html`There has been an error.` : nothing} @@ -68,12 +63,7 @@ describe(`sbb-file-selector`, () => { describeEach({ size: ['s', 'm'] }, ({ size }) => { beforeEach(async function () { root = await visualRegressionFixture(html` - + `); }); diff --git a/src/elements/file-selector/file-selector/readme.md b/src/elements/file-selector/file-selector/readme.md index a09ade6297..1411da6591 100644 --- a/src/elements/file-selector/file-selector/readme.md +++ b/src/elements/file-selector/file-selector/readme.md @@ -91,7 +91,6 @@ It's suggested to have a different value for each variant, e.g.: | `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 From 414e3f2bac6c7d5ed3f23a4491bd989f116b1743 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Wed, 13 Nov 2024 10:49:31 +0100 Subject: [PATCH 5/8] feat: 'files' prop is now readonly (#3152) --- .../common/file-selector-common.spec.ts | 2 +- .../common/file-selector-common.ts | 34 +++++++++---------- .../file-selector-dropzone.ts | 2 +- .../file-selector-dropzone/readme.md | 22 ++++++------ .../file-selector/file-selector.ts | 2 +- .../file-selector/file-selector/readme.md | 22 ++++++------ 6 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/elements/file-selector/common/file-selector-common.spec.ts b/src/elements/file-selector/common/file-selector-common.spec.ts index e2e0bfb9dc..e97df52c5d 100644 --- a/src/elements/file-selector/common/file-selector-common.spec.ts +++ b/src/elements/file-selector/common/file-selector-common.spec.ts @@ -137,7 +137,7 @@ describe('sbb-file-selector common', () => { expect(elemInputEvent.count, 'input event').to.be.equal(nativeInputEvent.count); } - it.only('renders', () => { + it('renders', () => { compareToNativeInput(); }); diff --git a/src/elements/file-selector/common/file-selector-common.ts b/src/elements/file-selector/common/file-selector-common.ts index a4e9cd424b..835c6d573a 100644 --- a/src/elements/file-selector/common/file-selector-common.ts +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -38,7 +38,7 @@ export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbF public accessor accept: string; public accessor accessibilityLabel: string; public accessor disabled: boolean; - public accessor files: File[]; + public accessor files: Readonly[]; protected formDisabled: boolean; protected loadButton: SbbSecondaryButtonStaticElement; protected language: SbbLanguageController; @@ -47,7 +47,7 @@ export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbF protected updateFormValue(): void; public formResetCallback(): void; public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void; - public getFiles(): File[]; + public getFiles(): Readonly[]; } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -101,25 +101,23 @@ export const SbbFileSelectorCommonElementMixin = []) { this._files = value ?? []; // update the inner input const dt: DataTransfer = new DataTransfer(); - this.files.forEach((e: File) => dt.items.add(e)); + this.files.forEach((e: Readonly) => dt.items.add(e)); this._hiddenInput.files = dt.files; this.updateFormValue(); } - - public get files(): File[] { + public get files(): Readonly[] { return this._files; } - - private _files: File[] = []; + private _files: Readonly[] = []; /** An event which is emitted each time the file list changes. */ - private _fileChangedEvent: EventEmitter = new EventEmitter( + private _fileChangedEvent: EventEmitter[]> = new EventEmitter( this, SbbFileSelectorCommonElement.events.fileChangedEvent, ); @@ -133,7 +131,7 @@ export const SbbFileSelectorCommonElementMixin = [] { return this.files; } @@ -148,7 +146,9 @@ export const SbbFileSelectorCommonElementMixin = value as File); + this.files = (state as [string, FormDataEntryValue][]).map( + ([_, value]) => value as Readonly, + ); } protected override updateFormValue(): void { @@ -157,7 +157,7 @@ export const SbbFileSelectorCommonElementMixin = , file2: Readonly): boolean { return ( file1.name === file2.name && file1.size === file2.size && @@ -192,8 +192,8 @@ export const SbbFileSelectorCommonElementMixin = - this.files!.findIndex((oldFile: File) => + (newFile: Readonly): boolean => + this.files!.findIndex((oldFile: Readonly) => this._checkFileEquality(newFile, oldFile), ) === -1, ) @@ -203,8 +203,8 @@ export const SbbFileSelectorCommonElementMixin = !this._checkFileEquality(file, f)); + 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. @@ -235,7 +235,7 @@ export const SbbFileSelectorCommonElementMixin = ${this.files.map( - (file: File) => html` + (file: Readonly) => html` <${unsafeStatic(TAG_NAME.ELEMENT)} class='sbb-file-selector__file'> ${file.name} 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 index c689ee38f4..5e699b3e61 100644 --- a/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts +++ b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.ts @@ -18,7 +18,7 @@ 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 {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. */ diff --git a/src/elements/file-selector/file-selector-dropzone/readme.md b/src/elements/file-selector/file-selector-dropzone/readme.md index 8d9d27edd3..6421eec0a3 100644 --- a/src/elements/file-selector/file-selector-dropzone/readme.md +++ b/src/elements/file-selector/file-selector-dropzone/readme.md @@ -88,7 +88,7 @@ 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. | @@ -99,19 +99,19 @@ It's suggested to have a different value for each variant, e.g.: ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | --------------------------------- | -| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | -| `getFiles` | public | | | `File[]` | SbbFileSelectorCommonElementMixin | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------------------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | +| `getFiles` | public | | | `Readonly[]` | 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 | +| 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/file-selector/file-selector/file-selector.ts b/src/elements/file-selector/file-selector/file-selector.ts index 7cfefc164c..50ae0ca4f5 100644 --- a/src/elements/file-selector/file-selector/file-selector.ts +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -16,7 +16,7 @@ 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 {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. */ diff --git a/src/elements/file-selector/file-selector/readme.md b/src/elements/file-selector/file-selector/readme.md index 1411da6591..2ac768f549 100644 --- a/src/elements/file-selector/file-selector/readme.md +++ b/src/elements/file-selector/file-selector/readme.md @@ -85,7 +85,7 @@ 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. | @@ -95,19 +95,19 @@ It's suggested to have a different value for each variant, e.g.: ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | -------- | --------------------------------- | -| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | -| `getFiles` | public | | | `File[]` | SbbFileSelectorCommonElementMixin | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------------------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | +| `getFiles` | public | | | `Readonly[]` | 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 | +| 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 From 3c28c155302df71896d4b18b9f29d381d0f51fae Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Tue, 19 Nov 2024 10:20:56 +0100 Subject: [PATCH 6/8] fix: review Tommaso --- .../common/file-selector-common-stories.ts | 46 +++++++++++- .../file-selector-dropzone.stories.ts | 75 +++++-------------- .../file-selector/file-selector.stories.ts | 65 +++++----------- 3 files changed, 82 insertions(+), 104 deletions(-) diff --git a/src/elements/file-selector/common/file-selector-common-stories.ts b/src/elements/file-selector/common/file-selector-common-stories.ts index 51453e825c..bd6b4a4edc 100644 --- a/src/elements/file-selector/common/file-selector-common-stories.ts +++ b/src/elements/file-selector/common/file-selector-common-stories.ts @@ -1,5 +1,5 @@ import type { InputType } from '@storybook/types'; -import type { Args, ArgTypes } from '@storybook/web-components'; +import type { Args, ArgTypes, StoryObj } from '@storybook/web-components'; import { type TemplateResult } from 'lit'; import { html, unsafeStatic } from 'lit/static-html.js'; @@ -73,7 +73,17 @@ const accessibilityLabel: InputType = { }, }; +const tag: InputType = { + control: { + type: 'text', + }, + table: { + disable: true, + }, +}; + export const fileSelectorDefaultArgTypes: ArgTypes = { + tag, size, disabled, multiple, @@ -83,6 +93,7 @@ export const fileSelectorDefaultArgTypes: ArgTypes = { }; export const fileSelectorDefaultArgs: Args = { + tag: 'TBD', size: size.options![0], disabled: false, multiple: false, @@ -96,3 +107,36 @@ export const fileSelectorMultipleDefaultArgs: Args = { 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-dropzone/file-selector-dropzone.stories.ts b/src/elements/file-selector/file-selector-dropzone/file-selector-dropzone.stories.ts index 0e31e7899f..dcc37cb5d1 100644 --- 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 @@ -3,17 +3,20 @@ 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, - FileSelectorTemplate, - FileSelectorTemplateWithError, } from '../common/file-selector-common-stories.js'; import { SbbFileSelectorDropzoneElement } from './file-selector-dropzone.js'; import readme from './readme.md?raw'; -const applyComponentTag = (args: Args): Args => ({ ...args, tag: 'sbb-file-selector-dropzone' }); - const titleContent: InputType = { control: { type: 'text', @@ -26,64 +29,22 @@ const fileSelectorDropzoneArgTypes: ArgTypes = { }; const fileSelectorDropzoneArgs: Args = { - fileSelectorDefaultArgs, + ...fileSelectorDefaultArgs, 'title-content': 'Title', + tag: 'sbb-file-selector-dropzone', }; -const fileSelectorMultipleDropzoneArgs: Args = { - ...fileSelectorDropzoneArgs, - multiple: true, - 'accessibility-label': 'Select from hard disk - multiple files allowed', -}; - -const fileSelectorMultipleDropzoneArgsSizeS: Args = { - ...fileSelectorMultipleDropzoneArgs, - size: 's', -}; - -export const Default: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag(fileSelectorDropzoneArgs), -}; - -export const DefaultDisabled: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag({ ...fileSelectorDropzoneArgs, disabled: true }), -}; - -export const DefaultMulti: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag(fileSelectorMultipleDropzoneArgs), -}; - -export const DefaultMultiPersistent: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag({ ...fileSelectorMultipleDropzoneArgs, 'multiple-mode': 'persistent' }), -}; - -export const DefaultWithError: StoryObj = { - render: FileSelectorTemplateWithError, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag(fileSelectorDropzoneArgs), -}; - -export const DefaultOnlyPDF: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag({ ...fileSelectorDropzoneArgs, accept: '.pdf' }), -}; - -export const DefaultMultiSizeS: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDropzoneArgTypes, - args: applyComponentTag(fileSelectorMultipleDropzoneArgsSizeS), -}; +export const DefaultFileSelectorDropzone: StoryObj = defaultFileSelector; +export const DefaultDisabled: StoryObj = defaultDisabled; +export const DefaultMulti: StoryObj = defaultMulti; +export const DefaultMultiPersistent: StoryObj = defaultMultiPersistent; +export const DefaultWithError: StoryObj = defaultWithError; +export const DefaultOnlyPDF: StoryObj = defaultOnlyPDF; +export const DefaultMultiSizeS: StoryObj = defaultMultiSizeS; const meta: Meta = { + args: fileSelectorDropzoneArgs, + argTypes: fileSelectorDropzoneArgTypes, decorators: [withActions as Decorator], parameters: { actions: { diff --git a/src/elements/file-selector/file-selector/file-selector.stories.ts b/src/elements/file-selector/file-selector/file-selector.stories.ts index ccb7e1a5b4..379dfe3d5c 100644 --- a/src/elements/file-selector/file-selector/file-selector.stories.ts +++ b/src/elements/file-selector/file-selector/file-selector.stories.ts @@ -1,62 +1,35 @@ import { withActions } from '@storybook/addon-actions/decorator'; -import type { Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; import { + defaultDisabled, + defaultFileSelector, + defaultMulti, + defaultMultiPersistent, + defaultMultiSizeS, + defaultOnlyPDF, + defaultWithError, fileSelectorDefaultArgs, fileSelectorDefaultArgTypes, - fileSelectorMultipleDefaultArgs, - FileSelectorTemplate, - FileSelectorTemplateWithError, } from '../common/file-selector-common-stories.js'; import { SbbFileSelectorElement } from './file-selector.js'; import readme from './readme.md?raw'; -const applyComponentTag = (args: Args): Args => ({ ...args, tag: 'sbb-file-selector' }); +const defaultArgTypes: ArgTypes = { ...fileSelectorDefaultArgTypes }; +const defaultArgs: Args = { ...fileSelectorDefaultArgs, tag: 'sbb-file-selector' }; -export const Default: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag(fileSelectorDefaultArgs), -}; - -export const DefaultDisabled: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag({ ...fileSelectorDefaultArgs, disabled: true }), -}; - -export const DefaultMulti: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag(fileSelectorMultipleDefaultArgs), -}; - -export const DefaultMultiPersistent: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag({ ...fileSelectorMultipleDefaultArgs, 'multiple-mode': 'persistent' }), -}; - -export const DefaultWithError: StoryObj = { - render: FileSelectorTemplateWithError, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag(fileSelectorDefaultArgs), -}; - -export const DefaultOnlyPDF: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag({ ...fileSelectorDefaultArgs, accept: '.pdf' }), -}; - -export const DefaultMultiSizeS: StoryObj = { - render: FileSelectorTemplate, - argTypes: fileSelectorDefaultArgTypes, - args: applyComponentTag({ ...fileSelectorMultipleDefaultArgs, size: 's' }), -}; +export const DefaultFileSelector: StoryObj = defaultFileSelector; +export const DefaultDisabled: StoryObj = defaultDisabled; +export const DefaultMulti: StoryObj = defaultMulti; +export const DefaultMultiPersistent: StoryObj = defaultMultiPersistent; +export const DefaultWithError: StoryObj = defaultWithError; +export const DefaultOnlyPDF: StoryObj = defaultOnlyPDF; +export const DefaultMultiSizeS: StoryObj = defaultMultiSizeS; const meta: Meta = { + args: defaultArgs, + argTypes: defaultArgTypes, decorators: [withActions as Decorator], parameters: { actions: { From fcc995f8c1fa757c49baba7ec75e8ece118cfa76 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Tue, 19 Nov 2024 10:56:40 +0100 Subject: [PATCH 7/8] fix: review Jeri (pt.1) --- .../file-selector/common/file-selector-common.ts | 15 ++------------- .../file-selector-dropzone/readme.md | 11 +++++------ .../file-selector/file-selector/file-selector.ts | 5 +++-- .../file-selector/file-selector/readme.md | 15 +++++++-------- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/elements/file-selector/common/file-selector-common.ts b/src/elements/file-selector/common/file-selector-common.ts index 835c6d573a..96951e24c9 100644 --- a/src/elements/file-selector/common/file-selector-common.ts +++ b/src/elements/file-selector/common/file-selector-common.ts @@ -1,4 +1,4 @@ -import type { CSSResultGroup, LitElement, TemplateResult } from 'lit'; +import type { LitElement, TemplateResult } from 'lit'; import { nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; @@ -23,14 +23,10 @@ import { type Constructor, } from '../../core/mixins.js'; -import style from './file-selector-common.scss?lit&inline'; - import '../../button/secondary-button.js'; import '../../button/secondary-button-static.js'; import '../../icon.js'; -export type DOMEvent = globalThis.Event; - export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbFormAssociatedMixinType { public accessor size: 's' | 'm'; public accessor multiple: boolean; @@ -47,7 +43,6 @@ export declare abstract class SbbFileSelectorCommonElementMixinType extends SbbF protected updateFormValue(): void; public formResetCallback(): void; public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void; - public getFiles(): Readonly[]; } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -58,7 +53,6 @@ export const SbbFileSelectorCommonElementMixin = { - public static styles: CSSResultGroup = style; public static readonly events = { fileChangedEvent: 'fileChanged', } as const; @@ -130,11 +124,6 @@ export const SbbFileSelectorCommonElementMixin = [] { - return this.files; - } - public override formResetCallback(): void { this.files = []; } @@ -177,7 +166,7 @@ export const SbbFileSelectorCommonElementMixin = []` | SbbFileSelectorCommonElementMixin | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | ## Events diff --git a/src/elements/file-selector/file-selector/file-selector.ts b/src/elements/file-selector/file-selector/file-selector.ts index 50ae0ca4f5..a80ef08cfb 100644 --- a/src/elements/file-selector/file-selector/file-selector.ts +++ b/src/elements/file-selector/file-selector/file-selector.ts @@ -1,4 +1,4 @@ -import { LitElement, type TemplateResult } from 'lit'; +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'; @@ -6,7 +6,7 @@ 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 { SbbFileSelectorCommonElementMixin } from '../common.js'; +import { fileSelectorCommonStyle, SbbFileSelectorCommonElementMixin } from '../common.js'; import '../../button/secondary-button.js'; import '../../button/secondary-button-static.js'; @@ -24,6 +24,7 @@ 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; diff --git a/src/elements/file-selector/file-selector/readme.md b/src/elements/file-selector/file-selector/readme.md index 2ac768f549..e6f3765eee 100644 --- a/src/elements/file-selector/file-selector/readme.md +++ b/src/elements/file-selector/file-selector/readme.md @@ -1,5 +1,5 @@ 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. 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) @@ -13,7 +13,7 @@ The component mimics the native ``; for the drag-and-drop va 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. ``` @@ -60,7 +60,7 @@ The component has also two different sizes, `m` (default) and `s`, which can be ### 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 @@ -95,11 +95,10 @@ It's suggested to have a different value for each variant, e.g.: ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------------------ | --------------------------------- | -| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | -| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | -| `getFiles` | public | | | `Readonly[]` | SbbFileSelectorCommonElementMixin | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------------------------- | ------- | ----------- | ------------------------------------------------------------- | ------ | --------------------------------- | +| `formResetCallback` | public | | | `void` | SbbFileSelectorCommonElementMixin | +| `formStateRestoreCallback` | public | | `state: FormRestoreState \| null, _reason: FormRestoreReason` | `void` | SbbFileSelectorCommonElementMixin | ## Events From a7166f796eebe8bb45a04655b1ebf21e2a0c88d6 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 21 Nov 2024 12:21:52 +0100 Subject: [PATCH 8/8] fix: reviews --- src/elements/button/common/common-stories.ts | 5 +++++ .../common/file-selector-common-stories.ts | 5 +++++ .../file-selector-dropzone.stories.ts | 14 +++++++------- .../file-selector/file-selector.stories.ts | 14 +++++++------- src/elements/link/common/link-common-stories.ts | 5 +++++ 5 files changed, 29 insertions(+), 14 deletions(-) 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/common/file-selector-common-stories.ts b/src/elements/file-selector/common/file-selector-common-stories.ts index bd6b4a4edc..706cd906f4 100644 --- a/src/elements/file-selector/common/file-selector-common-stories.ts +++ b/src/elements/file-selector/common/file-selector-common-stories.ts @@ -92,6 +92,11 @@ export const fileSelectorDefaultArgTypes: ArgTypes = { '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], 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 index dcc37cb5d1..3934a33673 100644 --- 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 @@ -34,13 +34,13 @@ const fileSelectorDropzoneArgs: Args = { tag: 'sbb-file-selector-dropzone', }; -export const DefaultFileSelectorDropzone: StoryObj = defaultFileSelector; -export const DefaultDisabled: StoryObj = defaultDisabled; -export const DefaultMulti: StoryObj = defaultMulti; -export const DefaultMultiPersistent: StoryObj = defaultMultiPersistent; -export const DefaultWithError: StoryObj = defaultWithError; -export const DefaultOnlyPDF: StoryObj = defaultOnlyPDF; -export const DefaultMultiSizeS: StoryObj = defaultMultiSizeS; +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, diff --git a/src/elements/file-selector/file-selector/file-selector.stories.ts b/src/elements/file-selector/file-selector/file-selector.stories.ts index 379dfe3d5c..97c0979c5c 100644 --- a/src/elements/file-selector/file-selector/file-selector.stories.ts +++ b/src/elements/file-selector/file-selector/file-selector.stories.ts @@ -19,13 +19,13 @@ import readme from './readme.md?raw'; const defaultArgTypes: ArgTypes = { ...fileSelectorDefaultArgTypes }; const defaultArgs: Args = { ...fileSelectorDefaultArgs, tag: 'sbb-file-selector' }; -export const DefaultFileSelector: StoryObj = defaultFileSelector; -export const DefaultDisabled: StoryObj = defaultDisabled; -export const DefaultMulti: StoryObj = defaultMulti; -export const DefaultMultiPersistent: StoryObj = defaultMultiPersistent; -export const DefaultWithError: StoryObj = defaultWithError; -export const DefaultOnlyPDF: StoryObj = defaultOnlyPDF; -export const DefaultMultiSizeS: StoryObj = defaultMultiSizeS; +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, 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,