diff --git a/packages/storybook/stories/va-file-input-uswds.stories.jsx b/packages/storybook/stories/va-file-input-uswds.stories.jsx
new file mode 100644
index 000000000..d770c77dd
--- /dev/null
+++ b/packages/storybook/stories/va-file-input-uswds.stories.jsx
@@ -0,0 +1,101 @@
+/* eslint-disable react/prop-types */
+import React from 'react';
+import { VaFileInput } from '@department-of-veterans-affairs/web-components/react-bindings';
+import { getWebComponentDocs, propStructure, StoryDocs } from './wc-helpers';
+
+const fileInputDocs = getWebComponentDocs('va-file-input');
+
+export default {
+ title: 'USWDS/File input USWDS',
+ id: 'uswds/va-file-input',
+ parameters: {
+ componentSubtitle: `va-file-input web component`,
+ docs: {
+ page: () => ,
+ },
+ },
+};
+
+const defaultArgs = {
+ 'label': 'Input accepts a single file',
+ 'name': 'my-file-input',
+ 'accept': null,
+ 'required': false,
+ 'error': '',
+ 'enable-analytics': false,
+ 'hint': null,
+ 'multiple': false,
+ 'uswds': true,
+ 'vaChange': event =>
+ alert(`File change event received: ${event?.detail?.files[0]?.name}`),
+};
+
+const Template = ({
+ label,
+ name,
+ accept,
+ error,
+ required,
+ hint,
+ multiple,
+ 'enable-analytics': enableAnalytics,
+ uswds,
+ vaChange,
+}) => {
+ return (
+
+ );
+};
+
+export const Default = Template.bind(null);
+Default.args = { ...defaultArgs };
+Default.argTypes = propStructure(fileInputDocs);
+
+export const Required = Template.bind(null);
+Required.args = { ...defaultArgs, required: true };
+
+export const AcceptsOnlySpecificFileTypes = Template.bind(null);
+AcceptsOnlySpecificFileTypes.args = {
+ ...defaultArgs,
+ label: 'Input accepts only specific file types',
+ hint: 'Select PDF or TXT files',
+ accept: '.pdf,.txt',
+};
+
+export const AcceptsAnyKindOfImage = Template.bind(null);
+AcceptsAnyKindOfImage.args = {
+ ...defaultArgs,
+ label: 'Input accepts any kind of image',
+ hint: 'Select any type of image format',
+ accept: 'image/*',
+};
+
+export const AcceptsMultipleFiles = Template.bind(null);
+AcceptsMultipleFiles.args = {
+ ...defaultArgs,
+ label: 'Input accepts multiple files',
+ hint: 'Select one or more files',
+ multiple: true,
+};
+
+export const ErrorMessage = Template.bind(null);
+ErrorMessage.args = {
+ ...defaultArgs,
+ label: 'Input has an error',
+ hint: 'Select any valid file',
+ error: 'Display a helpful error message',
+};
+
+export const WithAnalytics = Template.bind(null);
+WithAnalytics.args = { ...defaultArgs, 'enable-analytics': true };
diff --git a/packages/web-components/package.json b/packages/web-components/package.json
index 338137698..84b163bbc 100644
--- a/packages/web-components/package.json
+++ b/packages/web-components/package.json
@@ -66,4 +66,4 @@
"i18next": "*",
"i18next-browser-languagedetector": "*"
}
-}
\ 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 7c8d3bb9a..f4691622d 100644
--- a/packages/web-components/src/components.d.ts
+++ b/packages/web-components/src/components.d.ts
@@ -434,6 +434,10 @@ export namespace Components {
* The label for the file input.
*/
"label"?: string;
+ /**
+ * Optionally allow multiple files (USWDS Only)
+ */
+ "multiple"?: boolean;
/**
* The name for the input element.
*/
@@ -442,6 +446,10 @@ export namespace Components {
* Sets the input to required and renders the (*Required) text.
*/
"required"?: boolean;
+ /**
+ * Whether or not the component will use USWDS v3 styling.
+ */
+ "uswds"?: boolean;
}
interface VaIcon {
/**
@@ -2359,6 +2367,10 @@ declare namespace LocalJSX {
* The label for the file input.
*/
"label"?: string;
+ /**
+ * Optionally allow multiple files (USWDS Only)
+ */
+ "multiple"?: boolean;
/**
* The name for the input element.
*/
@@ -2375,6 +2387,10 @@ declare namespace LocalJSX {
* Sets the input to required and renders the (*Required) text.
*/
"required"?: boolean;
+ /**
+ * Whether or not the component will use USWDS v3 styling.
+ */
+ "uswds"?: boolean;
}
interface VaIcon {
/**
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 03179c6dd..328bffe30 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
@@ -88,13 +88,15 @@ describe('va-file-input', () => {
it('emits the vaChange event only once', async () => {
const page = await newE2EPage();
- await page.setContent(``);
-
+ await page.setContent(``);
+
const fileUploadSpy = await page.spyOnEvent('vaChange');
const filePath = path.relative(process.cwd(), __dirname + '/1x1.png');
const input = (
await page.waitForFunction(() =>
- document.querySelector("va-file-input").shadowRoot.querySelector("input[type=file]")
+ document
+ .querySelector('va-file-input')
+ .shadowRoot.querySelector('input[type=file]'),
)
).asElement();
@@ -113,4 +115,124 @@ describe('va-file-input', () => {
await axeCheck(page);
});
+
+ /** USWDS v3 mode tests */
+
+ 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 >>> .usa-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('');
+
+ // Render the hint text
+ const hintTextElement = await page.find('va-file-input >>> span.usa-hint');
+ expect(hintTextElement.innerText).toContain('This is hint text');
+ });
+
+ it('v3 renders a required span', async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ ``,
+ );
+
+ const requiredSpan = await page.find(
+ 'va-file-input >>> .usa-label--required',
+ );
+ expect(requiredSpan).not.toBeNull();
+ });
+
+ it('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(
+ ``,
+ );
+
+ const fileInput = await page.find('va-file-input >>> input');
+ expect(fileInput.getAttribute('accept')).toBeTruthy;
+ });
+
+ it('the `accept` attribute 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('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 () => {
+ 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');
+ expect(input).not.toBeNull();
+
+ await input
+ .uploadFile(filePath)
+ .catch(e => console.log('uploadFile error', e));
+
+ expect(fileUploadSpy).toHaveReceivedEventTimes(1);
+ });
+
+ it('v3 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/va-file-input-upgrader.ts b/packages/web-components/src/components/va-file-input/va-file-input-upgrader.ts
new file mode 100644
index 000000000..26417b58a
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input/va-file-input-upgrader.ts
@@ -0,0 +1,609 @@
+/* eslint-disable i18next/no-literal-string */
+import { Sanitizer, selectOrMatches } from '../../utils/utils';
+
+const PREFIX = 'usa';
+const DROPZONE_CLASS = `${PREFIX}-file-input`;
+const DROPZONE = `.${DROPZONE_CLASS}`;
+const INPUT_CLASS = `${PREFIX}-file-input__input`;
+const TARGET_CLASS = `${PREFIX}-file-input__target`;
+const INPUT = `.${INPUT_CLASS}`;
+const BOX_CLASS = `${PREFIX}-file-input__box`;
+const INSTRUCTIONS_CLASS = `${PREFIX}-file-input__instructions`;
+const PREVIEW_CLASS = `${PREFIX}-file-input__preview`;
+const PREVIEW_HEADING_CLASS = `${PREFIX}-file-input__preview-heading`;
+const DISABLED_CLASS = `${PREFIX}-file-input--disabled`;
+const CHOOSE_CLASS = `${PREFIX}-file-input__choose`;
+const ACCEPTED_FILE_MESSAGE_CLASS = `${PREFIX}-file-input__accepted-files-message`;
+const DRAG_TEXT_CLASS = `${PREFIX}-file-input__drag-text`;
+const DRAG_CLASS = `${PREFIX}-file-input--drag`;
+const LOADING_CLASS = 'is-loading';
+const INVALID_FILE_CLASS = 'has-invalid-file';
+const GENERIC_PREVIEW_CLASS_NAME = `${PREFIX}-file-input__preview-image`;
+const GENERIC_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--generic`;
+const PDF_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--pdf`;
+const WORD_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--word`;
+const VIDEO_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--video`;
+const EXCEL_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--excel`;
+const SR_ONLY_CLASS = `${PREFIX}-sr-only`;
+const SPACER_GIF =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+
+let TYPE_IS_VALID = Boolean(true); // logic gate for change listener
+let DEFAULT_ARIA_LABEL_TEXT = '';
+let DEFAULT_FILE_STATUS_TEXT = '';
+
+/**
+ * The properties and elements within the file input.
+ * @typedef {Object} FileInputContext
+ * @property {HTMLDivElement} dropZoneEl
+ * @property {HTMLInputElement} inputEl
+ */
+
+/**
+ * Get an object of the properties and elements belonging directly to the given
+ * file input component.
+ *
+ * @param {HTMLElement} el the element within the file input
+ * @returns {FileInputContext} elements
+ */
+const getFileInputContext = el => {
+ const dropZoneEl = el.closest(DROPZONE);
+
+ if (!dropZoneEl) {
+ throw new Error(`Element is missing outer ${DROPZONE}`);
+ }
+
+ const inputEl = dropZoneEl.querySelector(INPUT);
+
+ return {
+ dropZoneEl,
+ inputEl,
+ };
+};
+
+/**
+ * Disable the file input component
+ *
+ * @param {HTMLElement} el An element within the file input component
+ */
+const disable = el => {
+ const { dropZoneEl, inputEl } = getFileInputContext(el);
+
+ inputEl.disabled = true;
+ dropZoneEl.classList.add(DISABLED_CLASS);
+};
+
+/**
+ * Set aria-disabled attribute to file input component
+ *
+ * @param {HTMLElement} el An element within the file input component
+ */
+const ariaDisable = el => {
+ const { dropZoneEl } = getFileInputContext(el);
+
+ dropZoneEl.classList.add(DISABLED_CLASS);
+};
+
+/**
+ * Enable the file input component
+ *
+ * @param {HTMLElement} el An element within the file input component
+ */
+const enable = el => {
+ const { dropZoneEl, inputEl } = getFileInputContext(el);
+
+ inputEl.disabled = false;
+ dropZoneEl.classList.remove(DISABLED_CLASS);
+ dropZoneEl.removeAttribute('aria-disabled');
+};
+
+/**
+ *
+ * @param {String} s special characters
+ * @returns {String} replaces specified values
+ */
+const replaceName = s => {
+ const c = s.charCodeAt(0);
+ if (c === 32) return '-';
+ if (c >= 65 && c <= 90) return `img_${s.toLowerCase()}`;
+ return `__000${c.toString(16).slice(-4)}`;
+};
+
+/**
+ * Creates an ID name for each file that strips all invalid characters.
+ * @param {String} name - name of the file added to file input (search value)
+ * @returns {String} same characters as the name with invalid chars removed (new value)
+ */
+const makeSafeForID = name => name.replace(/[^a-z0-9]/g, replaceName);
+
+// Takes a generated safe ID and creates a unique ID.
+const createUniqueID = name => `${name}-${Math.floor(Date.now() / 1000)}`;
+
+/**
+ * Determines if the singular or plural item label should be used
+ * Determination is based on the presence of the `multiple` attribute
+ *
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @returns {HTMLDivElement} The singular or plural version of "item"
+ */
+const getItemsLabel = fileInputEl => {
+ const acceptsMultiple = fileInputEl.hasAttribute('multiple');
+ const itemsLabel = acceptsMultiple ? 'files' : 'file';
+
+ return itemsLabel;
+};
+
+/**
+ * Scaffold the file input component with a parent wrapper and
+ * Create a target area overlay for drag and drop functionality
+ *
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @returns {HTMLDivElement} The drag and drop target area.
+ */
+const createTargetArea = fileInputEl => {
+ const fileInputParent = document.createElement('div');
+ const dropTarget = document.createElement('div');
+ const box = document.createElement('div');
+
+ // Adds class names and other attributes
+ fileInputEl.classList.remove(DROPZONE_CLASS);
+ fileInputEl.classList.add(INPUT_CLASS);
+ fileInputParent.classList.add(DROPZONE_CLASS);
+ box.classList.add(BOX_CLASS);
+ dropTarget.classList.add(TARGET_CLASS);
+
+ // Adds child elements to the DOM
+ dropTarget.prepend(box);
+ fileInputEl.parentNode.insertBefore(dropTarget, fileInputEl);
+ fileInputEl.parentNode.insertBefore(fileInputParent, dropTarget);
+ dropTarget.appendChild(fileInputEl);
+ fileInputParent.appendChild(dropTarget);
+
+ return dropTarget;
+};
+
+/**
+ * Build the visible element with default interaction instructions.
+ *
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @returns {HTMLDivElement} The container for visible interaction instructions.
+ */
+const createVisibleInstructions = fileInputEl => {
+ const fileInputParent = fileInputEl.closest(DROPZONE);
+ const itemsLabel = getItemsLabel(fileInputEl);
+ const instructions = document.createElement('div');
+ const dragText = `Drag ${itemsLabel} here or`;
+ const chooseText = 'choose from folder';
+
+ // Create instructions text for aria-label
+ DEFAULT_ARIA_LABEL_TEXT = `${dragText} ${chooseText}`;
+
+ // Adds class names and other attributes
+ instructions.classList.add(INSTRUCTIONS_CLASS);
+ instructions.setAttribute('aria-hidden', 'true');
+
+ // Add initial instructions for input usage
+ fileInputEl.setAttribute('aria-label', DEFAULT_ARIA_LABEL_TEXT);
+ instructions.innerHTML = Sanitizer.escapeHTML(
+ `${dragText} ${chooseText}`,
+ );
+
+ // Add the instructions element to the DOM
+ fileInputEl.parentNode.insertBefore(instructions, fileInputEl);
+
+ // IE11 and Edge do not support drop files on file inputs, so we've removed text that indicates that
+ if (
+ /rv:11.0/i.test(navigator.userAgent) ||
+ /Edge\/\d./i.test(navigator.userAgent)
+ ) {
+ fileInputParent.querySelector(`.${DRAG_TEXT_CLASS}`).outerHTML = '';
+ }
+
+ return instructions;
+};
+
+/**
+ * Build a screen reader-only message element that contains file status updates and
+ * Create and set the default file status message
+ *
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ */
+const createSROnlyStatus = fileInputEl => {
+ const statusEl = document.createElement('div');
+ const itemsLabel = getItemsLabel(fileInputEl);
+ const fileInputParent = fileInputEl.closest(DROPZONE);
+ const fileInputTarget = fileInputEl.closest(`.${TARGET_CLASS}`);
+
+ DEFAULT_FILE_STATUS_TEXT = `No ${itemsLabel} selected.`;
+
+ // Adds class names and other attributes
+ statusEl.classList.add(SR_ONLY_CLASS);
+ statusEl.setAttribute('aria-live', 'polite');
+
+ // Add initial file status message
+ statusEl.textContent = DEFAULT_FILE_STATUS_TEXT;
+
+ // Add the status element to the DOM
+ fileInputParent.insertBefore(statusEl, fileInputTarget);
+};
+
+/**
+ * Scaffold the component with all required elements
+ *
+ * @param {HTMLInputElement} fileInputEl - The original input element.
+ */
+const enhanceFileInput = fileInputEl => {
+ const isInputDisabled =
+ fileInputEl.hasAttribute('aria-disabled') ||
+ fileInputEl.hasAttribute('disabled');
+ const dropTarget = createTargetArea(fileInputEl);
+ const instructions = createVisibleInstructions(fileInputEl);
+ const { dropZoneEl } = getFileInputContext(fileInputEl);
+
+ if (isInputDisabled) {
+ dropZoneEl.classList.add(DISABLED_CLASS);
+ } else {
+ createSROnlyStatus(fileInputEl);
+ }
+
+ return { instructions, dropTarget };
+};
+
+/**
+ * Removes image previews
+ * We want to start with a clean list every time files are added to the file input
+ *
+ * @param {HTMLDivElement} dropTarget - The drag and drop target area.
+ * @param {HTMLDivElement} instructions - The container for visible interaction instructions.
+ */
+const removeOldPreviews = (dropTarget, instructions) => {
+ const filePreviews = dropTarget.querySelectorAll(`.${PREVIEW_CLASS}`);
+ const currentPreviewHeading = dropTarget.querySelector(
+ `.${PREVIEW_HEADING_CLASS}`,
+ );
+ const currentErrorMessage = dropTarget.querySelector(
+ `.${ACCEPTED_FILE_MESSAGE_CLASS}`,
+ );
+
+ /**
+ * finds the parent of the passed node and removes the child
+ * @param {HTMLElement} node
+ */
+ const removeImages = node => {
+ node.parentNode.removeChild(node);
+ };
+
+ // Remove the heading above the previews
+ if (currentPreviewHeading) {
+ currentPreviewHeading.outerHTML = '';
+ }
+
+ // Remove existing error messages
+ if (currentErrorMessage) {
+ currentErrorMessage.outerHTML = '';
+ dropTarget.classList.remove(INVALID_FILE_CLASS);
+ }
+
+ // Get rid of existing previews if they exist, show instructions
+ if (filePreviews !== null) {
+ if (instructions) {
+ instructions.removeAttribute('hidden');
+ }
+ Array.prototype.forEach.call(filePreviews, removeImages);
+ }
+};
+
+/**
+ * Update the screen reader-only status message after interaction
+ *
+ * @param {HTMLDivElement} statusElement - The screen reader-only container for file status updates.
+ * @param {Object} fileNames - The selected files found in the fileList object.
+ * @param {Array} fileStore - The array of uploaded file names created from the fileNames object.
+ */
+const updateStatusMessage = (statusElement, fileNames, fileStore) => {
+ const statusEl = statusElement;
+ let statusMessage = DEFAULT_FILE_STATUS_TEXT;
+
+ // If files added, update the status message with file name(s)
+ if (fileNames.length === 1) {
+ statusMessage = `You have selected the file: ${fileStore}`;
+ } else if (fileNames.length > 1) {
+ statusMessage = `You have selected ${
+ fileNames.length
+ } files: ${fileStore.join(', ')}`;
+ }
+
+ // Add delay to encourage screen reader readout
+ setTimeout(() => {
+ statusEl.textContent = statusMessage;
+ }, 1000);
+};
+
+/**
+ * Show the preview heading, hide the initial instructions and
+ * Update the aria-label with new instructions text
+ *
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @param {Object} fileNames - The selected files found in the fileList object.
+ */
+const addPreviewHeading = (fileInputEl, fileNames) => {
+ const filePreviewsHeading = document.createElement('div');
+ const dropTarget = fileInputEl.closest(`.${TARGET_CLASS}`);
+ const instructions = dropTarget.querySelector(`.${INSTRUCTIONS_CLASS}`);
+ let changeItemText = 'Change file';
+ let previewHeadingText = '';
+
+ if (fileNames.length === 1) {
+ previewHeadingText = Sanitizer.escapeHTML(
+ `Selected file ${changeItemText}`,
+ );
+ } else if (fileNames.length > 1) {
+ changeItemText = 'Change files';
+ previewHeadingText = Sanitizer.escapeHTML(
+ `${fileNames.length} files selected ${changeItemText}`,
+ );
+ }
+
+ // Hides null state content and sets preview heading
+ instructions.setAttribute('hidden', 'true');
+ filePreviewsHeading.classList.add(PREVIEW_HEADING_CLASS);
+ filePreviewsHeading.innerHTML = previewHeadingText;
+ dropTarget.insertBefore(filePreviewsHeading, instructions);
+
+ // Update aria label to match the visible action text
+ fileInputEl.setAttribute('aria-label', changeItemText);
+};
+
+/**
+ * When new files are applied to file input, this function generates previews
+ * and removes old ones.
+ *
+ * @param {event} e
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @param {HTMLDivElement} instructions - The container for visible interaction instructions.
+ * @param {HTMLDivElement} dropTarget - The drag and drop target area.
+ */
+
+const handleChange = (e, fileInputEl, instructions, dropTarget) => {
+ const fileNames = e.target.files;
+ const inputParent = dropTarget.closest(`.${DROPZONE_CLASS}`);
+ const statusElement = inputParent.querySelector(`.${SR_ONLY_CLASS}`);
+ const fileStore = [];
+
+ // First, get rid of existing previews
+ removeOldPreviews(dropTarget, instructions);
+
+ // Then, iterate through files list and create previews
+ for (let i = 0; i < fileNames.length; i += 1) {
+ const reader = new FileReader();
+ const fileName = fileNames[i].name;
+ let imageId;
+
+ // Push updated file names into the store array
+ fileStore.push(fileName);
+
+ // Starts with a loading image while preview is created
+ reader.onloadstart = function createLoadingImage() {
+ imageId = createUniqueID(makeSafeForID(fileName));
+
+ instructions.insertAdjacentHTML(
+ 'afterend',
+ Sanitizer.escapeHTML(`
+
${fileName}
+
`),
+ );
+ };
+
+ // Not all files will be able to generate previews. In case this happens, we provide several types "generic previews" based on the file extension.
+ reader.onloadend = function createFilePreview() {
+ const previewImage = dropTarget.querySelector(
+ `#${imageId}`,
+ ) as HTMLImageElement;
+ if (fileName.indexOf('.pdf') > 0) {
+ previewImage.setAttribute(
+ 'onerror',
+ `this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${PDF_PREVIEW_CLASS}")`,
+ );
+ } else if (
+ fileName.indexOf('.doc') > 0 ||
+ fileName.indexOf('.pages') > 0
+ ) {
+ previewImage.setAttribute(
+ 'onerror',
+ `this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${WORD_PREVIEW_CLASS}")`,
+ );
+ } else if (
+ fileName.indexOf('.xls') > 0 ||
+ fileName.indexOf('.numbers') > 0
+ ) {
+ previewImage.setAttribute(
+ 'onerror',
+ `this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${EXCEL_PREVIEW_CLASS}")`,
+ );
+ } else if (fileName.indexOf('.mov') > 0 || fileName.indexOf('.mp4') > 0) {
+ previewImage.setAttribute(
+ 'onerror',
+ `this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${VIDEO_PREVIEW_CLASS}")`,
+ );
+ } else {
+ previewImage.setAttribute(
+ 'onerror',
+ `this.onerror=null;this.src="${SPACER_GIF}"; this.classList.add("${GENERIC_PREVIEW_CLASS}")`,
+ );
+ }
+
+ // Removes loader and displays preview
+ previewImage.classList.remove(LOADING_CLASS);
+ previewImage.src = reader.result as string;
+ };
+
+ if (fileNames[i]) {
+ reader.readAsDataURL(fileNames[i]);
+ }
+ }
+
+ if (fileNames.length === 0) {
+ // Reset input aria-label with default message
+ fileInputEl.setAttribute('aria-label', DEFAULT_ARIA_LABEL_TEXT);
+ } else {
+ addPreviewHeading(fileInputEl, fileNames);
+ }
+
+ updateStatusMessage(statusElement, fileNames, fileStore);
+};
+
+/**
+ * When using an Accept attribute, invalid files will be hidden from
+ * file browser, but they can still be dragged to the input. This
+ * function prevents them from being dragged and removes error states
+ * when correct files are added.
+ *
+ * @param {event} e
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @param {HTMLDivElement} instructions - The container for visible interaction instructions.
+ * @param {HTMLDivElement} dropTarget - The drag and drop target area.
+ */
+const preventInvalidFiles = (e, fileInputEl, instructions, dropTarget) => {
+ const acceptedFilesAttr = fileInputEl.getAttribute('accept');
+ dropTarget.classList.remove(INVALID_FILE_CLASS);
+
+ /**
+ * We can probably move away from this once IE11 support stops, and replace
+ * with a simple es `.includes`
+ * check if element is in array
+ * check if 1 or more alphabets are in string
+ * if element is present return the position value and -1 otherwise
+ * @param {Object} file
+ * @param {String} value
+ * @returns {Boolean}
+ */
+ const isIncluded = (file, value) => {
+ let returnValue = false;
+ const pos = file.indexOf(value);
+ if (pos >= 0) {
+ returnValue = true;
+ }
+ return returnValue;
+ };
+
+ // Runs if only specific files are accepted
+ if (acceptedFilesAttr) {
+ const acceptedFiles = acceptedFilesAttr.split(',');
+ const errorMessage = document.createElement('div');
+
+ // If multiple files are dragged, this iterates through them and look for any files that are not accepted.
+ let allFilesAllowed = true;
+ const scannedFiles = e.target.files || e.dataTransfer.files;
+ for (let i = 0; i < scannedFiles.length; i += 1) {
+ const file = scannedFiles[i];
+ if (allFilesAllowed) {
+ for (let j = 0; j < acceptedFiles.length; j += 1) {
+ const fileType = acceptedFiles[j];
+ allFilesAllowed =
+ file.name.indexOf(fileType) > 0 ||
+ isIncluded(file.type, fileType.replace(/\*/g, ''));
+ if (allFilesAllowed) {
+ TYPE_IS_VALID = true;
+ break;
+ }
+ }
+ } else break;
+ }
+
+ // If dragged files are not accepted, this removes them from the value of the input and creates and error state
+ if (!allFilesAllowed) {
+ removeOldPreviews(dropTarget, instructions);
+ fileInputEl.value = ''; // eslint-disable-line no-param-reassign
+ dropTarget.insertBefore(errorMessage, fileInputEl);
+ errorMessage.textContent =
+ fileInputEl.dataset.errormessage || `This is not a valid file type.`;
+ errorMessage.classList.add(ACCEPTED_FILE_MESSAGE_CLASS);
+ dropTarget.classList.add(INVALID_FILE_CLASS);
+ TYPE_IS_VALID = false;
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+};
+
+/**
+ * 1. passes through gate for preventing invalid files
+ * 2. handles updates if file is valid
+ *
+ * @param {event} event
+ * @param {HTMLInputElement} fileInputEl - The input element.
+ * @param {HTMLDivElement} instructions - The container for visible interaction instructions.
+ * @param {HTMLDivElement} dropTarget - The drag and drop target area.
+ */
+const handleUpload = (event, fileInputEl, instructions, dropTarget) => {
+ preventInvalidFiles(event, fileInputEl, instructions, dropTarget);
+ if (TYPE_IS_VALID === true) {
+ handleChange(event, fileInputEl, instructions, dropTarget);
+ }
+};
+
+export const fileInput = {
+ init(root: HTMLElement) {
+ selectOrMatches(DROPZONE, root).forEach(fileInputEl => {
+ const { instructions, dropTarget } = enhanceFileInput(fileInputEl);
+
+ dropTarget.addEventListener(
+ 'dragover',
+ function handleDragOver() {
+ this.classList.add(DRAG_CLASS);
+ },
+ false,
+ );
+
+ dropTarget.addEventListener(
+ 'dragleave',
+ function handleDragLeave() {
+ this.classList.remove(DRAG_CLASS);
+ },
+ false,
+ );
+
+ dropTarget.addEventListener(
+ 'drop',
+ function handleDrop(e) {
+ e.preventDefault(); // Prevents browser from opening file instead of adding it to input
+ this.classList.remove(DRAG_CLASS);
+
+ // Because of the way 'drop' works differently from 'change', need to get files from the dataTransfer object
+ const dt = e.dataTransfer;
+ (e.target as HTMLInputElement).files = dt.files;
+ handleUpload(e, fileInputEl, instructions, dropTarget);
+
+ // Because we're preventing the default behavior, need to manually fire off a change event
+ const changeEvent = new CustomEvent('change', {
+ detail: { files: dt.files },
+ });
+ fileInputEl.dispatchEvent(changeEvent);
+ },
+ false,
+ );
+
+ // Listens for "input" event so that it fires before the "change" event, which is being captured by the component
+ fileInputEl.addEventListener(
+ 'input',
+ e => handleUpload(e, fileInputEl, instructions, dropTarget),
+ false,
+ );
+ });
+ },
+ teardown(root) {
+ selectOrMatches(INPUT, root).forEach(fileInputEl => {
+ const fileInputTopElement = fileInputEl.parentElement.parentElement;
+ fileInputTopElement.parentElement.replaceChild(
+ fileInputEl,
+ fileInputTopElement,
+ );
+ // eslint-disable-next-line no-param-reassign
+ fileInputEl.className = DROPZONE_CLASS;
+ });
+ },
+ getFileInputContext,
+ disable,
+ ariaDisable,
+ enable,
+};
diff --git a/packages/web-components/src/components/va-file-input/va-file-input.css b/packages/web-components/src/components/va-file-input/va-file-input.css
deleted file mode 100644
index 9dec61b6e..000000000
--- a/packages/web-components/src/components/va-file-input/va-file-input.css
+++ /dev/null
@@ -1,21 +0,0 @@
-@import '../../mixins/accessibility.css';
-@import '../../mixins/form-field-error.css';
-@import '../../mixins/hint-text.css';
-
-:host {
- display: block;
- font-family: var(--font-source-sans);
-}
-
-#error-message{
- margin-bottom: 0.3rem;
-}
-
-va-button {
- margin-bottom: -0.8rem;
-}
-
-.required {
- color: var(--color-secondary-dark);
- margin-left: 0.4rem;
-}
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
new file mode 100644
index 000000000..754dd3f79
--- /dev/null
+++ b/packages/web-components/src/components/va-file-input/va-file-input.scss
@@ -0,0 +1,36 @@
+@forward 'settings';
+
+@use 'usa-file-input/src/styles/usa-file-input';
+@use 'usa-label/src/styles/usa-label';
+@use 'usa-hint/src/styles/usa-hint';
+@use 'uswds-helpers/src/styles/usa-sr-only';
+@import '../../mixins/uswds-error-border.scss';
+@import '../../global/formation_overrides';
+
+/* V3/USWDS Styles */
+[hidden] {
+ display: none;
+}
+
+/* Original Styles */
+@import '../../mixins/accessibility.css';
+@import '../../mixins/form-field-error.css';
+@import '../../mixins/hint-text.css';
+
+:host {
+ display: block;
+ font-family: var(--font-source-sans);
+}
+
+#error-message {
+ margin-bottom: 0.3rem;
+}
+
+va-button {
+ margin-bottom: -0.8rem;
+}
+
+.required {
+ color: var(--color-secondary-dark);
+ margin-left: 0.4rem;
+}
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 890f2431c..713aa6b8e 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
@@ -1,14 +1,17 @@
/* eslint-disable i18next/no-literal-string */
-import {
- Component,
- Element,
- Host,
- h,
- Prop,
+import {
+ Component,
+ Element,
+ Host,
+ h,
+ Prop,
Fragment,
Event,
- EventEmitter
+ EventEmitter,
} from '@stencil/core';
+import classnames from 'classnames';
+import i18next from 'i18next';
+import { fileInput } from './va-file-input-upgrader';
/**
* @componentName File input
@@ -19,10 +22,9 @@ import {
@Component({
tag: 'va-file-input',
- styleUrl: 'va-file-input.css',
+ styleUrl: 'va-file-input.scss',
shadow: true,
})
-
export class VaFileInput {
@Element() el: HTMLElement;
@@ -61,10 +63,20 @@ export class VaFileInput {
*/
@Prop() hint?: string;
+ /**
+ * Optionally allow multiple files (USWDS Only)
+ */
+ @Prop() multiple?: boolean = false;
+
/**
* Emit component-library-analytics events on the file input change event.
*/
- @Prop() enableAnalytics?: boolean = false;
+ @Prop() enableAnalytics?: boolean = false;
+
+ /**
+ * Whether or not the component will use USWDS v3 styling.
+ */
+ @Prop() uswds?: boolean = false;
/**
* The event emitted when the file input value changes.
@@ -75,16 +87,16 @@ export class VaFileInput {
* The event used to track usage of the component. This is emitted when the
* file input changes and enableAnalytics is true.
*/
- @Event({
+ @Event({
eventName: 'component-library-analytics',
composed: true,
bubbles: true,
})
componentLibraryAnalytics: EventEmitter;
- private handleChange = (e: Event) => {
+ private handleChange = (e: Event, files?: any) => {
const target = e.target as HTMLInputElement;
- this.vaChange.emit({files: target.files});
+ this.vaChange.emit({ files: target.files || files });
/**
* Clear the original input, otherwise events will be triggered
* with empty file arrays and sometimes uploading a file twice will
@@ -97,15 +109,15 @@ export class VaFileInput {
componentName: 'va-file-input',
action: 'change',
details: {
- label: this.label
+ label: this.label,
},
});
}
};
- private handleButtonClick = () => {
+ private handleButtonClick = () => {
this.el.shadowRoot.getElementById('fileInputField').click();
- }
+ };
/**
* Makes sure the button text always has a value.
@@ -115,46 +127,110 @@ export class VaFileInput {
return this.buttonText ? this.buttonText : 'Upload file';
};
+ componentDidLoad() {
+ if (this.uswds) fileInput.init(this.el);
+ }
+
+ connectedCallback() {
+ if (this.uswds) {
+ this.el.addEventListener('change', this.handleChange);
+ }
+ }
+
+ disconnectedCallback() {
+ if (this.uswds) {
+ this.el.removeEventListener('change', this.handleChange);
+ }
+ }
+
render() {
- const {
- label,
- name,
- required,
- accept,
- error,
- hint,
- } = this;
-
+ const { label, name, required, accept, error, hint, multiple, uswds } =
+ this;
+
const text = this.getButtonText();
- return (
-
- {label && (
-
- )}
- {hint && {hint}}
-
-
- {error && (
-
- Error
- {error}
-
+ if (uswds) {
+ const labelClass = classnames({
+ 'usa-label': true,
+ 'usa-label--error': error,
+ });
+ const inputClass = classnames({
+ 'usa-file-input': true,
+ 'usa-input--error': error,
+ });
+ const ariaDescribedbyIds =
+ `${hint ? 'input-hint-message' : ''} ${
+ error ? 'input-error-message' : ''
+ }`.trim() || null; // Null so we don't add the attribute if we have an empty string
+
+ return (
+
+ {label && (
+
)}
-
-
-
+ {hint}
+
+ )}
+
+
+ {error && (
+
+ {i18next.t('error')}
+ {error}
+
+ )}
+
+
+
+ );
+ } else {
+ return (
+
+ {label && (
+
+ )}
+ {hint && {hint}}
+
+
+ {error && (
+
+ Error
+ {error}
+
+ )}
+
+
+
-
- );
+
+ );
+ }
}
-
}
diff --git a/packages/web-components/src/global/_formation_overrides.scss b/packages/web-components/src/global/_formation_overrides.scss
index 08e2e8a3a..21af9d130 100644
--- a/packages/web-components/src/global/_formation_overrides.scss
+++ b/packages/web-components/src/global/_formation_overrides.scss
@@ -155,7 +155,7 @@
margin-right: rem-override(0.5rem);
padding: rem-override(0.75rem) rem-override(1.25rem);
-
+
&--big {
border-radius: rem-override(0.25rem);
font-size: rem-override(1.46rem);
@@ -552,4 +552,37 @@
.usa-search--small .usa-search__submit-icon {
height: rem-override(1.5rem);
width: rem-override(1.5rem);
+}
+
+.usa-file-input {
+ max-width: rem-override(30rem);
+
+ &__target {
+ font-size: rem-override(1rem);
+ margin-top: rem-override(0.5rem);
+ }
+
+ &__instructions {
+ padding: rem-override(2rem) rem-override(1rem);
+ }
+
+ &__preview {
+ padding: rem-override(0.25rem) rem-override(0.5rem);
+ font-size: rem-override(0.87rem);
+ }
+
+ &__preview-heading {
+ padding: rem-override(0.5rem);
+ }
+
+ &__preview-image {
+ height: rem-override(2.5rem);
+ margin-right: rem-override(0.5rem);
+ width: rem-override(2.5rem);
+ background-size: rem-override(1.5rem);
+ }
+
+ .usa-file-input__input[type] {
+ padding: rem-override(0.5rem);
+ }
}
\ No newline at end of file
diff --git a/packages/web-components/src/utils/utils.ts b/packages/web-components/src/utils/utils.ts
index 4857613e8..ffa3d50fe 100644
--- a/packages/web-components/src/utils/utils.ts
+++ b/packages/web-components/src/utils/utils.ts
@@ -22,11 +22,13 @@ export function getSlottedNodes(
nodeName: string | null,
): Array {
// This will only get the first slot on a component
- const children = root.shadowRoot.querySelector('slot').assignedNodes()
+ const children = root.shadowRoot.querySelector('slot').assignedNodes();
- return nodeName !== null ? Array.from(children).filter(
+ return nodeName !== null
+ ? Array.from(children).filter(
item => item.nodeName.toLowerCase() === nodeName,
- ) : Array.from(children);
+ )
+ : Array.from(children);
}
/**
@@ -78,6 +80,90 @@ export function makeArray(start: number, end: number) {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
+/**
+ * @name isElement
+ * @desc returns whether or not the given argument is a DOM element.
+ */
+const isElement = (value: any): boolean =>
+ value && typeof value === 'object' && value.nodeType === 1;
+
+/**
+ * @name select
+ * @desc selects elements from the DOM by class selector or ID selector
+ */
+function select(selector: string, context?: HTMLElement): HTMLElement[] {
+ if (typeof selector !== 'string') {
+ return [];
+ }
+
+ if (!context || !isElement(context)) {
+ return [];
+ }
+
+ const selection = context.shadowRoot.querySelectorAll(selector);
+ return Array.from(selection) as HTMLElement[];
+}
+
+/**
+ * @name selectOrMatches
+ * @desc selects elements from a specific DOM context by class selector or ID selector.
+ */
+export function selectOrMatches(
+ selector: string,
+ context?: HTMLElement,
+): HTMLElement[] {
+ const selection = select(selector, context);
+
+ if (typeof selector !== 'string') {
+ return selection;
+ }
+
+ if (isElement(context) && context.matches(selector)) {
+ selection.push(context);
+ }
+
+ return selection;
+}
+
+/**
+ * Sanitizes strings of HTML to be output in innerHTML of an element
+ */
+export const Sanitizer = {
+ _entity: /[&<>"'/]/g,
+
+ /* eslint-disable i18next/no-literal-string */
+ _entities: {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ '/': '/',
+ },
+ /* eslint-enable i18next/no-literal-string */
+
+ getEntity: function (s) {
+ return Sanitizer._entities[s];
+ },
+
+ /**
+ * Escapes HTML for all values in a tagged template string.
+ */
+ escapeHTML: function (strings: string | string[]) {
+ var result = '';
+
+ for (var i = 0; i < strings.length; i++) {
+ result += strings[i];
+ if (i + 1 < arguments.length) {
+ var value = arguments[i + 1] || '';
+ result += String(value).replace(Sanitizer._entity, Sanitizer.getEntity);
+ }
+ }
+
+ return result;
+ },
+};
+
/**
* return the heading level based on an integer input
* if invalid input return null
@@ -90,4 +176,4 @@ export function getHeaderLevel(headerInput: number | string): string | null {
headerLevel = Math.floor(headerInput);
}
return headerLevel >= 1 && headerLevel <= 6 ? `h${headerLevel}` : null;
-}
\ No newline at end of file
+}