Skip to content

Commit b58829e

Browse files
committed
refactor(input): split file type input into a new component
1 parent 4632d91 commit b58829e

File tree

20 files changed

+916
-443
lines changed

20 files changed

+916
-443
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/)
55
and this project adheres to [Semantic Versioning](http://semver.org/).
66

7+
## [Unreleased]
8+
### Added
9+
- New File Input Component(`igc-file-input`)
10+
711
## [5.3.0] - 2025-03-13
812
### Added
913
- Tile manager component [#1402](https://github.com/IgniteUI/igniteui-webcomponents/pull/1402)

package-lock.json

Lines changed: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"globby": "^14.1.0",
8080
"husky": "^9.1.7",
8181
"ig-typedoc-theme": "^6.0.0",
82-
"igniteui-theming": "^16.1.0",
82+
"igniteui-theming": "^17.0.0",
8383
"keep-a-changelog": "^2.6.1",
8484
"lint-staged": "^15.5.0",
8585
"lit-analyzer": "^2.0.3",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
2+
import { spy } from 'sinon';
3+
4+
import type { TemplateResult } from 'lit';
5+
import { defineComponents } from '../common/definitions/defineComponents.js';
6+
import IgcFileInputComponent from './file-input.js';
7+
8+
describe('File input component', () => {
9+
before(() => {
10+
defineComponents(IgcFileInputComponent);
11+
});
12+
13+
let element: IgcFileInputComponent;
14+
let input: HTMLInputElement;
15+
16+
async function createFixture(template: TemplateResult) {
17+
element = await fixture<IgcFileInputComponent>(template);
18+
input = element.renderRoot.querySelector('input')!;
19+
}
20+
21+
describe('Properties', () => {
22+
it('sets the multiple property when input is of type file', async () => {
23+
await createFixture(html`<igc-file-input multiple></igc-file-input>`);
24+
25+
expect(element.multiple).to.equal(true);
26+
expect(input.multiple).to.equal(true);
27+
28+
element.multiple = false;
29+
await elementUpdated(element);
30+
31+
expect(element.multiple).to.equal(false);
32+
expect(input.multiple).to.equal(false);
33+
});
34+
35+
it('sets the accept property when input is of type file', async () => {
36+
await createFixture(
37+
html`<igc-file-input accept="image/*"></igc-file-input>`
38+
);
39+
40+
expect(element.accept).to.equal('image/*');
41+
expect(input.accept).to.equal('image/*');
42+
43+
element.accept = '';
44+
await elementUpdated(element);
45+
46+
expect(element.accept).to.be.empty;
47+
expect(input.accept).to.be.empty;
48+
});
49+
50+
it('returns the uploaded files when input is of type file', async () => {
51+
await createFixture(html`<igc-file-input></igc-file-input>`);
52+
53+
const eventSpy = spy(element, 'emitEvent');
54+
const file = new File(['test content'], 'test.txt', {
55+
type: 'text/plain',
56+
});
57+
const fileList = {
58+
0: file,
59+
length: 1,
60+
item: (index: number) => (index === 0 ? file : null),
61+
};
62+
const nativeInput = element.shadowRoot!.querySelector(
63+
'input[type="file"]'
64+
) as HTMLInputElement;
65+
66+
Object.defineProperty(nativeInput, 'files', {
67+
value: fileList,
68+
writable: true,
69+
});
70+
71+
nativeInput!.dispatchEvent(new Event('change', { bubbles: true }));
72+
73+
await elementUpdated(element);
74+
75+
expect(eventSpy).calledOnceWith('igcChange');
76+
expect(element.files).to.exist;
77+
expect(element.files!.length).to.equal(1);
78+
expect(element.files![0].name).to.equal('test.txt');
79+
expect(element.files).to.deep.equal(nativeInput.files);
80+
});
81+
});
82+
83+
describe('File type layout', () => {
84+
it('renders publicly documented parts when the input is of type file', async () => {
85+
await createFixture(html`<igc-file-input></igc-file-input>`);
86+
87+
expect(
88+
element.shadowRoot!.querySelector('div[part="file-selector-button"]')
89+
).to.exist;
90+
expect(element.shadowRoot!.querySelector('div[part="file-names"]')).to
91+
.exist;
92+
});
93+
94+
it('renders slotted contents when the input is of type file', async () => {
95+
await createFixture(html`
96+
<igc-file-input>
97+
<span slot="file-selector-text">Upload</span>
98+
<span slot="file-missing-text">Choose a file</span>
99+
</igc-file-input>
100+
`);
101+
102+
const selectorSlot = element.shadowRoot!.querySelector(
103+
'slot[name="file-selector-text"]'
104+
) as HTMLSlotElement;
105+
const missingSlot = element.shadowRoot!.querySelector(
106+
'slot[name="file-missing-text"]'
107+
) as HTMLSlotElement;
108+
109+
expect(selectorSlot!.assignedNodes()[0].textContent).to.equal('Upload');
110+
expect(missingSlot!.assignedNodes()[0].textContent).to.equal(
111+
'Choose a file'
112+
);
113+
});
114+
});
115+
116+
describe('Events', () => {
117+
beforeEach(async () => {
118+
await createFixture(html`<igc-file-input></igc-file-input>`);
119+
});
120+
121+
it('emits igcInput', async () => {});
122+
it('emits igcChange', async () => {});
123+
it('emits igcCancel', async () => {});
124+
});
125+
});
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { html, nothing } from 'lit';
2+
import { property } from 'lit/decorators.js';
3+
import { ifDefined } from 'lit/directives/if-defined.js';
4+
import { live } from 'lit/directives/live.js';
5+
6+
import { themes } from '../../theming/theming-decorator.js';
7+
import IgcButtonComponent from '../button/button.js';
8+
import { registerComponent } from '../common/definitions/register.js';
9+
import {
10+
type FormValue,
11+
createFormValueState,
12+
} from '../common/mixins/forms/form-value.js';
13+
import { isEmpty, partNameMap } from '../common/util.js';
14+
import { IgcInputBaseComponent } from '../input/input-base.js';
15+
import IgcValidationContainerComponent from '../validation-container/validation-container.js';
16+
import { styles } from './themes/base.css.js';
17+
import { all } from './themes/themes.js';
18+
import { fileValidators } from './validators.js';
19+
20+
/**
21+
* @element igc-file-input
22+
*
23+
* @slot prefix - Renders content before the input.
24+
* @slot suffix - Renders content after input.
25+
* @slot helper-text - Renders content below the input.
26+
* @slot file-selector-text - Renders content for the browse button when input type is file.
27+
* @slot file-missing-text - Renders content when input type is file and no file is chosen.
28+
* @slot value-missing - Renders content when the required validation fails.
29+
* @slot custom-error - Renders content when setCustomValidity(message) is set.
30+
* @slot invalid - Renders content when the component is in invalid state (validity.valid = false).
31+
*
32+
* @fires igcInput - Emitted when the control input receives user input.
33+
* @fires igcChange - Emitted when the control's checked state changes.
34+
* @fires igcCancel - Emitted when the control's file picker dialog is canceled.
35+
*
36+
* @csspart container - The main wrapper that holds all main input elements.
37+
* @csspart input - The native input element.
38+
* @csspart label - The native label element.
39+
* @csspart file-names - The file names wrapper when input type is 'file'.
40+
* @csspart file-selector-button - The browse button when input type is 'file'.
41+
* @csspart prefix - The prefix wrapper.
42+
* @csspart suffix - The suffix wrapper.
43+
* @csspart helper-text - The helper text wrapper.
44+
*/
45+
@themes(all, { exposeController: true })
46+
export default class IgcFileInputComponent extends IgcInputBaseComponent {
47+
public static readonly tagName = 'igc-file-input';
48+
public static override styles = [...super.styles, styles];
49+
50+
/* blazorSuppress */
51+
public static register() {
52+
registerComponent(
53+
IgcFileInputComponent,
54+
IgcValidationContainerComponent,
55+
IgcButtonComponent
56+
);
57+
}
58+
59+
protected override get __validators() {
60+
return fileValidators;
61+
}
62+
63+
protected override _formValue: FormValue<string>;
64+
65+
private get _fileNames(): string | null {
66+
if (!this.files || this.files.length === 0) return null;
67+
68+
return Array.from(this.files)
69+
.map((file) => file.name)
70+
.join(', ');
71+
}
72+
73+
/* @tsTwoWayProperty(true, "igcChange", "detail", false) */
74+
/**
75+
* The value of the control.
76+
* @attr
77+
*/
78+
@property()
79+
public set value(value: string) {
80+
this._formValue.setValueAndFormState(value);
81+
this._validate();
82+
}
83+
84+
public get value(): string {
85+
return this._formValue.value;
86+
}
87+
88+
/**
89+
* The multiple attribute of the control.
90+
* Used to indicate that a file input allows the user to select more than one file.
91+
* @attr
92+
*/
93+
@property({ type: Boolean })
94+
public multiple = false;
95+
96+
/**
97+
* The accept attribute of the control.
98+
* Defines the file types as a list of comma-separated values that the file input should accept.
99+
* @attr
100+
*/
101+
@property({ type: String })
102+
public accept = '';
103+
104+
/**
105+
* The autofocus attribute of the control.
106+
* @attr
107+
*/
108+
@property({ type: Boolean })
109+
public override autofocus!: boolean;
110+
111+
/**
112+
* @internal
113+
*/
114+
@property({ type: Number })
115+
public override tabIndex = 0;
116+
117+
constructor() {
118+
super();
119+
this._formValue = createFormValueState(this, { initialValue: '' });
120+
}
121+
122+
/** Returns the selected files when input type is 'file', otherwise returns null. */
123+
public get files(): FileList | null {
124+
if (!this.input) return null;
125+
return this.input.files;
126+
}
127+
128+
private handleInput() {
129+
this.value = this.input.value;
130+
this.emitEvent('igcInput', { detail: this.value });
131+
}
132+
133+
private handleChange() {
134+
this.value = this.input.value;
135+
this.emitEvent('igcChange', { detail: this.value });
136+
}
137+
138+
private handleCancel() {
139+
this.emitEvent('igcCancel', {
140+
detail: {
141+
message: 'User canceled the file selection dialog',
142+
value: this.value,
143+
},
144+
});
145+
}
146+
147+
protected handleFocus(): void {
148+
this._dirty = true;
149+
}
150+
151+
protected handleBlur(): void {
152+
this._validate();
153+
}
154+
155+
protected override renderFileParts() {
156+
const emptyText = this.placeholder ?? 'No file chosen';
157+
158+
return html`
159+
<div part="file-parts">
160+
<div part="file-selector-button">
161+
<igc-button variant="flat" ?disabled=${this.disabled} tabindex="-1">
162+
<slot name="file-selector-text">Browse</slot>
163+
</igc-button>
164+
</div>
165+
<div part="file-names">
166+
${this._fileNames ??
167+
html`<slot name="file-missing-text">${emptyText}</slot>`}
168+
</div>
169+
</div>
170+
`;
171+
}
172+
173+
protected renderInput() {
174+
return html`
175+
<input
176+
id=${this.inputId}
177+
part=${partNameMap(this.resolvePartNames('input'))}
178+
name=${ifDefined(this.name)}
179+
type="file"
180+
.value=${live(this.value)}
181+
?readonly=${this.readOnly}
182+
?disabled=${this.disabled}
183+
?required=${this.required}
184+
?autofocus=${this.autofocus}
185+
?multiple=${this.multiple}
186+
tabindex=${this.tabIndex}
187+
accept=${ifDefined(this.accept === '' ? undefined : this.accept)}
188+
aria-invalid=${this.invalid ? 'true' : 'false'}
189+
aria-describedby=${ifDefined(
190+
isEmpty(this._helperText) ? nothing : 'helper-text'
191+
)}
192+
@change=${this.handleChange}
193+
@input=${this.handleInput}
194+
@cancel=${this.handleCancel}
195+
@focus=${this.handleFocus}
196+
@blur=${this.handleBlur}
197+
/>
198+
`;
199+
}
200+
}
201+
202+
declare global {
203+
interface HTMLElementTagNameMap {
204+
'igc-file-input': IgcFileInputComponent;
205+
}
206+
}

0 commit comments

Comments
 (0)