Repository: department-of-veterans-affairs/component-library
Pull request #1500: va-file-input: add percent upload support
Author: login: it-harrison
Labels: name: minor
description: For a minor Semantic Version change
Created: 2025-02-19T18:20:05Z | Merged: 2025-03-07T17:14:19Z

## Chromatic
<!-- This `prog-bar-file-input` is a placeholder for a CI job - it will be updated automatically -->
https://prog-bar-file-input--65a6e2ed2314f7b8f98609d8.chromatic.com

---

## Description
Closes [3864](https://github.com/orgs/department-of-veterans-affairs/projects/768/views/17?pane=issue&itemId=99582576&issue=department-of-veterans-affairs%7Cvets-design-system-documentation%7C3864)

## QA Checklist
- [ ] Component maintains 1:1 parity with design mocks
- [x] Text is consistent with what's been provided in the mocks
- [ ] Component behaves as expected across breakpoints
- [ ] Accessibility expert has signed off on code changes (if applicable. If not applicable provide reason why)
- [ ] Designer has signed off on changes (if applicable. If not applicable provide reason why)
- [ ] Tab order and focus state work as expected
- [ ] Changes have been tested against screen readers (if applicable. If not applicable provide reason why)
- [ ] New components are covered by e2e tests; updates to existing components are covered by existing test suite
- [ ] Changes have been tested in vets-website using Verdaccio (if applicable. If not applicable provide reason why)

## Screenshots

https://github.com/user-attachments/assets/2c504c39-d661-44d4-a51c-30d03a8a7031

## Acceptance criteria
- [ ] QA checklist has been completed
- [ ] Screenshots have been attached that cover desktop and mobile screens

## Definition of done
- [ ] Documentation has been updated, if applicable
- [ ] A link has been provided to the originating GitHub issue




Diff:
diff --git a/packages/storybook/stories/va-file-input-uswds.stories.jsx b/packages/storybook/stories/va-file-input-uswds.stories.jsx
index 383b59f2d2..8faee7c424 100644
--- a/packages/storybook/stories/va-file-input-uswds.stories.jsx
+++ b/packages/storybook/stories/va-file-input-uswds.stories.jsx
@@ -63,7 +63,7 @@ const Template = ({
   value,
   children,
   uploadedFile,
-  maxFileSize
+  maxFileSize,
 }) => {
   return (
     <VaFileInput
@@ -87,7 +87,6 @@ const Template = ({
 };
 
 export const Default = Template.bind(null);
-Default.args = { ...defaultArgs };
 Default.argTypes = propStructure(fileInputDocs);
 
 export const Required = Template.bind(null);
@@ -330,3 +329,25 @@ ReadOnlyWithAdditionalInputs.args = {
   readOnly: true,
   children: readOnlyAdditionalInfoContent,
 };
+
+const PercentUploadedTemplate = args => {
+  const [percent, setPercent] = useState(0);
+  function handleUpload() {
+    const intervalId = setInterval(() => {
+      setPercent(_prev => {
+        if (_prev >= 100) {
+          clearInterval(intervalId);
+          return null;
+        }
+        return _prev + (Math.random() * 4);
+      })
+    }, 100);
+  }
+
+  return(
+    <VaFileInput {...args} percentUploaded={percent} onVaChange={handleUpload} />
+  )
+}
+
+export const WithPercentUploaded = PercentUploadedTemplate.bind(null);
+WithPercentUploaded.args = { ...defaultArgs }
\ No newline at end of file
diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts
index e212786f0a..65982a4eb5 100644
--- a/packages/web-components/src/components.d.ts
+++ b/packages/web-components/src/components.d.ts
@@ -635,6 +635,10 @@ export namespace Components {
           * The name for the input element.
          */
         "name"?: string;
+        /**
+          * Percent upload completed. For use with va-progress-bar component
+         */
+        "percentUploaded"?: number;
         /**
           * Optionally displays the read-only view
          */
@@ -4058,6 +4062,10 @@ declare namespace LocalJSX {
           * The event emitted when the file input value changes.
          */
         "onVaChange"?: (event: VaFileInputCustomEvent<any>) => void;
+        /**
+          * Percent upload completed. For use with va-progress-bar component
+         */
+        "percentUploaded"?: number;
         /**
           * Optionally displays the read-only view
          */
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 d1dac6f75e..87cc21cc38 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
@@ -328,4 +328,44 @@ describe('va-file-input', () => {
     const errorMessage = await fileInfoCard.find('span.usa-error-message');
     expect(errorMessage.innerHTML).toEqual("We can't upload your file because it's too big. Files must be less than 1 B.");
   });
+
+  it('renders a progress bar if percent-uploaded prop is set', async () => {
+    const page = await newE2EPage();
+    await page.setContent(`<va-file-input percent-uploaded="12" />`);
+    const filePath = path.relative(process.cwd(), __dirname + '/1x1.png');
+
+    const input = (await page.$(
+      'pierce/#fileInputField',
+    )) as ElementHandle<HTMLInputElement>;
+
+    await input
+      .uploadFile(filePath);
+
+    await page.waitForChanges();
+
+    const progBar = await page.find('va-file-input >>> va-progress-bar');
+
+    expect(progBar).not.toBeNull();
+
+  });
+  it('resets the component if the cancel button is clicked during upload', async () => {
+    const page = await newE2EPage();
+    await page.setContent(`<va-file-input percent-uploaded="12" />`);
+    const filePath = path.relative(process.cwd(), __dirname + '/1x1.png');
+
+    const input = (await page.$(
+      'pierce/#fileInputField',
+    )) as ElementHandle<HTMLInputElement>;
+
+    await input
+      .uploadFile(filePath);
+
+    await page.waitForChanges();
+
+    const button = await page.find('va-file-input >>> va-button-icon');
+    button.click();
+    await page.waitForChanges();
+    const progBar = await page.find('va-file-input >>> va-progress-bar');
+    expect(progBar).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 b4df3b6e96..0bb8d388ea 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
@@ -194,6 +194,16 @@
   .thumbnail-error {
     color: $vads-color-error-dark;
   }
+
+  va-progress-bar {
+    font-size: var(--vads-font-size-root);
+    display: block;
+    margin-left: 16px;
+  }
+
+  .uploading-status {
+    font-style: var(--font-style-italic);
+  }
 }
 
 :host(.has-error) {
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 248c42abea..fe2fa0a86e 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
@@ -10,6 +10,7 @@ import {
   EventEmitter,
   State,
   Watch,
+  forceUpdate
 } from '@stencil/core';
 import { i18next } from '../..';
 import { fileInput } from './va-file-input-upgrader';
@@ -32,12 +33,6 @@ export class VaFileInput {
   private fileInputRef!: HTMLInputElement;
   private uploadStatus: 'idle' | 'success' = 'idle';
   private fileType?: string;
-  private defaultUploadMessage: HTMLElement = (
-    <span>
-      Drag a file here or{' '}
-      <span class="file-input-choose-text">choose from folder</span>
-    </span>
-  )
 
   @Element() el: HTMLElement;
   @State() file?: File;
@@ -46,8 +41,6 @@ export class VaFileInput {
   @State() showModal: boolean = false;
   @State() showSeparator: boolean = true;
 
-  
-
   /**
    * The label for the file input.
    */
@@ -96,7 +89,7 @@ export class VaFileInput {
   /**
    * Custom instructional message in the file input.
    */
-  @Prop() uploadMessage?: HTMLElement = this.defaultUploadMessage;
+  @Prop() uploadMessage?: HTMLElement = null;
 
   /**
    * Emit component-library-analytics events on the file input change event.
@@ -131,6 +124,11 @@ export class VaFileInput {
    */
   @Prop() maxFileSize?: number = Infinity;
 
+  /**
+   * Percent upload completed. For use with va-progress-bar component
+   */
+  @Prop({ mutable: true}) percentUploaded?: number = null;
+
   /**
    * The event emitted when the file input value changes.
    */
@@ -151,8 +149,27 @@ export class VaFileInput {
     handleValueChange(value) {
       //This won't be read if its not in a timeout due to other messages being read.
       setTimeout(() => {this.updateStatusMessage(value)});
+  }
+  
+  @Watch('percentUploaded') 
+    percentHandler(value: number) {
+      if (value >= 100) {
+        this.resetState();
+      }
     }
 
+  /**
+   * called when file has been uploaded 
+   * or file upload has been cancelled
+   * only relevant when percentUploaded specified
+   */
+  private resetState() {
+    this.fileContents = null;
+    this.uploadStatus = 'idle';
+    this.percentUploaded = null;
+    forceUpdate(this.el);
+  }
+
   private handleChange = (e: Event) => {
     const input = e.target as HTMLInputElement;
     if (input.files && input.files.length > 0) {
@@ -376,6 +393,15 @@ export class VaFileInput {
     this.el.removeEventListener('change', this.handleChange);
   }
 
+  private getDefaultUploadMessage() {
+    return (
+      <span>
+        Drag a file here or{' '}
+        <span class="file-input-choose-text">choose from folder</span>
+      </span>
+    )
+  }
+
   render() {
     const {
       label,
@@ -386,6 +412,7 @@ export class VaFileInput {
       hint,
       file,
       uploadStatus,
+      uploadMessage,
       headerSize,
       fileContents,
       fileType,
@@ -393,7 +420,8 @@ export class VaFileInput {
       value,
       readOnly,
       statusText,
-      uploadedFile
+      uploadedFile,
+      percentUploaded,
     } = this;
 
     if (value && !this.file) {
@@ -456,11 +484,14 @@ export class VaFileInput {
       : 'selected-files-wrapper';
     const hintClass = 'usa-hint' + (headless ? ' usa-sr-only' : '');
 
-    // Check if there is an upload message and set it back to the default if not.
-    if (!this.uploadMessage) {
-      this.uploadMessage = this.defaultUploadMessage;
-    }
     
+    const showProgBar = percentUploaded !== null && percentUploaded < 100;
+
+    let statusClassNames = 'file-status-label'
+    if (showProgBar) {
+      statusClassNames = `${statusClassNames} uploading-status`;
+    }
+
     return (
       <Host class={{ 'has-error': !!displayError }}>
         {!readOnly && (
@@ -504,7 +535,9 @@ export class VaFileInput {
               ></div>
               <div class={fileInputTargetClasses}>
                 <div class="file-input-box"></div>
-                <div class="file-input-instructions">{this.uploadMessage}</div>
+                <div class="file-input-instructions">
+                  {!!uploadMessage ? uploadMessage : this.getDefaultUploadMessage()}
+                </div>
               </div>
             </div>
           )}
@@ -531,11 +564,11 @@ export class VaFileInput {
                         <span aria-live="polite" class="usa-error-message">{displayError}</span>
                       </span>
                     )}
-                    <span class="file-size-label">
+                    {!showProgBar && <span class="file-size-label">
                       {this.formatFileSize(file ? file.size : uploadedFile.size)}
-                    </span>
-                      <span class="file-status-label" aria-live="polite">
-                        {statusText}
+                    </span>}
+                      <span class={statusClassNames} aria-live="polite">
+                        {showProgBar ? 'Uploading...' : statusText}
                       </span>
                   </div>
                 </div>
@@ -546,37 +579,42 @@ export class VaFileInput {
                     <div class="additional-info-slot">
                       <slot></slot>
                     </div>
-
-                    {!readOnly && (
-                      <Fragment>
-                        <div class="file-button-section">
-                          <va-button-icon
-                            buttonType="change-file"
-                            onClick={this.changeFile}
-                            label="Change file"
-                            aria-label={`change file ${file ? file.name : uploadedFile.name}`}
-                          ></va-button-icon>
-                          <va-button-icon
-                            buttonType="delete"
-                            onClick={this.openModal}
-                            aria-label={`delete file ${file ? file.name : uploadedFile.name}`}
-                            label="Delete"
-                          ></va-button-icon>
-                        </div>
-                        <va-modal
-                          modalTitle="Delete this file?"
-                          visible={this.showModal}
-                          primaryButtonText="Yes, remove this"
-                          secondaryButtonText="No, keep this"
-                          onCloseEvent={this.closeModal}
-                          onPrimaryButtonClick={() => this.removeFile(true)}
-                          onSecondaryButtonClick={this.closeModal}
-                        >
-                          We'll remove the uploaded document{' '}
-                          <span class="file-label">{file ? file.name : uploadedFile.name}</span>
-                        </va-modal>
-                      </Fragment>
-                    )}
+                    {!readOnly ?
+                      (showProgBar
+                        ? <Fragment>
+                            <va-progress-bar percent={percentUploaded} />
+                            <va-button-icon buttonType="cancel" onClick={this.resetState.bind(this)} />
+                          </Fragment>
+                        : <Fragment>
+                          <div class="file-button-section">
+                            <va-button-icon
+                              buttonType="change-file"
+                              onClick={this.changeFile}
+                              label="Change file"
+                              aria-label={`change file ${file ? file.name : uploadedFile.name}`}
+                            ></va-button-icon>
+                            <va-button-icon
+                              buttonType="delete"
+                              onClick={this.openModal}
+                              aria-label={`delete file ${file ? file.name : uploadedFile.name}`}
+                              label="Delete"
+                            ></va-button-icon>
+                          </div>
+                          <va-modal
+                            modalTitle="Delete this file?"
+                            visible={this.showModal}
+                            primaryButtonText="Yes, remove this"
+                            secondaryButtonText="No, keep this"
+                            onCloseEvent={this.closeModal}
+                            onPrimaryButtonClick={() => this.removeFile(true)}
+                            onSecondaryButtonClick={this.closeModal}
+                          >
+                            We'll remove the uploaded document{' '}
+                            <span class="file-label">{file ? file.name : uploadedFile.name}</span>
+                          </va-modal>
+                        </Fragment>
+                      )
+                      : null}
                   </div>
                 )}
               </va-card>


Reviews:
user: login: it-harrison
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T22:10:00Z
user: login: danbrady
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:44:42Z
user: login: jamigibbs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T21:36:53Z
user: login: danbrady
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T21:43:53Z
user: login: it-harrison
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T22:48:03Z
user: login: danbrady
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T14:11:49Z

Review comments:
diff_hunk: @@ -32,12 +33,6 @@ export class VaFileInput {
   private fileInputRef!: HTMLInputElement;
   private uploadStatus: 'idle' | 'success' = 'idle';
   private fileType?: string;
-  private defaultUploadMessage: HTMLElement = (
-    <span>
-      Drag a file here or{' '}
-      <span class="file-input-choose-text">choose from folder</span>
-    </span>
-  )
path: packages/web-components/src/components/va-file-input/va-file-input.tsx
user: login: it-harrison
body: moved this variable into a function because when calling `forceUpdate` after reaching the end of upload Stencil was throwing errors relating to caching the JSX. 
created_at: 2025-03-04T22:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,6 +194,16 @@
   .thumbnail-error {
     color: $vads-color-error-dark;
   }
+
+  va-progress-bar {
+    font-size: var(--v3-font-base-size);
path: packages/web-components/src/components/va-file-input/va-file-input.scss
user: login: jamigibbs
body: I realize this is the only token we have right now that's for `16px` but it's such an awkward name. We created it temporarily when we were updating the base font sizes (before we officially started semantic naming). We should probably remove it from the CSS-Library now.

Are we sure this shouldn't be the `--vads-font-size-source-sans-normalized: 1.06rem;` token? This would make it `16.96px` though. There is also `--vads-font-size-root: 1rem;` (which should calculate to 16px). 🤔 

https://github.com/department-of-veterans-affairs/component-library/blob/main/packages/css-library/dist/tokens/css/variables.css#L151
created_at: 2025-03-06T21:36:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,6 +194,16 @@
   .thumbnail-error {
     color: $vads-color-error-dark;
   }
+
+  va-progress-bar {
+    font-size: var(--v3-font-base-size);
path: packages/web-components/src/components/va-file-input/va-file-input.scss
user: login: danbrady
body: Ooh, good catch! I think all text should be normalized, so the value of this style should render as 16.96px. However, what text is this `font-size` property styling? I see it's on the `va-progress-bar` selector which is progress bar itself and has no visual text within it, no? Maybe this can be removed completely?
created_at: 2025-03-06T21:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,6 +194,16 @@
   .thumbnail-error {
     color: $vads-color-error-dark;
   }
+
+  va-progress-bar {
+    font-size: var(--v3-font-base-size);
path: packages/web-components/src/components/va-file-input/va-file-input.scss
user: login: it-harrison
body: @danbrady @jamigibbs the `font-size` controls the height of the progress bar because it is in `em` units...and I think we want it to be 16px? So maybe use `--vads-font-size-root` ?
created_at: 2025-03-06T22:48:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,6 +194,16 @@
   .thumbnail-error {
     color: $vads-color-error-dark;
   }
+
+  va-progress-bar {
+    font-size: var(--v3-font-base-size);
path: packages/web-components/src/components/va-file-input/va-file-input.scss
user: login: danbrady
body: Oh, ok! Yeah, let's keep it 16px then. Our designs aim to follow multiples of 8s for the most part. So that falls in line. 
created_at: 2025-03-07T14:11:49Z
author_association: CONTRIBUTOR

PR comments:
user: login: it-harrison
created_at: 2025-03-04T22:36:41Z
body: @danbrady design question: I am using the `va-button-icon` for the "cancel" upload button...but `va-button-icon` doesn't work with the design because it appears to have extra margin on the left unless you hover. To match the design we would have to make an imposter component. 
<img width="535" alt="Screenshot 2025-03-04 at 4 31 07 PM" src="https://github.com/user-attachments/assets/f52ff720-e526-4522-8887-51a5e008aeb3" />

author_association: CONTRIBUTOR
user: login: danbrady
created_at: 2025-03-05T15:20:08Z
body: @it-harrison Yeah, that's a bit of a quirk with the icon buttons. They have the same padding as our other buttons, but because they don't have a background in the default state they can look misaligned. However, it may be a bit hard to see but we compensated for that in the designs by reducing the left/right margin for the container of the buttons (or alternatively you could increase the left margin for the other elements):
<img width="539" alt="image" src="https://github.com/user-attachments/assets/4d0aebc2-037d-438a-936c-dc3aa26fa6d0" />

BTW, the uploading progress looks great! When we did the designs, we weren't sure if the file weight would be known until the file was fully uploaded. So instead of showing an empty file weight, we used an italicized "Uploading..." label. I sort of like showing that label to describe what the progress bar is doing. (Also, I wonder if there is an accessibility reason to show it as well?) 

<img width="466" alt="image" src="https://github.com/user-attachments/assets/0cd4dcab-fd15-41a2-9a79-99539519d58a" />
(Also showing the button container in this screenshot.)

author_association: CONTRIBUTOR
user: login: it-harrison
created_at: 2025-03-06T20:29:21Z
body: @danbrady after an update we have: 

https://github.com/user-attachments/assets/60d40a81-97c9-45d3-8955-a3cb6e54e0a7


author_association: CONTRIBUTOR
user: login: danbrady
created_at: 2025-03-06T20:44:27Z
body: LGTM! Design approved! Nice work!
author_association: CONTRIBUTOR