Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- New File Input Component(`igc-file-input`)

### Fixed
- Setting validation properties on a pristine non-dirty form associated element does not apply invalid styles [#1632](https://github.com/IgniteUI/igniteui-webcomponents/issues/1632)

## [5.3.0] - 2025-03-13
### Added
- Tile manager component [#1402](https://github.com/IgniteUI/igniteui-webcomponents/pull/1402)
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/mixins/forms/associated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function BaseFormAssociated<T extends Constructor<LitElement>>(base: T) {
}

private _setInvalidState(): void {
if (this.hasUpdated || this._dirty) {
if (this._dirty || !this._pristine) {
this.invalid = !this.checkValidity();
}
}
Expand Down
31 changes: 0 additions & 31 deletions src/components/common/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,37 +357,6 @@ 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<void> {
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 })
Expand Down
192 changes: 87 additions & 105 deletions src/components/file-input/file-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { spy } from 'sinon';

import type { TemplateResult } from 'lit';
import { defineComponents } from '../common/definitions/defineComponents.js';
import { first } from '../common/util.js';
import {
type ValidationContainerTestsParams,
createFormAssociatedTestBed,
simulateFileUpload,
runValidationContainerTests,
} from '../common/utils.spec.js';
import IgcFileInputComponent from './file-input.js';

Expand All @@ -27,6 +29,10 @@ describe('File Input component', () => {
input = element.renderRoot.querySelector('input')!;
}

function getDOM(selector: string) {
return element.renderRoot.querySelector(selector) as HTMLElement;
}

describe('Properties', () => {
it('sets the multiple property when input is of type file', async () => {
await createFixture(html`<igc-file-input multiple></igc-file-input>`);
Expand Down Expand Up @@ -58,65 +64,61 @@ describe('File Input component', () => {

it('returns the uploaded files when input is of type file', async () => {
await createFixture(html`<igc-file-input></igc-file-input>`);
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');
simulateFileUpload(input, files);
await elementUpdated(element);

expect(element.files).lengthOf(2);
expect(element.files?.item(0)?.name).to.equal('test.txt');
expect(element.files?.item(1)?.name).to.equal('image.png');
});

it('should show placeholder text when no file is selected', async () => {
await createFixture(html`<igc-file-input></igc-file-input>`);

expect(
element
.shadowRoot!.querySelector('[part="file-names"]')!
.textContent!.trim()
).to.equal('No file chosen');
expect(getDOM('[part="file-names"]').innerText).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');
expect(getDOM('[part="file-names"]').innerText).to.equal(
'Select a document'
);

await simulateFileUpload(element, [files[0]]);
element.focus();
simulateFileUpload(input, [first(files)]);
await elementUpdated(element);

expect(
element
.shadowRoot!.querySelector('[part="file-names"]')!
.textContent!.trim()
).to.equal('test.txt');
expect(getDOM('[part="file-names"]').innerText).to.equal(
first(files).name
);
});

it('resets the file selection when empty string is passed for value', async () => {
const file = files[0];
await createFixture(html`<igc-file-input></igc-file-input>`);
await simulateFileUpload(element, [file]);
const file = first(files);

simulateFileUpload(input, [file]);
await elementUpdated(element);

expect(element.value).to.equal(`C:\\fakepath\\${file.name}`);
expect(element.files!.length).to.equal(1);
expect(element.files![0]).to.equal(file);
expect(element.files).lengthOf(1);
expect(element.files?.item(0)).to.equal(file);

element.value = '';
expect(element.value).to.equal('');
expect(element.files!.length).to.equal(0);
expect(element.value).to.be.empty;
expect(element.files).to.be.empty;
});
});

describe('File type layout', () => {
it('renders publicly documented parts when the input is of type file', async () => {
await createFixture(html`<igc-file-input></igc-file-input>`);

expect(
element.shadowRoot!.querySelector('div[part="file-selector-button"]')
).to.exist;
expect(element.shadowRoot!.querySelector('div[part="file-names"]')).to
.exist;
expect(getDOM('div[part="file-selector-button"]')).is.not.null;
expect(getDOM('div[part="file-names"]')).is.not.null;
});

it('renders slotted contents when the input is of type file', async () => {
Expand All @@ -127,15 +129,17 @@ describe('File Input component', () => {
</igc-file-input>
`);

const selectorSlot = element.shadowRoot!.querySelector(
const selectorSlot = getDOM(
'slot[name="file-selector-text"]'
) as HTMLSlotElement;
const missingSlot = element.shadowRoot!.querySelector(
const missingSlot = getDOM(
'slot[name="file-missing-text"]'
) as HTMLSlotElement;

expect(selectorSlot!.assignedNodes()[0].textContent).to.equal('Upload');
expect(missingSlot!.assignedNodes()[0].textContent).to.equal(
expect(first(selectorSlot.assignedNodes()).textContent).to.equal(
'Upload'
);
expect(first(missingSlot.assignedNodes()).textContent).to.equal(
'Choose a file'
);
});
Expand All @@ -150,7 +154,8 @@ describe('File Input component', () => {
await createFixture(html`<igc-file-input></igc-file-input>`);
const eventSpy = spy(element, 'emitEvent');

await simulateFileUpload(element, [files[0]]);
simulateFileUpload(input, [first(files)]);
await elementUpdated(element);

expect(eventSpy).calledWith('igcChange', {
detail: element.value,
Expand All @@ -159,9 +164,6 @@ describe('File Input component', () => {

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);
Expand All @@ -171,76 +173,28 @@ describe('File Input component', () => {
});
});

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 () => {
it('should update UI invalid state on blur when interacted', async () => {
await createFixture(html`<igc-file-input required></igc-file-input>`);

const input = element.shadowRoot!.querySelector(
'input[type="file"]'
) as HTMLInputElement;
// Internal invalid state, invalid UI is in initial state.
expect(element.validity.valueMissing).to.be.true;
expect(element.invalid).to.be.false;

input.dispatchEvent(new Event('blur', { bubbles: true }));
await elementUpdated(element);
// Internal invalid state, invalid UI is still in initial state.
element.focus();
expect(element.validity.valueMissing).to.be.true;
expect(element.invalid).to.be.false;

// Internal invalid state, invalid UI is updated.
element.blur();
expect(element.validity.valueMissing).to.be.true;
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<string>) {
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<IgcFileInputComponent>(
html`<igc-file-input
name="input"
required
@igcChange=${validateInput}
></igc-file-input>`
);

beforeEach(async () => {
await spec.setup(IgcFileInputComponent.tagName);
});

it('validates component', async () => {
setExpectedValidationState(true, spec.element);
await simulateFileUpload(spec.element, files);
});
});

describe('Form Integration', () => {
let input: HTMLInputElement;
const files = [
new File(['test content'], 'test.txt', { type: 'text/plain' }),
];
Expand All @@ -251,6 +205,7 @@ describe('Form Integration', () => {

beforeEach(async () => {
await spec.setup(IgcFileInputComponent.tagName);
input = spec.element.renderRoot.querySelector('input')!;
});

it('correct initial state', () => {
Expand All @@ -267,12 +222,14 @@ describe('Form Integration', () => {
});

it('is associated on submit', async () => {
await simulateFileUpload(spec.element, files);
spec.assertSubmitHasValue(files[0]);
simulateFileUpload(input, files);
await elementUpdated(spec.element);
spec.assertSubmitHasValue(first(files));
});

it('is correctly resets on form reset', async () => {
await simulateFileUpload(spec.element, files);
simulateFileUpload(input, files);
await elementUpdated(spec.element);
spec.reset();

expect(spec.element.value).to.be.empty;
Expand All @@ -289,7 +246,32 @@ describe('Form Integration', () => {
it('fulfils required constraint', async () => {
spec.assertSubmitFails();

await simulateFileUpload(spec.element, files);
simulateFileUpload(input, files);
await elementUpdated(spec.element);

spec.assertSubmitPasses();
});
});

describe('Validation message slots', () => {
it('', async () => {
const testParameters: ValidationContainerTestsParams<IgcFileInputComponent>[] =
[
{ slots: ['valueMissing'], props: { required: true } },
{ slots: ['customError'] },
];

runValidationContainerTests(IgcFileInputComponent, testParameters);
});
});

function simulateFileUpload(input: HTMLInputElement, files: File[]) {
const dataTransfer = new DataTransfer();

for (const file of files) {
dataTransfer.items.add(file);
}

input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
Loading