diff --git a/packages/storybook/stories/va-file-input-multiple-uswds.stories.jsx b/packages/storybook/stories/va-file-input-multiple-uswds.stories.jsx
new file mode 100644
index 000000000..1413e28aa
--- /dev/null
+++ b/packages/storybook/stories/va-file-input-multiple-uswds.stories.jsx
@@ -0,0 +1,308 @@
+/* eslint-disable react/prop-types */
+import React, {useState} from 'react';
+import {VaFileInputMultiple} from '@department-of-veterans-affairs/web-components/react-bindings';
+import {getWebComponentDocs, propStructure} from './wc-helpers';
+
+const fileInputDocs = getWebComponentDocs('va-file-input-multiple');
+
+export default {
+ title: 'Components/File input multiple USWDS',
+ id: 'uswds/va-file-input-multiple',
+ parameters: {
+ componentSubtitle: `va-file-input-multiple web component`,
+
+ },
+};
+
+const defaultArgs = {
+ 'label': 'Select a file to upload',
+ 'name': 'my-file-input',
+ 'accept': null,
+ 'required': false,
+ 'errors': [],
+ 'enable-analytics': false,
+ 'hint': 'You can upload a .pdf, .gif, .jpg, .bmp, or .txt file.',
+ 'vaMultipleChange': null,
+ 'header-size': null,
+ 'children': null
+};
+
+const Template = ({
+ label,
+ name,
+ accept,
+ errors,
+ required,
+ hint,
+ 'enable-analytics': enableAnalytics,
+ vaMultipleChange,
+ headerSize,
+ additional
+}) => {
+ return (
+
+ );
+};
+
+export const Default = Template.bind(null);
+Default.args = { ...defaultArgs };
+Default.argTypes = propStructure(fileInputDocs);
+
+export const Accept = Template.bind(null);
+Accept.args = {
+ ...defaultArgs,
+ label: 'Select PDF files',
+ hint: 'All files in this list must be PDFs',
+ accept: '.pdf'
+};
+
+export const HeaderSize = Template.bind(null);
+HeaderSize.args = {
+ ...defaultArgs,
+ label: 'Custom sized header',
+ hint: 'Numbers from 1-6 correspond to an H1-H6 tag',
+ headerSize: 1
+};
+
+const additionalInfoContent = (
+
+
+ Public Document
+ Private Document
+
+
);
+
+const AdditionalInfoTemplate = ({
+ label,
+ name,
+ accept,
+ errors,
+ required,
+ hint,
+ 'enable-analytics': enableAnalytics,
+ vaMultipleChange,
+ headerSize,
+ additional
+}) => {
+ return (
+ <>
+
+ {additional}
+
+
+
+
To add additional fields associated with some file, you can pass custom content into the slot of this component and it will render in each file input.
+
This example showcases how to include custom content, such as dropdowns, within the file input component.
+
+
+ >
+ );
+};
+
+export const AdditionalInfo = AdditionalInfoTemplate.bind(null);
+AdditionalInfo.args = {
+ ...defaultArgs,
+ label: 'Label Header',
+ additional: additionalInfoContent
+}
+
+const ErrorsTemplate = ({label, name, hint}) => {
+ const [errorsList, setErrorsList] = useState([]);
+
+ function setErrorForEachFile(event) {
+ const fileEntries = event.detail.files;
+ const errors = fileEntries.map((file, index) => {
+ if (!file) {
+ return '';
+ }
+ return 'Error for index ' + index;
+ });
+ setErrorsList(errors);
+ }
+
+ return (
+ <>
+
+
+
+
Parent components are responsible for managing error states through a dedicated error array. Each index in this array corresponds to a file input, with the value at each index representing the error state for that specific file. This setup allows for the dynamic display of errors based on real-time validation of each file as it is processed.
+
+
+
+
+ {`const [errorsList, setErrorsList] = useState([]);
+
+ function setErrorForEachFile(event) {
+ const fileEntries = event.detail.files;
+ const errors = fileEntries.map((file, index) => {
+ if (!file) {
+ return '';
+ }
+ return 'Error for index ' + index;
+ });
+ setErrorsList(errors);
+ }
+
+ return (
+ `}
+
+
+
+ View validation code in our repo
+
+
+ >
+ )
+};
+
+export const Errors = ErrorsTemplate.bind(null);
+Errors.args = {
+ ...defaultArgs,
+ label: 'Label Header',
+}
+
+const CustomValidationTemplate = ({ label, name, accept, hint }) => {
+ const [errorsList, setErrorsList] = useState([]);
+
+ function validateFileContents(event) {
+ let errors = [];
+ const fileEntries = event.detail.files;
+
+ fileEntries.forEach(fileEntry => {
+ if (fileEntry) {
+ let error = '';
+
+ if (fileEntry.size > 2 * 1024 * 1024) { // 2MB = 2 * 1024 * 1024 bytes
+ error = "File size cannot be greater than 2MB";
+ }
+
+ errors.push(error);
+ } else {
+ errors.push(''); // Add an empty error if no fileEntry
+ }
+ });
+
+ setErrorsList(errors);
+ }
+
+ return (
+ <>
+
+
+
+
The parent component can capture the files from the onVaMultipleChange event, validate the files, and dynamically set errors. Each file must have a corresponding entry in the errors array prop, even if the entry is an empty string indicating no errors.
+
This example demonstrates custom validation logic to show an error if the file size exceeds 2MB. Validation occurs when a file is added or removed.
+
+
+
+
+{`const [errorsList, setErrorsList] = useState([]);
+
+function validateFileContents(event) {
+ let errors = [];
+ const fileEntries = event.detail.files;
+
+ fileEntries.forEach(fileEntry => {
+ if (fileEntry) {
+ let error = '';
+
+ if (fileEntry.size > 2 * 1024 * 1024) { // 2MB = 2 * 1024 * 1024 bytes
+ error = "File size cannot be greater than 2MB";
+ }
+
+ errors.push(error);
+ } else {
+ errors.push(''); // Add an empty error if no fileEntry
+ }
+ });
+
+ setErrorsList(errors);
+ }
+
+ `}
+
+
+
+ View validation code in our repo
+
+
+ >
+ );
+};
+
+export const CustomValidation = CustomValidationTemplate.bind(null);
+CustomValidation.args = {
+ ...defaultArgs,
+ label: 'Upload files which are smaller than 2 MB',
+ hint: 'Select any file type',
+}
\ No newline at end of file
diff --git a/packages/storybook/stories/va-file-input-uswds.stories.jsx b/packages/storybook/stories/va-file-input-uswds.stories.jsx
index 08f7a74ea..95868211e 100644
--- a/packages/storybook/stories/va-file-input-uswds.stories.jsx
+++ b/packages/storybook/stories/va-file-input-uswds.stories.jsx
@@ -24,10 +24,9 @@ const defaultArgs = {
'error': '',
'enable-analytics': false,
'hint': 'You can upload a .pdf, .gif, .jpg, .bmp, or .txt file.',
- 'vaChange': event =>
- alert(`File change event received: ${event?.detail?.files[0]?.name}`),
+ 'vaChange': null,
'uswds': true,
- 'headerSize': null,
+ 'header-size': null,
'children': null
};
@@ -55,7 +54,7 @@ const Template = ({
enable-analytics={enableAnalytics}
onVaChange={vaChange}
uswds={uswds}
- headerSize={headerSize}
+ header-size={headerSize}
children={children}
/>
);
@@ -100,7 +99,7 @@ HeaderLabel.args = {
required: true
}
-const additionalInfoSlotContent = (
+const additionalInfoContent = (
Public Document
@@ -112,7 +111,7 @@ export const AdditionalInfo = Template.bind(null);
AdditionalInfo.args = {
...defaultArgs,
label: 'Label Header',
- children: additionalInfoSlotContent
+ children: additionalInfoContent
}
const CustomValidationTemplate = ({ label, name, accept, required, error, hint }) => {
@@ -206,4 +205,7 @@ CustomValidation.args = {
}
export const WithAnalytics = Template.bind(null);
-WithAnalytics.args = { ...defaultArgs, 'enable-analytics': true };
\ No newline at end of file
+WithAnalytics.args = {
+ ...defaultArgs,
+ 'enable-analytics': true
+};
\ No newline at end of file
diff --git a/packages/web-components/package.json b/packages/web-components/package.json
index cb8f1f762..c91586058 100644
--- a/packages/web-components/package.json
+++ b/packages/web-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@department-of-veterans-affairs/web-components",
- "version": "9.2.0",
+ "version": "10.0.0",
"description": "Stencil Component Starter",
"main": "dist/index.cjs.js",
"module": "dist/index.js",
diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts
index e2bbac282..456d0adaf 100644
--- a/packages/web-components/src/components.d.ts
+++ b/packages/web-components/src/components.d.ts
@@ -443,6 +443,10 @@ export namespace Components {
* Optionally specifies the size of the header element to use instead of the base label. Accepts a number from 1 to 6, corresponding to HTML header elements h1 through h6. If not provided, defaults to standard label styling.
*/
"headerSize"?: number;
+ /**
+ * DST only prop removes extraneous display for multiple file input
+ */
+ "headless"?: boolean;
/**
* Optional hint text.
*/
@@ -464,6 +468,40 @@ export namespace Components {
*/
"uswds"?: boolean;
}
+ interface VaFileInputMultiple {
+ /**
+ * Defines acceptable file types the user can select; uses file type or extensions.
+ */
+ "accept"?: string;
+ /**
+ * If enabled, emits custom analytics events when file changes occur.
+ */
+ "enableAnalytics"?: boolean;
+ /**
+ * Array of error messages corresponding to each file input. The length and order match the files array.
+ */
+ "errors": string[];
+ /**
+ * Specifies the header size of the label element, from 1 (largest) to 6 (smallest).
+ */
+ "headerSize"?: number;
+ /**
+ * Hint text provided to guide users on the expected format or type of files.
+ */
+ "hint"?: string;
+ /**
+ * Label for the file input, displayed above the input.
+ */
+ "label"?: string;
+ /**
+ * Name attribute for the file input element, used to identify the form data in the submission.
+ */
+ "name"?: string;
+ /**
+ * If true, the file input is marked as required, and users must select a file.
+ */
+ "required"?: boolean;
+ }
interface VaHeaderMinimal {
/**
* Disables use of heading tags in the minimal header in favor of `` tags. This is for when a heading level 1 needs to be used elsewhere, as there should only be one heading level 1 per page.
@@ -1558,6 +1596,10 @@ export interface VaFileInputCustomEvent
extends CustomEvent {
detail: T;
target: HTMLVaFileInputElement;
}
+export interface VaFileInputMultipleCustomEvent extends CustomEvent {
+ detail: T;
+ target: HTMLVaFileInputMultipleElement;
+}
export interface VaLinkCustomEvent extends CustomEvent {
detail: T;
target: HTMLVaLinkElement;
@@ -1753,6 +1795,12 @@ declare global {
prototype: HTMLVaFileInputElement;
new (): HTMLVaFileInputElement;
};
+ interface HTMLVaFileInputMultipleElement extends Components.VaFileInputMultiple, HTMLStencilElement {
+ }
+ var HTMLVaFileInputMultipleElement: {
+ prototype: HTMLVaFileInputMultipleElement;
+ new (): HTMLVaFileInputMultipleElement;
+ };
interface HTMLVaHeaderMinimalElement extends Components.VaHeaderMinimal, HTMLStencilElement {
}
var HTMLVaHeaderMinimalElement: {
@@ -1975,6 +2023,7 @@ declare global {
"va-crisis-line-modal": HTMLVaCrisisLineModalElement;
"va-date": HTMLVaDateElement;
"va-file-input": HTMLVaFileInputElement;
+ "va-file-input-multiple": HTMLVaFileInputMultipleElement;
"va-header-minimal": HTMLVaHeaderMinimalElement;
"va-icon": HTMLVaIconElement;
"va-link": HTMLVaLinkElement;
@@ -2533,6 +2582,10 @@ declare namespace LocalJSX {
* Optionally specifies the size of the header element to use instead of the base label. Accepts a number from 1 to 6, corresponding to HTML header elements h1 through h6. If not provided, defaults to standard label styling.
*/
"headerSize"?: number;
+ /**
+ * DST only prop removes extraneous display for multiple file input
+ */
+ "headless"?: boolean;
/**
* Optional hint text.
*/
@@ -2562,6 +2615,44 @@ declare namespace LocalJSX {
*/
"uswds"?: boolean;
}
+ interface VaFileInputMultiple {
+ /**
+ * Defines acceptable file types the user can select; uses file type or extensions.
+ */
+ "accept"?: string;
+ /**
+ * If enabled, emits custom analytics events when file changes occur.
+ */
+ "enableAnalytics"?: boolean;
+ /**
+ * Array of error messages corresponding to each file input. The length and order match the files array.
+ */
+ "errors"?: string[];
+ /**
+ * Specifies the header size of the label element, from 1 (largest) to 6 (smallest).
+ */
+ "headerSize"?: number;
+ /**
+ * Hint text provided to guide users on the expected format or type of files.
+ */
+ "hint"?: string;
+ /**
+ * Label for the file input, displayed above the input.
+ */
+ "label"?: string;
+ /**
+ * Name attribute for the file input element, used to identify the form data in the submission.
+ */
+ "name"?: string;
+ /**
+ * Event emitted when any change to the file inputs occurs.
+ */
+ "onVaMultipleChange"?: (event: VaFileInputMultipleCustomEvent) => void;
+ /**
+ * If true, the file input is marked as required, and users must select a file.
+ */
+ "required"?: boolean;
+ }
interface VaHeaderMinimal {
/**
* Disables use of heading tags in the minimal header in favor of `` tags. This is for when a heading level 1 needs to be used elsewhere, as there should only be one heading level 1 per page.
@@ -3769,6 +3860,7 @@ declare namespace LocalJSX {
"va-crisis-line-modal": VaCrisisLineModal;
"va-date": VaDate;
"va-file-input": VaFileInput;
+ "va-file-input-multiple": VaFileInputMultiple;
"va-header-minimal": VaHeaderMinimal;
"va-icon": VaIcon;
"va-link": VaLink;
@@ -3826,6 +3918,7 @@ declare module "@stencil/core" {
"va-crisis-line-modal": LocalJSX.VaCrisisLineModal & JSXBase.HTMLAttributes
;
"va-date": LocalJSX.VaDate & JSXBase.HTMLAttributes;
"va-file-input": LocalJSX.VaFileInput & JSXBase.HTMLAttributes;
+ "va-file-input-multiple": LocalJSX.VaFileInputMultiple & JSXBase.HTMLAttributes;
"va-header-minimal": LocalJSX.VaHeaderMinimal & JSXBase.HTMLAttributes;
"va-icon": LocalJSX.VaIcon & JSXBase.HTMLAttributes;
"va-link": LocalJSX.VaLink & JSXBase.HTMLAttributes;
diff --git a/packages/web-components/src/components/va-file-input-multiple/FileIndex.ts b/packages/web-components/src/components/va-file-input-multiple/FileIndex.ts
new file mode 100644
index 000000000..d7a7f7a47
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input-multiple/FileIndex.ts
@@ -0,0 +1,16 @@
+/**
+ * Represents an index entry for managing file objects within a collection.
+ * Each entry contains a file and a unique key to identify it.
+ * This interface is used to track files in va-file-input-multiple,
+ * allowing for operations like addition, deletion, and updates of files.
+ *
+ * @interface
+ * @property {File | null} file - The file object managed by this entry; can be null if no file is currently selected.
+ * @property {number} key - A unique identifier for the file entry, used to manage updates and track the file's position in a collection.
+ * @property {Node[]} content - An array of DOM nodes representing additional content associated with the file, such as labels, hints, or custom controls.
+ */
+export interface FileIndex {
+ file: File | null;
+ key: number;
+ content: Node[];
+}
diff --git a/packages/web-components/src/components/va-file-input-multiple/test/1x1.png b/packages/web-components/src/components/va-file-input-multiple/test/1x1.png
new file mode 100644
index 000000000..1914264c0
Binary files /dev/null and b/packages/web-components/src/components/va-file-input-multiple/test/1x1.png differ
diff --git a/packages/web-components/src/components/va-file-input-multiple/test/va-file-input-multiple.e2e.ts b/packages/web-components/src/components/va-file-input-multiple/test/va-file-input-multiple.e2e.ts
new file mode 100644
index 000000000..58f925069
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input-multiple/test/va-file-input-multiple.e2e.ts
@@ -0,0 +1,88 @@
+import { newE2EPage } from '@stencil/core/testing';
+import { axeCheck } from '../../../testing/test-helpers';
+const path = require('path');
+
+describe('va-file-input-multiple', () => {
+
+ it('renders', async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ ' ',
+ );
+
+ const multiElement = await page.find('va-file-input-multiple');
+ expect(multiElement).not.toBeNull();
+
+ const singleElement = await page.find('va-file-input-multiple >>> va-file-input');
+ expect(singleElement).not.toBeNull();
+ });
+
+ it('renders hint text', async () => {
+ const page = await newE2EPage();
+ await page.setContent(' ');
+
+ // Render the hint text
+ const hintTextElement = await page.find('va-file-input-multiple >>> div.usa-hint');
+ expect(hintTextElement.innerText).toContain('This is hint text');
+ });
+
+ it('renders a required span', async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ ` `,
+ );
+
+ const requiredSpan = await page.find(
+ 'va-file-input-multiple >>> .required',
+ );
+ expect(requiredSpan).not.toBeNull();
+ });
+
+ it('the `accept` attribute exists if set', async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ ` `,
+ );
+
+ const fileInputComponent = await page.find('va-file-input-multiple >>> va-file-input');
+ const fileInput = fileInputComponent.shadowRoot.querySelector('input');
+ expect(fileInput.getAttribute('accept')).toBeTruthy();
+ });
+
+ it('the `accept` attribute does not apply if omitted', async () => {
+ const page = await newE2EPage();
+ await page.setContent(` `);
+
+ const fileInputComponent = await page.find('va-file-input-multiple >>> va-file-input');
+ const fileInput = fileInputComponent.shadowRoot.querySelector('input');
+ expect(fileInput.getAttribute('accept')).toBeFalsy();
+ });
+
+
+ it('emits the vaMultipleChange event only once', async () => {
+ const page = await newE2EPage();
+ await page.setContent(` `);
+
+ const fileUploadSpy = await page.spyOnEvent('vaMultipleChange');
+ const filePath = path.relative(process.cwd(), __dirname + '/1x1.png');
+
+ const input = await page.$('pierce/#fileInputField');
+ expect(input).not.toBeNull();
+
+ await input
+ .uploadFile(filePath)
+ .catch(e => console.log('uploadFile error', e));
+
+ expect(fileUploadSpy).toHaveReceivedEventTimes(1);
+ });
+
+ it.skip('passes an aXe check', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(
+ ' ',
+ );
+
+ await axeCheck(page);
+ });
+});
diff --git a/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.scss b/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.scss
new file mode 100644
index 000000000..6b9ed2de7
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.scss
@@ -0,0 +1,38 @@
+@forward 'settings';
+
+@use '../va-file-input/va-file-input';
+@import '../../mixins/uswds-error-border.scss';
+
+:host {
+ &.has-error {
+ border-left: 0.25rem solid $vads-color-secondary-dark;
+ padding-left: 1rem;
+ }
+
+ .outer-wrap {
+ background-color: var(--vads-color-primary-lighter);
+ border: 1px solid var(--vads-color-base-light);
+ display: block;
+ max-width: 30rem;
+ width: 100%;
+ position: relative;
+ margin: 8px 0;
+ }
+
+ .outer-wrap va-file-input:last-child {
+ padding: 0 8px;
+ }
+
+ va-file-input:not(:last-child) {
+ border-bottom: 1px solid var(--vads-color-white);
+ }
+
+ va-file-input.has-error {
+ border-left: none;
+ padding-left: 0;
+
+ @media screen and (min-width: 1008px) {
+ margin-left: 0;
+ }
+ }
+}
diff --git a/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.tsx b/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.tsx
new file mode 100644
index 000000000..cad9d0733
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input-multiple/va-file-input-multiple.tsx
@@ -0,0 +1,280 @@
+import {
+ Component,
+ Prop,
+ State,
+ Element,
+ h,
+ Host,
+ Event,
+ EventEmitter,
+} from '@stencil/core';
+import i18next from 'i18next';
+import { FileIndex } from "./FileIndex";
+
+/**
+ * A component that manages multiple file inputs, allowing users to upload several files.
+ * It supports adding, changing, and removing files with dynamic error handling.
+ *
+ * @componentName File input multiple
+ * @maturityCategory caution
+ * @maturityLevel available
+ * @guidanceHref form/file-input-multiple
+ */
+@Component({
+ tag: 'va-file-input-multiple',
+ styleUrl: 'va-file-input-multiple.scss',
+ shadow: true,
+})
+export class VaFileInputMultiple {
+ @Element() el: HTMLElement;
+
+ /**
+ * Label for the file input, displayed above the input.
+ */
+ @Prop() label?: string;
+
+ /**
+ * Name attribute for the file input element, used to identify the form data in the submission.
+ */
+ @Prop() name?: string;
+
+ /**
+ * If true, the file input is marked as required, and users must select a file.
+ */
+ @Prop() required?: boolean = false;
+
+ /**
+ * Defines acceptable file types the user can select; uses file type or extensions.
+ */
+ @Prop() accept?: string;
+
+ /**
+ * Array of error messages corresponding to each file input. The length and order match the files array.
+ */
+ @Prop() errors: string[] = [];
+
+ /**
+ * Hint text provided to guide users on the expected format or type of files.
+ */
+ @Prop() hint?: string;
+
+ /**
+ * If enabled, emits custom analytics events when file changes occur.
+ */
+ @Prop() enableAnalytics?: boolean = false;
+
+ /**
+ * Specifies the header size of the label element, from 1 (largest) to 6 (smallest).
+ */
+ @Prop() headerSize?: number;
+
+ /**
+ * Event emitted when any change to the file inputs occurs.
+ */
+ @Event() vaMultipleChange: EventEmitter;
+
+ /**
+ * Internal state to track files and their unique keys.
+ */
+ @State() files: FileIndex[] = [{ key: 0, file: null , content: null}];
+
+ /**
+ * Counter to assign unique keys to new file inputs.
+ */
+ private fileKeyCounter: number = 0;
+ private additionalSlot = null;
+
+ /**
+ * Finds a file entry by its unique key.
+ * @param {number} fileKey - The unique key of the file.
+ * @returns {FileIndex | undefined} The matching file index object or undefined if not found.
+ */
+ private findFileByKey(fileKey: number) {
+ return this.files.find(file => file.key === fileKey);
+ }
+
+ /**
+ * Checks if the first file input is empty.
+ * @returns {boolean} True if the first file input has no file, false otherwise.
+ */
+ private isEmpty(): boolean {
+ return this.files[0].file === null;
+ }
+
+ /**
+ * Sets the content for the slots by finding the first 'slot' within the shadow DOM of this component.
+ * If there is no additionalSlot set, it fetches the assigned elements to this slot, ensuring that content
+ * is managed only if the slot exists. This prevents the default slot content from rendering.
+ */
+ private setSlotContent() {
+ const slot = this.el.shadowRoot.querySelector('slot');
+ if (!this.additionalSlot) {
+ this.additionalSlot = slot
+ ? slot.assignedElements({ flatten: true })
+ : [];
+ }
+ slot?.remove();
+ }
+
+ /**
+ * Retrieves cloned nodes of the additional content that was originally assigned to the slot.
+ * This allows for independent manipulation and reuse of the content in multiple instances
+ * without altering the original nodes.
+ *
+ * @returns {Node[]} An array of cloned nodes from the additionalSlot.
+ */
+ private getAdditionalContent() {
+ return this.additionalSlot.map(n => n.cloneNode(true));
+ }
+
+ /**
+ * Handles file input changes by updating, adding, or removing files based on user interaction.
+ * @param {any} event - The event object containing file details.
+ * @param {number} fileKey - The key of the file being changed.
+ * @param {number} pageIndex - The index of the file in the files array.
+ */
+ private handleChange(event: any, fileKey: number, pageIndex: number) {
+ const newFile = event.detail.files[0];
+
+ if (newFile) {
+ const fileObject = this.findFileByKey(fileKey);
+
+ if (fileObject.file) {
+ // Change file
+ fileObject.file = newFile;
+ } else {
+ // New file
+ fileObject.file = newFile;
+ fileObject.content = this.getAdditionalContent();
+ this.fileKeyCounter++;
+ this.files.push({
+ file: null,
+ key: this.fileKeyCounter,
+ content: null,
+ });
+ }
+ } else {
+ // Deleted file
+ this.files.splice(pageIndex, 1);
+ }
+
+ this.vaMultipleChange.emit({ files: this.files.map(fileObj => fileObj.file) });
+ this.files = Array.of(...this.files);
+ }
+
+ /**
+ * Renders the label or header based on the provided configuration.
+ * @param {string} label - The text of the label.
+ * @param {boolean} required - Whether the input is required.
+ * @param {number} headerSize - The size of the header element.
+ * @returns {JSX.Element} A JSX element representing the label or header.
+ */
+ private renderLabelOrHeader = (
+ label: string,
+ required: boolean,
+ headerSize?: number,
+ ) => {
+ const requiredSpan = required ? (
+ {i18next.t('required')}
+ ) : null;
+ if (headerSize && headerSize >= 1 && headerSize <= 6) {
+ const HeaderTag = `h${headerSize}` as keyof JSX.IntrinsicElements;
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ };
+
+ /**
+ * It first ensures that the slot content is correctly set up, then iterates over each file input in the component,
+ * appending cloned additional content where applicable. This method ensures that additional content is
+ * consistently rendered across multiple file inputs after updates to the DOM.
+ */
+ componentDidRender() {
+ const theFileInputs = this.el.shadowRoot.querySelectorAll(`va-file-input`);
+ this.setSlotContent();
+ theFileInputs.forEach((fileEntry, index) => {
+ if (this.files[index].content) {
+ this.files[index].content.forEach(node => fileEntry.append(node));
+ }
+ });
+ }
+
+ /**
+ * Checks if there are any errors in the errors array.
+ * @returns {boolean} True if there are errors, false otherwise.
+ */
+ private hasErrors = () => {
+ return this.errors.some(error => !!error);
+ }
+
+ /**
+ * The render method to display the component structure.
+ * @returns {JSX.Element} The rendered component.
+ */
+ render() {
+ const {
+ label,
+ required,
+ headerSize,
+ hint,
+ files,
+ name,
+ accept,
+ errors,
+ enableAnalytics,
+ } = this;
+ const outerWrapClass = this.isEmpty() ? '' : 'outer-wrap';
+ const hasError = this.hasErrors() ? 'has-error': '';
+
+ return (
+
+ {label && this.renderLabelOrHeader(label, required, headerSize)}
+ {hint && (
+
+ {hint}
+
+ )}
+
+ {!this.isEmpty() && (
+
Selected files
+ )}
+ {files.map((fileEntry, pageIndex) => {
+ return (
+
+ this.handleChange(event, fileEntry.key, pageIndex)
+ }
+ enable-analytics={enableAnalytics}
+ />
+ );
+ })}
+
+
+
+ );
+ }
+}
diff --git a/packages/web-components/src/components/va-file-input/test/va-file-input.e2e.ts b/packages/web-components/src/components/va-file-input/test/va-file-input.e2e.ts
index 39a4d7e4e..ad8b62266 100644
--- a/packages/web-components/src/components/va-file-input/test/va-file-input.e2e.ts
+++ b/packages/web-components/src/components/va-file-input/test/va-file-input.e2e.ts
@@ -6,12 +6,12 @@ describe('va-file-input', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent(
- ' ',
+ ' ',
);
const element = await page.find('va-file-input');
expect(element).toEqualHtml(`
-
+
This is the file upload label
@@ -60,25 +60,6 @@ describe('va-file-input', () => {
expect(requiredSpan).not.toBeNull();
});
- // Not supporting multiple for now
- it.skip('the `multiple` attributes exists if set', async () => {
- const page = await newE2EPage();
- await page.setContent(
- ` `,
- );
-
- const fileInput = await page.find('va-file-input >>> input');
- expect(fileInput.getAttribute('multiple')).toBeTruthy();
- });
-
- it('the `multiple` attributes does not apply if omitted', async () => {
- const page = await newE2EPage();
- await page.setContent(` `);
-
- const fileInput = await page.find('va-file-input >>> input');
- expect(fileInput.getAttribute('multiple')).toBeFalsy();
- });
-
it('the `accept` attribute exists if set', async () => {
const page = await newE2EPage();
await page.setContent(
@@ -132,31 +113,13 @@ describe('va-file-input', () => {
it('v3 renders', async () => {
const page = await newE2EPage();
await page.setContent(
- ' ',
+ ' ',
);
const element = await page.find('va-file-input');
expect(element).not.toBeNull();
});
- it('v3 displays an error message when `error` is defined', async () => {
- const page = await newE2EPage();
- await page.setContent(
- ` `,
- );
-
- const errorSpan = await page.find('va-file-input >>> #input-error-message');
- expect(errorSpan.innerText.includes('This is an error')).toBe(true);
- });
-
- it('v3 no error message when `error` is not defined', async () => {
- const page = await newE2EPage();
- await page.setContent(` `);
-
- const errorSpan = await page.find('va-file-input >>> .usa-error-message');
- expect(errorSpan).toBeUndefined;
- });
-
it('v3 renders hint text', async () => {
const page = await newE2EPage();
await page.setContent(' ');
@@ -178,25 +141,6 @@ describe('va-file-input', () => {
expect(requiredSpan).not.toBeNull();
});
- // Skipping temporarily while we are not supporting the multiple file upload option
- it.skip('v3 the `multiple` attributes exists if set', async () => {
- const page = await newE2EPage();
- await page.setContent(
- ` `,
- );
-
- const fileInput = await page.find('va-file-input >>> input');
- expect(fileInput.getAttribute('multiple')).toBeTruthy();
- });
-
- it('v3 the `multiple` attributes does not apply if omitted', async () => {
- const page = await newE2EPage();
- await page.setContent(` `);
-
- const fileInput = await page.find('va-file-input >>> input');
- expect(fileInput.getAttribute('multiple')).toBeFalsy();
- });
-
it('v3 the `accept` attribute exists if set', async () => {
const page = await newE2EPage();
await page.setContent(
@@ -207,7 +151,7 @@ describe('va-file-input', () => {
expect(fileInput.getAttribute('accept')).toBeTruthy();
});
- it('the `accept` attribute does not apply if omitted', async () => {
+ it('v3 the `accept` attribute does not apply if omitted', async () => {
const page = await newE2EPage();
await page.setContent(` `);
@@ -215,20 +159,14 @@ describe('va-file-input', () => {
expect(fileInput.getAttribute('accept')).toBeFalsy();
});
- // Skipping due to test flakiness, but this event does work in the browser
- it.skip('v3 emits the vaChange event only once', async () => {
+ it('v3 emits the vaChange event only once', async () => {
const page = await newE2EPage();
await page.setContent(` `);
const fileUploadSpy = await page.spyOnEvent('vaChange');
const filePath = path.relative(process.cwd(), __dirname + '/1x1.png');
- const instructions = await page.find(
- 'va-file-input >>> .usa-file-input__instructions',
- );
-
- expect(instructions).not.toBeNull();
- const input = await page.$('pierce/.usa-file-input__input');
+ const input = await page.$('pierce/#fileInputField');
expect(input).not.toBeNull();
await input
@@ -294,10 +232,10 @@ describe('va-file-input', () => {
//modal now closed
expect(modalCheck2).toBeNull();
-
+
// get buttons again
const [__, deleteButton2] = await page.findAll('va-file-input >>> va-button-icon');
-
+
// file not deleted because delete option still here
expect(deleteButton2).not.toBeNull();
});
diff --git a/packages/web-components/src/components/va-file-input/va-file-input.scss b/packages/web-components/src/components/va-file-input/va-file-input.scss
index b8542f7bc..ac8d1f113 100644
--- a/packages/web-components/src/components/va-file-input/va-file-input.scss
+++ b/packages/web-components/src/components/va-file-input/va-file-input.scss
@@ -45,7 +45,7 @@ va-button {
margin-left: 0.25rem;
}
-:host([uswds='true']) {
+:host(:not([uswds=false])) {
.label-header {
color: var(--vads-color-base);
@@ -152,6 +152,11 @@ va-button {
overflow: hidden;
}
+ #input-error-message {
+ padding: 0 8px;
+ width: 100%;
+ }
+
.file-size-label {
color: var(--vads-color-base-dark);
font-weight: var(--font-weight-normal);
@@ -208,4 +213,8 @@ va-button {
max-height: 40px;
height: auto;
}
+
+ .thumbnail-error {
+ color: $vads-color-error-dark;
+ }
}
diff --git a/packages/web-components/src/components/va-file-input/va-file-input.tsx b/packages/web-components/src/components/va-file-input/va-file-input.tsx
index 976b85a6c..947d20821 100644
--- a/packages/web-components/src/components/va-file-input/va-file-input.tsx
+++ b/packages/web-components/src/components/va-file-input/va-file-input.tsx
@@ -89,6 +89,12 @@ export class VaFileInput {
*/
@Prop() headerSize?: number;
+ /**
+ * DST only prop
+ * removes extraneous display for multiple file input
+ */
+ @Prop() headless?: boolean = false;
+
/**
* The event emitted when the file input value changes.
*/
@@ -127,7 +133,7 @@ export class VaFileInput {
if (this.accept) {
const normalizedAcceptTypes = this.normalizeAcceptProp(this.accept);
if (!this.isAcceptedFileType(file.type, normalizedAcceptTypes)) {
- this.removeFile();
+ this.removeFile(false);
this.internalError = 'This is not a valid file type.';
return;
}
@@ -154,12 +160,14 @@ export class VaFileInput {
this.el.shadowRoot.getElementById('fileInputField').click();
};
- private removeFile = () => {
+ private removeFile = (notifyParent: boolean = true) => {
this.closeModal();
this.file = null;
- this.vaChange.emit({ files: [this.file] });
this.uploadStatus = 'idle';
this.internalError = null;
+ if (notifyParent) {
+ this.vaChange.emit({ files: [this.file] });
+ }
};
private openModal = () => {
@@ -315,6 +323,7 @@ export class VaFileInput {
headerSize,
fileContents,
fileType,
+ headless
} = this;
const text = this.getButtonText();
@@ -342,7 +351,14 @@ export class VaFileInput {
);
- if (fileContents) {
+ if (error) {
+ fileThumbnail = (
+
+
+
+ );
+ }
+ else if (fileContents) {
if (fileType.startsWith('image/')) {
fileThumbnail = (
@@ -361,6 +377,7 @@ export class VaFileInput {
);
}
}
+ let selectedFileClassName = headless ? "headless-selected-files-wrapper" : "selected-files-wrapper"
return (
@@ -370,14 +387,6 @@ export class VaFileInput {
{hint}
)}
-
- {displayError && (
-
- {i18next.t('error')}
- {displayError}
-
- )}
-