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(`