diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6b205af..82b604979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] +### Added +- New File Input Component(`igc-file-input`) + ## [5.3.0] - 2025-03-13 ### Added - Tile manager component [#1402](https://github.com/IgniteUI/igniteui-webcomponents/pull/1402) diff --git a/package-lock.json b/package-lock.json index 21837a93b..c26bf7055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "globby": "^14.1.0", "husky": "^9.1.7", "ig-typedoc-theme": "^6.0.0", - "igniteui-theming": "^16.1.0", + "igniteui-theming": "^17.1.0", "keep-a-changelog": "^2.6.2", "lint-staged": "^15.5.0", "lit-analyzer": "^2.0.3", @@ -7479,9 +7479,9 @@ } }, "node_modules/igniteui-theming": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-16.1.0.tgz", - "integrity": "sha512-TfEuswXYScOpHv4/OR08+7M8gEWlaX0f4BpJGTUCkG9EiqTQ8QgGIuOgdPtO54foNNfiCpv5HigpwVC1FEnT2Q==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-17.1.0.tgz", + "integrity": "sha512-oHqtA8NvV/HxverYhb78ZgN5+PXZ9ZY8NVKYxsm5+R46Ze25BsK/1RbU0/bauV1cxjzoHvruSr8lBa6njEbIdQ==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 9f925fd0e..bf30546e4 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "globby": "^14.1.0", "husky": "^9.1.7", "ig-typedoc-theme": "^6.0.0", - "igniteui-theming": "^16.1.0", + "igniteui-theming": "^17.1.0", "keep-a-changelog": "^2.6.2", "lint-staged": "^15.5.0", "lit-analyzer": "^2.0.3", diff --git a/src/components/common/mixins/forms/form-value.ts b/src/components/common/mixins/forms/form-value.ts index 52dba163d..eb48b795e 100644 --- a/src/components/common/mixins/forms/form-value.ts +++ b/src/components/common/mixins/forms/form-value.ts @@ -56,6 +56,28 @@ export const defaultDateTimeTransformers: Partial< setFormValue: getDateFormValue, }; +export const defaultFileListTransformer: Partial< + FormValueTransformers +> = { + setValue: (value) => value || null, + getValue: (value) => value, + setDefaultValue: (value) => value || null, + getDefaultValue: (value) => value, + setFormValue: (files: FileList | null, host: IgcFormControl) => { + if (!host.name || !files) { + return null; + } + + const data = new FormData(); + + for (const file of Array.from(files)) { + data.append(host.name, file); + } + + return data; + }, +}; + /* blazorSuppress */ export class FormValue { private static readonly setFormValueKey = '_setFormValue' as const; diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index e185bc0b7..573358553 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -357,6 +357,37 @@ export function simulateWheel(node: Element, options?: WheelEventInit) { ); } +/** + * Simulates file upload for an input of type file. + * + * @param element - The custom element containing the file input + * @param files - Array of File objects to upload + * @param shadowRoot - Whether to look for the input in shadow DOM (default: true) + * @returns Promise that resolves when element updates + */ +export async function simulateFileUpload( + element: HTMLElement, + files: File[], + shadowRoot = true +): Promise { + const input = shadowRoot + ? (element.shadowRoot!.querySelector( + 'input[type="file"]' + ) as HTMLInputElement) + : (element.querySelector('input[type="file"]') as HTMLInputElement); + + if (!input) { + throw new Error('File input not found'); + } + + const dataTransfer = new DataTransfer(); + files.forEach((file) => dataTransfer.items.add(file)); + + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + await elementUpdated(element); +} + export function simulateDoubleClick(node: Element) { node.dispatchEvent( new PointerEvent('dblclick', { bubbles: true, composed: true }) diff --git a/src/components/file-input/file-input.spec.ts b/src/components/file-input/file-input.spec.ts new file mode 100644 index 000000000..a64e9d671 --- /dev/null +++ b/src/components/file-input/file-input.spec.ts @@ -0,0 +1,295 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import type { TemplateResult } from 'lit'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { + createFormAssociatedTestBed, + simulateFileUpload, +} from '../common/utils.spec.js'; +import IgcFileInputComponent from './file-input.js'; + +describe('File Input component', () => { + const files = [ + new File(['test content'], 'test.txt', { type: 'text/plain' }), + new File(['image data'], 'image.png', { type: 'image/png' }), + ]; + + before(() => { + defineComponents(IgcFileInputComponent); + }); + + let element: IgcFileInputComponent; + let input: HTMLInputElement; + + async function createFixture(template: TemplateResult) { + element = await fixture(template); + input = element.renderRoot.querySelector('input')!; + } + + describe('Properties', () => { + it('sets the multiple property when input is of type file', async () => { + await createFixture(html``); + + expect(element.multiple).to.equal(true); + expect(input.multiple).to.equal(true); + + element.multiple = false; + await elementUpdated(element); + + expect(element.multiple).to.equal(false); + expect(input.multiple).to.equal(false); + }); + + it('sets the accept property when input is of type file', async () => { + await createFixture( + html`` + ); + + expect(element.accept).to.equal('image/*'); + expect(input.accept).to.equal('image/*'); + + element.accept = ''; + await elementUpdated(element); + + expect(element.accept).to.be.empty; + expect(input.accept).to.be.empty; + }); + + it('returns the uploaded files when input is of type file', async () => { + await createFixture(html``); + await simulateFileUpload(element, files); + + expect(element.files).to.exist; + expect(element.files!.length).to.equal(2); + expect(element.files![0].name).to.equal('test.txt'); + expect(element.files![1].name).to.equal('image.png'); + }); + + it('should show placeholder text when no file is selected', async () => { + await createFixture(html``); + + expect( + element + .shadowRoot!.querySelector('[part="file-names"]')! + .textContent!.trim() + ).to.equal('No file chosen'); + + element.placeholder = 'Select a document'; + await elementUpdated(element); + + expect( + element + .shadowRoot!.querySelector('[part="file-names"]')! + .textContent!.trim() + ).to.equal('Select a document'); + + await simulateFileUpload(element, [files[0]]); + + expect( + element + .shadowRoot!.querySelector('[part="file-names"]')! + .textContent!.trim() + ).to.equal('test.txt'); + }); + + it('resets the file selection when empty string is passed for value', async () => { + const file = files[0]; + await createFixture(html``); + await simulateFileUpload(element, [file]); + + expect(element.value).to.equal(`C:\\fakepath\\${file.name}`); + expect(element.files!.length).to.equal(1); + expect(element.files![0]).to.equal(file); + + element.value = ''; + expect(element.value).to.equal(''); + expect(element.files!.length).to.equal(0); + }); + }); + + describe('File type layout', () => { + it('renders publicly documented parts when the input is of type file', async () => { + await createFixture(html``); + + expect( + element.shadowRoot!.querySelector('div[part="file-selector-button"]') + ).to.exist; + expect(element.shadowRoot!.querySelector('div[part="file-names"]')).to + .exist; + }); + + it('renders slotted contents when the input is of type file', async () => { + await createFixture(html` + + Upload + Choose a file + + `); + + const selectorSlot = element.shadowRoot!.querySelector( + 'slot[name="file-selector-text"]' + ) as HTMLSlotElement; + const missingSlot = element.shadowRoot!.querySelector( + 'slot[name="file-missing-text"]' + ) as HTMLSlotElement; + + expect(selectorSlot!.assignedNodes()[0].textContent).to.equal('Upload'); + expect(missingSlot!.assignedNodes()[0].textContent).to.equal( + 'Choose a file' + ); + }); + }); + + describe('Events', () => { + beforeEach(async () => { + await createFixture(html``); + }); + + it('emits igcChange', async () => { + await createFixture(html``); + const eventSpy = spy(element, 'emitEvent'); + + await simulateFileUpload(element, [files[0]]); + + expect(eventSpy).calledWith('igcChange', { + detail: element.value, + }); + }); + + it('emits igcCancel', async () => { + const eventSpy = spy(element, 'emitEvent'); + const input = element.shadowRoot!.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + input.dispatchEvent(new Event('cancel', { bubbles: true })); + await elementUpdated(element); + + expect(eventSpy).calledOnceWith('igcCancel', { + detail: element.value, + }); + }); + + it('should mark as dirty on focus', async () => { + const input = element.shadowRoot!.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + input.dispatchEvent(new Event('focus', { bubbles: true })); + await elementUpdated(element); + + const eventSpy = spy(element as any, '_validate'); + input.dispatchEvent(new Event('blur', { bubbles: true })); + + expect(eventSpy).called; + }); + + it('should validate on blur', async () => { + await createFixture(html``); + + const input = element.shadowRoot!.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + input.dispatchEvent(new Event('blur', { bubbles: true })); + await elementUpdated(element); + + expect(element.invalid).to.be.true; + }); + }); +}); + +describe('Form Validation', () => { + const files = [ + new File(['test content'], 'test.txt', { type: 'text/plain' }), + ]; + const _expectedValidation = Symbol(); + + type TestBedInput = IgcFileInputComponent & { + [_expectedValidation]: boolean; + }; + + function validateInput(event: CustomEvent) { + const element = event.target as TestBedInput; + expect(element.checkValidity()).to.equal(element[_expectedValidation]); + } + + function setExpectedValidationState( + state: boolean, + element: IgcFileInputComponent + ) { + (element as TestBedInput)[_expectedValidation] = state; + } + + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcFileInputComponent.tagName); + }); + + it('validates component', async () => { + setExpectedValidationState(true, spec.element); + await simulateFileUpload(spec.element, files); + }); +}); + +describe('Form Integration', () => { + const files = [ + new File(['test content'], 'test.txt', { type: 'text/plain' }), + ]; + + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcFileInputComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + expect(spec.element.value).to.equal(''); + }); + + it('is form associated', () => { + expect(spec.element.form).to.equal(spec.form); + }); + + it('is not associated on submit if no value', () => { + spec.assertSubmitHasValue(null); + }); + + it('is associated on submit', async () => { + await simulateFileUpload(spec.element, files); + spec.assertSubmitHasValue(files[0]); + }); + + it('is correctly resets on form reset', async () => { + await simulateFileUpload(spec.element, files); + spec.reset(); + + expect(spec.element.value).to.be.empty; + }); + + it('reflects disabled ancestor state', () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; + + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); + + it('fulfils required constraint', async () => { + spec.assertSubmitFails(); + + await simulateFileUpload(spec.element, files); + spec.assertSubmitPasses(); + }); +}); diff --git a/src/components/file-input/file-input.ts b/src/components/file-input/file-input.ts new file mode 100644 index 000000000..331cbeec6 --- /dev/null +++ b/src/components/file-input/file-input.ts @@ -0,0 +1,224 @@ +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { themes } from '../../theming/theming-decorator.js'; +import IgcButtonComponent from '../button/button.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { + type FormValue, + createFormValueState, + defaultFileListTransformer, +} from '../common/mixins/forms/form-value.js'; +import { isEmpty, partNameMap } from '../common/util.js'; +import { IgcInputBaseComponent } from '../input/input-base.js'; +import IgcValidationContainerComponent from '../validation-container/validation-container.js'; +import { styles } from './themes/file-input.base.css.js'; +import { all } from './themes/themes.js'; +import { fileValidators } from './validators.js'; + +/** + * @element igc-file-input + * + * @slot prefix - Renders content before the input. + * @slot suffix - Renders content after input. + * @slot helper-text - Renders content below the input. + * @slot file-selector-text - Renders content for the browse button when input type is file. + * @slot file-missing-text - Renders content when input type is file and no file is chosen. + * @slot value-missing - Renders content when the required validation fails. + * @slot custom-error - Renders content when setCustomValidity(message) is set. + * @slot invalid - Renders content when the component is in invalid state (validity.valid = false). + * + * @fires igcInput - Emitted when the control input receives user input. + * @fires igcChange - Emitted when the control's checked state changes. + * @fires igcCancel - Emitted when the control's file picker dialog is canceled. + * + * @csspart container - The main wrapper that holds all main input elements. + * @csspart input - The native input element. + * @csspart label - The native label element. + * @csspart file-names - The file names wrapper when input type is 'file'. + * @csspart file-selector-button - The browse button when input type is 'file'. + * @csspart prefix - The prefix wrapper. + * @csspart suffix - The suffix wrapper. + * @csspart helper-text - The helper text wrapper. + */ +@themes(all, { exposeController: true }) +export default class IgcFileInputComponent extends IgcInputBaseComponent { + private _hasActivation = false; + public static readonly tagName = 'igc-file-input'; + public static override styles = [...super.styles, styles]; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcFileInputComponent, + IgcValidationContainerComponent, + IgcButtonComponent + ); + } + + protected override get __validators() { + return fileValidators; + } + + protected override _formValue: FormValue; + + private get _fileNames(): string | null { + if (!this.files || this.files.length === 0) return null; + + return Array.from(this.files) + .map((file) => file.name) + .join(', '); + } + + /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ + /** + * The value of the control. + * @attr + */ + @property() + public set value(value: string) { + if (value === '' && this.input) { + this.input.value = value; + } + } + + public get value(): string { + return this.input?.value ?? ''; + } + + /** + * The multiple attribute of the control. + * Used to indicate that a file input allows the user to select more than one file. + * @attr + */ + @property({ type: Boolean }) + public multiple = false; + + /** + * The accept attribute of the control. + * Defines the file types as a list of comma-separated values that the file input should accept. + * @attr + */ + @property({ type: String }) + public accept = ''; + + /** + * The autofocus attribute of the control. + * @attr + */ + @property({ type: Boolean }) + public override autofocus!: boolean; + + /** + * @internal + */ + @property({ type: Number }) + public override tabIndex = 0; + + /** @hidden */ + @property({ type: Boolean, attribute: false, noAccessor: true }) + public override readonly readOnly = false; + + constructor() { + super(); + this._formValue = createFormValueState(this, { + initialValue: null, + transformers: defaultFileListTransformer, + }); + } + + protected override _restoreDefaultValue(): void { + this.input.value = ''; + super._restoreDefaultValue(); + } + + /** @hidden */ + public override setSelectionRange(): void {} + + /** @hidden */ + public override setRangeText(): void {} + + /** Returns the selected files when input type is 'file', otherwise returns null. */ + public get files(): FileList | null { + return this.input?.files ?? null; + } + + private handleChange() { + this._hasActivation = false; + this._formValue.setValueAndFormState(this.files); + this._validate(); + this.requestUpdate(); + this.emitEvent('igcChange', { detail: this.value }); + } + + private handleCancel() { + this._hasActivation = false; + this.emitEvent('igcCancel', { + detail: this.value, + }); + } + + protected handleFocus(): void { + this._dirty = true; + } + + protected handleBlur(): void { + if (!this._hasActivation) { + this._validate(); + } + } + + protected handleClick(): void { + this._hasActivation = true; + } + + protected override renderFileParts() { + const emptyText = this.placeholder ?? 'No file chosen'; + + return html` +
+
+ + Browse + +
+
+ ${this._fileNames ?? + html`${emptyText}`} +
+
+ `; + } + + protected renderInput() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-file-input': IgcFileInputComponent; + } +} diff --git a/src/components/file-input/themes/file-input.base.scss b/src/components/file-input/themes/file-input.base.scss new file mode 100644 index 000000000..bc902ae42 --- /dev/null +++ b/src/components/file-input/themes/file-input.base.scss @@ -0,0 +1,64 @@ +@use 'styles/utilities' as *; +@use '../../input/themes/light/themes' as *; + +$theme: $base; + +:host(:is(igc-file-input)) { + [part~='file-parts'] { + display: contents; + } + + [part='file-selector-button'] { + align-content: center; + grid-area: 1 / 2; + + igc-button::part(base) { + transition: none; + } + } + + [part='file-names'] { + @include ellipsis(); + + align-content: center; + grid-area: 1 / 3; + } + + [part~='container'] { + position: relative; + grid-template-columns: auto auto 1fr auto; + } + + [part~='input'] { + position: absolute; + border: none; + inset: 0; + width: 100%; + grid-column: 2 / 4; + appearance: none; + opacity: 0; + } + + [part~='input']::file-selector-button { + width: 100%; + cursor: auto; + } + + [part~='suffix'] { + grid-area: 1 / 4; + } +} + +:host(:not(:disabled)) { + [part~='input'], + [part~='input']::file-selector-button { + cursor: pointer; + } +} + +:host(:is(igc-file-input):disabled), +:host(:is(igc-file-input)[disabled]) { + [part~='file-names'] { + color: var-get($theme, 'disabled-text-color'); + } +} diff --git a/src/components/file-input/themes/shared/file-input.bootstrap.scss b/src/components/file-input/themes/shared/file-input.bootstrap.scss new file mode 100644 index 000000000..6dc8bfee7 --- /dev/null +++ b/src/components/file-input/themes/shared/file-input.bootstrap.scss @@ -0,0 +1,112 @@ +@use 'styles/utilities' as *; +@use '../../../input/themes/light/themes' as *; + +$theme: $bootstrap; + +:host(:focus-within) { + --border-color: #{var-get($theme, 'focused-border-color')}; + + [part~='container'] { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focused-secondary-color'); + } +} + +[part~='file-selector-button'] igc-button { + --foreground: #{var-get($theme, 'input-prefix-color')}; + --background: #{var-get($theme, 'input-prefix-background')}; + --hover-foreground: var(--foreground); + --hover-background: var(--background); + --active-foreground: var(--foreground); + --active-background: var(--background); + --focus-visible-foreground: var(--foreground); + --focus-visible-background: var(--background); + --disabled-foreground: #{var-get($theme, 'disabled-text-color')}; + --disabled-background: #{var-get($theme, 'border-disabled-background')}; + + &::part(base) { + font-size: var(--input-font); + transition: border 0.15s ease-out; + box-shadow: none; + border: { + style: solid; + color: var(--input-border-color); + inline: { + start-width: 1px; + end-width: 0; + } + block: { + start-width: 1px; + end-width: 1px; + } + } + } +} + +[part~='file-names'] { + font-size: var(--input-font); + padding-inline: pad-inline(8px, 12px, 16px); + padding-block: pad-block(4px, 6px, 8px); + color: var-get($theme, 'idle-text-color'); + border: 1px solid var-get($theme, 'border-color'); + border-start-end-radius: var-get($theme, 'border-border-radius'); + border-end-end-radius: var-get($theme, 'border-border-radius'); + transition: + box-shadow 0.15s ease-out, + border 0.15s ease-out; +} + +[part*='prefixed'] { + [part~='file-selector-button'] igc-button::part(base) { + border-start-start-radius: 0; + border-end-start-radius: 0; + } +} + +[part*='suffixed'] { + [part~='file-names'] { + border-start-end-radius: 0; + border-end-end-radius: 0; + } +} + +[part~='container'] { + transition: + box-shadow 0.15s ease-out, + border 0.15s ease-out; + + [part~='file-selector-button'] igc-button::part(base) { + border-start-end-radius: 0; + border-end-end-radius: 0; + height: var(--input-height); + } +} + + +:host(:not([disabled])[invalid]) { + --input-border-color: #{var-get($theme, 'error-secondary-color')}; + + [part~='container'] { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'error-shadow-color'); + } + + [part='prefix'], + [part='suffix'], + [part~='file-names'] { + border-color: var-get($theme, 'error-secondary-color'); + } +} + +:host(:disabled), +:host([disabled]) { + --input-border-color: #{var-get($theme, 'disabled-border-color')}; + + [part~='file-names'] { + color: var-get($theme, 'disabled-text-color'); + background: var-get($theme, 'border-disabled-background'); + border-color: var-get($theme, 'disabled-border-color'); + } + + [part~='file-selector-button'] igc-button { + --disabled-border-color: var(--input-border-color); + } +} diff --git a/src/components/file-input/themes/shared/file-input.fluent.scss b/src/components/file-input/themes/shared/file-input.fluent.scss new file mode 100644 index 000000000..390405a62 --- /dev/null +++ b/src/components/file-input/themes/shared/file-input.fluent.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part='file-names'] { + font-size: rem(14px); +} diff --git a/src/components/file-input/themes/shared/file-input.indigo.scss b/src/components/file-input/themes/shared/file-input.indigo.scss new file mode 100644 index 000000000..6a9de7c0b --- /dev/null +++ b/src/components/file-input/themes/shared/file-input.indigo.scss @@ -0,0 +1,12 @@ +@use 'styles/utilities' as *; +@use '../../../input/themes/light/themes' as *; + +$theme: $indigo; + +[part='file-names'] { + @include type-style('body-2'); + + color: var-get($theme, 'idle-text-color'); + font-size: rem(12px); + line-height: rem(16px); +} diff --git a/src/components/file-input/themes/shared/file-input.material.scss b/src/components/file-input/themes/shared/file-input.material.scss new file mode 100644 index 000000000..e1743ba81 --- /dev/null +++ b/src/components/file-input/themes/shared/file-input.material.scss @@ -0,0 +1,106 @@ +@use 'styles/utilities' as *; +@use '../../../input/themes/light/themes' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +$theme: $material; +$idle-border-width: rem(1px) !default; +$active-border-width: rem(2px) !default; + +%label { + --label-position: #{sizable(18px, 22px, 26px)}; + + transform: translateY(calc(var(--label-position) * -1)); +} + +%floated-styles { + border-top: $idle-border-width solid transparent; +} + +:host(:is(igc-file-input)) { + [part~='container'] { + grid-template-columns: auto auto auto 1fr auto; + } + + [part='notch'] [part='label'] { + transition: none; + } + + [part='label'] { + @extend %label; + @include type-style('caption'); + } + + [part~='file-names'] { + grid-area: 1 / 3 / span 1 / span 2; + padding-inline: rem(4px); + } + + [part~='input'] { + grid-column: 2 / 5; + } + + [part='filler'] { + grid-column: 4 / 5; + } + + [part='end'] { + grid-area: 1 / 5; + } +} + +:host([outlined]) { + [part='notch'] { + @extend %floated-styles; + + grid-column: 3 / 3; + border-top-color: transparent !important; + } + + [part='file-selector-button'] { + border: { + width: $idle-border-width; + style: solid; + color: var-get($theme, 'border-color'); + left: none; + right: none; + } + } +} + +:host([outlined]:hover) { + [part='file-selector-button'] { + border-color: var-get($theme, 'hover-border-color'); + } +} + +:host([outlined]:focus-within) { + [part='file-selector-button'] { + border: { + width: $active-border-width; + color: var-get($theme, 'focused-border-color'); + } + } +} + + +:host([outlined][invalid]), +:host([outlined][invalid]:focus-within) { + [part='file-selector-button'] { + border-color: var-get($theme, 'error-secondary-color'); + } +} + +:host(:not([outlined])) { + [part='notch'] { + grid-area: 1 / 3; + } + + [part='notch'] [part='label'] { + transform: translateY(-73%); + } + + [part~='labelled'] [part~='file-names'] { + padding-top: rem(20px); + padding-bottom: rem(6px); + } +} diff --git a/src/components/file-input/themes/themes.ts b/src/components/file-input/themes/themes.ts new file mode 100644 index 000000000..5c4556c2d --- /dev/null +++ b/src/components/file-input/themes/themes.ts @@ -0,0 +1,56 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +import { all as inputThemes } from '../../input/themes/themes.js'; + +// Shared Styles +import { styles as bootstrap } from './shared/file-input.bootstrap.css.js'; +import { styles as fluent } from './shared/file-input.fluent.css.js'; +import { styles as indigo } from './shared/file-input.indigo.css.js'; +import { styles as material } from './shared/file-input.material.css.js'; + +const light = { + shared: css` + ${inputThemes.light.shared!} + `, + bootstrap: css` + ${bootstrap} + ${inputThemes.light.bootstrap!} + `, + material: css` + ${material} + ${inputThemes.light.material!} + `, + indigo: css` + ${indigo} + ${inputThemes.light.indigo!} + `, + fluent: css` + ${fluent} + ${inputThemes.light.fluent!} + `, +}; + +const dark = { + shared: css` + ${inputThemes.dark.shared!} + `, + bootstrap: css` + ${bootstrap} + ${inputThemes.dark.bootstrap!} + `, + material: css` + ${material} + ${inputThemes.dark.material!} + `, + indigo: css` + ${indigo} + ${inputThemes.dark.indigo!} + `, + fluent: css` + ${fluent} + ${inputThemes.dark.fluent!} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/file-input/validators.ts b/src/components/file-input/validators.ts new file mode 100644 index 000000000..4b2d7b3ef --- /dev/null +++ b/src/components/file-input/validators.ts @@ -0,0 +1,6 @@ +import { type Validator, requiredValidator } from '../common/validators.js'; +import type IgcFileInputComponent from './file-input.js'; + +export const fileValidators: Validator[] = [ + requiredValidator, +]; diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 2a2461762..847fdc7f7 100644 --- a/src/components/input/input-base.ts +++ b/src/components/input/input-base.ts @@ -18,6 +18,8 @@ export interface IgcInputComponentEventMap { igcInput: CustomEvent; /* blazorSuppress */ igcChange: CustomEvent; + /* blazorSuppress */ + igcCancel: CustomEvent; // For analyzer meta only: /* skipWCPrefix */ focus: FocusEvent; @@ -129,6 +131,10 @@ export abstract class IgcInputBaseComponent extends FormAssociatedRequiredMixin( }; } + protected renderFileParts(): TemplateResult | typeof nothing { + return nothing; + } + /** Sets the text selection range of the control */ public setSelectionRange( start: number, @@ -175,7 +181,7 @@ export abstract class IgcInputBaseComponent extends FormAssociatedRequiredMixin( })} >
${this.renderPrefix()}
- ${this.renderInput()} + ${this.renderInput()} ${this.renderFileParts()}
${this.renderLabel()}
${this.renderSuffix()}
@@ -187,7 +193,8 @@ export abstract class IgcInputBaseComponent extends FormAssociatedRequiredMixin( private renderStandard() { return html`${this.renderLabel()}
- ${this.renderPrefix()} ${this.renderInput()} ${this.renderSuffix()} + ${this.renderPrefix()} ${this.renderFileParts()} ${this.renderInput()} + ${this.renderSuffix()}
${this.renderValidatorContainer()}`; } diff --git a/src/components/input/themes/shared/input.bootstrap.scss b/src/components/input/themes/shared/input.bootstrap.scss index d9a768aca..6fbc22ed2 100644 --- a/src/components/input/themes/shared/input.bootstrap.scss +++ b/src/components/input/themes/shared/input.bootstrap.scss @@ -8,6 +8,8 @@ $theme: $bootstrap; --input-size: var(--component-size); --font: #{sizable(rem(14px), rem(16px), rem(20px))}; --input-font: #{sizable(rem(16px), rem(16px), rem(20px))}; + --input-border-color: #{var-get($theme, 'border-color')}; + --input-height: #{var-get($theme, 'size')}; ::part(helper-text) { @include type-style('body-2'); @@ -38,7 +40,9 @@ $theme: $bootstrap; box-shadow: none; z-index: 1; border: 1px solid var-get($theme, 'border-color'); - transition: box-shadow .15s ease-out, border .15s ease-out; + transition: + box-shadow 0.15s ease-out, + border 0.15s ease-out; grid-area: 1 / 2; background-clip: padding-box; } @@ -90,7 +94,9 @@ $theme: $bootstrap; position: relative; border-radius: none; min-width: auto; - transition: box-shadow .15s ease-out, border .15s ease-out; + transition: + box-shadow 0.15s ease-out, + border 0.15s ease-out; font-size: var(--font); ::slotted(*) { @@ -104,15 +110,15 @@ $theme: $bootstrap; grid-area: 1 / 1; border: { style: solid; - color: var-get($theme, 'border-color');; + color: var-get($theme, 'border-color'); inline: { start-width: 1px; end-width: 0; - }; + } block: { start-width: 1px; end-width: 1px; - }; + } } } @@ -126,11 +132,11 @@ $theme: $bootstrap; inline: { start-width: 0; end-width: 1px; - }; + } block: { start-width: 1px; end-width: 1px; - }; + } } } @@ -187,6 +193,6 @@ $theme: $bootstrap; [part='label'], ::part(helper-text) { - color: var-get($theme, 'disabled-text-color') + color: var-get($theme, 'disabled-text-color'); } } diff --git a/src/components/input/themes/shared/input.material.scss b/src/components/input/themes/shared/input.material.scss index 0eabc0cd8..a74c1a4b5 100644 --- a/src/components/input/themes/shared/input.material.scss +++ b/src/components/input/themes/shared/input.material.scss @@ -69,9 +69,9 @@ input:placeholder-shown + [part='notch'] [part='label'], color: var-get($theme, 'idle-secondary-color'); transition: - transform 150ms cubic-bezier(.4, 0, .2, 1), - color 150ms cubic-bezier(.4, 0, .2, 1), - font-size 150ms cubic-bezier(.4, 0, .2, 1); + transform 150ms cubic-bezier(0.4, 0, 0.2, 1), + color 150ms cubic-bezier(0.4, 0, 0.2, 1), + font-size 150ms cubic-bezier(0.4, 0, 0.2, 1); line-height: normal; text-overflow: ellipsis; overflow: hidden; @@ -174,10 +174,10 @@ input:placeholder-shown + [part='notch'] [part='label'], start: { end-radius: var-get($theme, 'box-border-radius'); start-radius: var-get($theme, 'box-border-radius'); - }; + } } - transition: border-bottom-width 150ms cubic-bezier(.4, 0, .2, 1); + transition: border-bottom-width 150ms cubic-bezier(0.4, 0, 0.2, 1); &::after { position: absolute; @@ -189,7 +189,9 @@ input:placeholder-shown + [part='notch'] [part='label'], bottom: -1px; background: var-get($theme, 'idle-bottom-line-color'); transform: scaleX(0); - transition: transform 180ms cubic-bezier(.4, 0, .2, 1), opacity 180ms cubic-bezier(.4, 0, .2, 1); + transition: + transform 180ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 180ms cubic-bezier(0.4, 0, 0.2, 1); } } @@ -318,17 +320,17 @@ input:placeholder-shown + [part='notch'] [part='label'], inline: { start-width: $idle-border-width; end-width: 0; - }; + } block: { start-width: $idle-border-width; end-width: $idle-border-width; - }; + } start: { start-radius: var-get($theme, 'border-border-radius'); - }; + } end: { start-radius: var-get($theme, 'border-border-radius'); - }; + } } } @@ -349,17 +351,17 @@ input:placeholder-shown + [part='notch'] [part='label'], inline: { start-width: 0; end-width: $idle-border-width; - }; + } block: { start-width: $idle-border-width; end-width: $idle-border-width; - }; + } start: { end-radius: var-get($theme, 'border-border-radius'); - }; + } end: { end-radius: var-get($theme, 'border-border-radius'); - }; + } } } } @@ -420,7 +422,7 @@ input:placeholder-shown + [part='notch'] [part='label'], color: var-get($theme, 'focused-border-color'); inline: { start-width: $active-border-width; - }; + } block: { start-width: $active-border-width; end-width: $active-border-width; @@ -448,11 +450,11 @@ input:placeholder-shown + [part='notch'] [part='label'], color: var-get($theme, 'focused-border-color'); inline: { end-width: $active-border-width; - }; + } block: { start-width: $active-border-width; end-width: $active-border-width; - }; + } } } } @@ -502,7 +504,8 @@ input:placeholder-shown + [part='notch'] [part='label'], border: { width: $active-border-width; color: var-get($theme, 'error-secondary-color'); - top: $active-border-width solid var-get($theme, 'error-secondary-color'); + top: $active-border-width solid + var-get($theme, 'error-secondary-color'); } } } @@ -530,7 +533,7 @@ input:placeholder-shown + [part='notch'] [part='label'], :host([type='search'][outlined]) { [part='notch'] { - border: none + border: none; } } diff --git a/src/index.ts b/src/index.ts index d6d209fa5..9f5a23bbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export { default as IgcDropdownComponent } from './components/dropdown/dropdown. export { default as IgcDropdownGroupComponent } from './components/dropdown/dropdown-group.js'; export { default as IgcDropdownHeaderComponent } from './components/dropdown/dropdown-header.js'; export { default as IgcDropdownItemComponent } from './components/dropdown/dropdown-item.js'; +export { default as IgcFileInputComponent } from './components/file-input/file-input.js'; export { default as IgcSelectComponent } from './components/select/select.js'; export { default as IgcSelectGroupComponent } from './components/select/select-group.js'; export { default as IgcSelectHeaderComponent } from './components/select/select-header.js'; @@ -100,6 +101,7 @@ export type { IgcDropdownComponentEventMap } from './components/dropdown/dropdow export type { IgcExpansionPanelComponentEventMap } from './components/expansion-panel/expansion-panel.js'; export type { IgcInputComponentEventMap } from './components/input/input-base.js'; export type { IgcInputComponentEventMap as IgcMaskInputComponentEventMap } from './components/input/input-base.js'; +export type { IgcInputComponentEventMap as IgcFileInputComponentEventMap } from './components/input/input-base.js'; export type { IgcRadioComponentEventMap } from './components/radio/radio.js'; export type { IgcRatingComponentEventMap } from './components/rating/rating.js'; export type { IgcSelectComponentEventMap } from './components/select/select.js'; diff --git a/src/theming/types.ts b/src/theming/types.ts index a239b95ba..90828ff80 100644 --- a/src/theming/types.ts +++ b/src/theming/types.ts @@ -5,10 +5,10 @@ export type ThemeVariant = 'light' | 'dark'; export type Themes = { light: { - [K in Theme]?: CSSResult; + [K in Theme | 'shared']?: CSSResult; }; dark: { - [K in Theme]?: CSSResult; + [K in Theme | 'shared']?: CSSResult; }; }; diff --git a/stories/file-input.stories.ts b/stories/file-input.stories.ts new file mode 100644 index 000000000..05f3f18e7 --- /dev/null +++ b/stories/file-input.stories.ts @@ -0,0 +1,220 @@ +import { github } from '@igniteui/material-icons-extended'; +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { + IgcFileInputComponent, + IgcIconComponent, + defineComponents, + registerIcon, + registerIconFromText, +} from 'igniteui-webcomponents'; +import { formControls, formSubmitHandler } from './story.js'; + +defineComponents(IgcFileInputComponent, IgcIconComponent); +registerIconFromText(github.name, github.value); +registerIcon( + 'search', + 'https://unpkg.com/material-design-icons@3.0.1/action/svg/production/ic_search_24px.svg' +); + +// region default +const metadata: Meta = { + title: 'FileInput', + component: 'igc-file-input', + parameters: { + docs: { description: { component: '' } }, + actions: { handles: ['igcInput', 'igcChange', 'igcCancel'] }, + }, + argTypes: { + value: { + type: 'string | Date', + description: 'The value of the control.', + options: ['string', 'Date'], + control: 'text', + }, + multiple: { + type: 'boolean', + description: + 'The multiple attribute of the control.\nUsed to indicate that a file input allows the user to select more than one file.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + accept: { + type: 'string', + description: + 'The accept attribute of the control.\nDefines the file types as a list of comma-separated values that the file input should accept.', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, + autofocus: { + type: 'boolean', + description: 'The autofocus attribute of the control.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + required: { + type: 'boolean', + description: + 'When set, makes the component a required field for validation.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + name: { + type: 'string', + description: 'The name attribute of the control.', + control: 'text', + }, + disabled: { + type: 'boolean', + description: 'The disabled state of the component.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + invalid: { + type: 'boolean', + description: 'Sets the control into invalid state (visual state only).', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + outlined: { + type: 'boolean', + description: 'Whether the control will have outlined appearance.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + placeholder: { + type: 'string', + description: 'The placeholder attribute of the control.', + control: 'text', + }, + label: { + type: 'string', + description: 'The label for the control.', + control: 'text', + }, + }, + args: { + multiple: false, + accept: '', + autofocus: false, + required: false, + disabled: false, + invalid: false, + outlined: false, + }, +}; + +export default metadata; + +interface IgcFileInputArgs { + /** The value of the control. */ + value: string | Date; + /** + * The multiple attribute of the control. + * Used to indicate that a file input allows the user to select more than one file. + */ + multiple: boolean; + /** + * The accept attribute of the control. + * Defines the file types as a list of comma-separated values that the file input should accept. + */ + accept: string; + /** The autofocus attribute of the control. */ + autofocus: boolean; + /** When set, makes the component a required field for validation. */ + required: boolean; + /** The name attribute of the control. */ + name: string; + /** The disabled state of the component. */ + disabled: boolean; + /** Sets the control into invalid state (visual state only). */ + invalid: boolean; + /** Whether the control will have outlined appearance. */ + outlined: boolean; + /** The placeholder attribute of the control. */ + placeholder: string; + /** The label for the control. */ + label: string; +} +type Story = StoryObj; + +// endregion + +export const Basic: Story = { + args: { + label: 'File input', + }, + render: (args) => html` + + `, +}; + +export const Slots: Story = { + args: { + label: 'Input with slots', + }, + render: (args) => html` + + + + Sample helper text. + + `, +}; + +export const Validation: Story = { + args: { + label: 'Required field', + name: 'files', + required: true, + }, + render: (args) => html` +
+
+ +

Your life's work

+

You must upload a file

+
+
+ ${formControls()} +
+ `, +};