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 = ( +
+ + + + +
); + +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.

+
+
+
+            
+  {`const additionalInfoContent = (
+  
+ + + + +
+); + + + {additionalInfoContent} +`} +
+
+ + View validation code in our repo + +
+ + ); +}; + +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 = (
@@ -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 ( +
+ + {label} + + {requiredSpan} +
+ ); + } else { + return ( +
+ {label} + {requiredSpan} +
+ ); + } + }; + + /** + * 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(` - + @@ -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} - - )} -
)} {uploadStatus !== 'idle' && ( -
-
Selected files
+
+ {!headless && +
Selected files
+ }
{fileThumbnail}
{file.name} + + {displayError && ( + + {i18next.t('error')} + {displayError} + + )} + {this.formatFileSize(file.size)} @@ -445,7 +464,7 @@ export class VaFileInput { primaryButtonText='Yes, remove this' secondaryButtonText='No, keep this' onCloseEvent={this.closeModal} - onPrimaryButtonClick={this.removeFile} + onPrimaryButtonClick={() => this.removeFile(true)} onSecondaryButtonClick={this.closeModal} > We'll remove the uploaded document {file.name}