diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss index 309ea05fd5..2f0b702a7b 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss +++ b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss @@ -64,7 +64,7 @@ border: $govuk-border-width-form-element dashed $govuk-input-border-colour; background-color: $govuk-body-background-colour; - .govuk-file-upload__button, + .govuk-file-upload__pseudo-button, .govuk-file-upload__status { // When the dropzone is hovered over, make these aspects not accept // mouse events, so dropped files fall through to the input beneath them @@ -85,7 +85,7 @@ opacity: 0; } - .govuk-file-upload__button { + .govuk-file-upload__pseudo-button { width: auto; margin-bottom: 0; flex-grow: 0; @@ -97,3 +97,50 @@ margin-left: govuk-spacing(2); } } + +.govuk-file-upload__button:focus { + outline: none; +} + +.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button { + outline: 3px solid transparent; + background-color: $govuk-focus-colour; + box-shadow: 0 2px 0 govuk-colour("black"); +} + +.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button:hover { + border-color: $govuk-focus-colour; + outline: 3px solid transparent; + background-color: govuk-colour("light-grey"); + box-shadow: inset 0 0 0 1px $govuk-focus-colour; +} + +.govuk-file-upload__button:active .govuk-file-upload__pseudo-button:hover { + background-color: govuk-shade(govuk-colour("light-grey"), 20%); +} + +.govuk-file-upload__button { + align-items: center; + display: flex; + padding: 0; + border: 0; + background-color: transparent; +} + +.govuk-file-upload:disabled + .govuk-file-upload__button { + pointer-events: none; +} + +.govuk-file-upload:disabled + .govuk-file-upload__button .govuk-file-upload__pseudo-button { + opacity: (0.5); + + &:hover { + background-color: govuk-colour("light-grey"); + cursor: not-allowed; + } + + &:active { + top: 0; + box-shadow: 0 $govuk-border-width-form-element 0 govuk-shade(govuk-colour("white"), 60%); // s0 + } +} diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs index 6d26a2a4c0..c94376f8a0 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -29,6 +29,9 @@ export class FileUpload extends ConfigurableComponent { /** @private */ i18n + /** @private */ + id + /** * @param {Element | null} $root - File input element * @param {FileUploadConfig} [config] - File Upload config @@ -45,6 +48,14 @@ export class FileUpload extends ConfigurableComponent { ) } + if (!this.$root.id.length) { + throw new ElementError( + formatErrorMessage(FileUpload, 'Form field must specify an `id`.') + ) + } + + this.id = this.$root.id + this.i18n = new I18n(this.config.i18n, { // Read the fallback if necessary rather than have it set in the defaults locale: closestAttributeValue(this.$root, 'lang') @@ -52,31 +63,51 @@ export class FileUpload extends ConfigurableComponent { this.$label = this.findLabel() + // we need to copy the 'id' of the root element + // to the new button replacement element + // so that focus will work in the error summary + this.$root.id = `${this.id}-input` + // Wrapping element. This defines the boundaries of our drag and drop area. const $wrapper = document.createElement('div') $wrapper.className = 'govuk-file-upload-wrapper' // Create the file selection button const $button = document.createElement('button') - $button.className = - 'govuk-button govuk-button--secondary govuk-file-upload__button' + $button.classList.add('govuk-file-upload__button') $button.type = 'button' - $button.innerText = this.i18n.t('selectFilesButton') + $button.id = this.id + + const buttonSpan = document.createElement('span') + buttonSpan.className = + 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button' + buttonSpan.innerText = this.i18n.t('selectFilesButton') + buttonSpan.setAttribute('aria-hidden', 'true') + + $button.appendChild(buttonSpan) $button.addEventListener('click', this.onClick.bind(this)) // Create status element that shows what/how many files are selected const $status = document.createElement('span') $status.className = 'govuk-body govuk-file-upload__status' $status.innerText = this.i18n.t('filesSelectedDefault') - $status.setAttribute('role', 'status') + $status.setAttribute('aria-hidden', 'true') + + $button.appendChild($status) + $button.setAttribute( + 'aria-label', + `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}` + ) // Assemble these all together $wrapper.insertAdjacentElement('beforeend', $button) - $wrapper.insertAdjacentElement('beforeend', $status) // Inject all this *after* the native file input this.$root.insertAdjacentElement('afterend', $wrapper) + this.$root.setAttribute('tabindex', '-1') + this.$root.setAttribute('aria-hidden', 'true') + // Move the native file input to inside of the wrapper $wrapper.insertAdjacentElement('afterbegin', this.$root) @@ -85,8 +116,8 @@ export class FileUpload extends ConfigurableComponent { this.$button = $button this.$status = $status - // Prevent the hidden input being tabbed to by keyboard users - this.$root.setAttribute('tabindex', '-1') + // Bind change event to the underlying input + this.$root.addEventListener('change', this.onChange.bind(this)) // Syncronise the `disabled` state between the button and underlying input this.updateDisabledState() @@ -213,6 +244,11 @@ export class FileUpload extends ConfigurableComponent { count: fileCount }) } + + this.$button.setAttribute( + 'aria-label', + `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}` + ) } /** @@ -240,7 +276,7 @@ export class FileUpload extends ConfigurableComponent { * When the button is clicked, emulate clicking the actual, hidden file input */ onClick() { - this.$label.click() + this.$root.click() } /** diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js index 297e2d9e09..31ada7628e 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js @@ -7,6 +7,7 @@ const inputSelector = '.govuk-file-upload' const wrapperSelector = '.govuk-file-upload-wrapper' const buttonSelector = '.govuk-file-upload__button' const statusSelector = '.govuk-file-upload__status' +const pseudoButtonSelector = '.govuk-file-upload__pseudo-button' describe('/components/file-upload', () => { let examples @@ -93,66 +94,59 @@ describe('/components/file-upload', () => { }) it('renders the button with default text', async () => { - const buttonElementText = await page.$eval(buttonSelector, (el) => - el.innerHTML.trim() + const buttonElementText = await page.$eval( + `${buttonSelector} ${pseudoButtonSelector}`, + (el) => el.innerHTML.trim() ) - expect(buttonElementText).toBe('Choose file') - }) - }) - - describe('status element', () => { - it('renders the status element', async () => { - const statusElement = await page.$eval(statusSelector, (el) => el) - - expect(statusElement).toBeDefined() - }) - - it('renders the status element with role', async () => { - const statusElementRole = await page.$eval(statusSelector, (el) => - el.getAttribute('role') + const statusElementText = await page.$eval( + `${buttonSelector} ${statusSelector}`, + (el) => el.innerHTML.trim() ) - expect(statusElementRole).toBe('status') - }) - - it('renders the status element with default text', async () => { - const statusElementText = await page.$eval(statusSelector, (el) => - el.innerHTML.trim() + const buttonAriaText = await page.$eval(buttonSelector, (el) => + el.getAttribute('aria-label') ) + expect(buttonElementText).toBe('Choose file') expect(statusElementText).toBe('No file chosen') + expect(buttonAriaText).toBe( + 'Upload a file, Choose file, No file chosen' + ) }) }) }) describe('when clicking the choose file button', () => { - it('opens the file picker', async () => { - // It doesn't seem to be possible to check if the file picker dialog - // opens as an isolated test, so this test clicks the button, tries to - // set a value in the file chooser, then checks if that value was set - // on the input as expected. - const testFilename = 'test.gif' - await render(page, 'file-upload', examples.default) + it.each([buttonSelector, pseudoButtonSelector, statusSelector])( + 'opens the file picker', + async (selector) => { + // It doesn't seem to be possible to check if the file picker dialog + // opens as an isolated test, so this test clicks the button, tries to + // set a value in the file chooser, then checks if that value was set + // on the input as expected. + const testFilename = 'test.gif' + await render(page, 'file-upload', examples.default) - const [fileChooser] = await Promise.all([ - page.waitForFileChooser(), - page.click(buttonSelector) - ]) + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.click(selector) + ]) - await fileChooser.accept([testFilename]) + await fileChooser.accept([testFilename]) - const inputElementValue = await page.$eval( - inputSelector, - (el) => - // @ts-ignore - el.value - ) + const inputElementValue = await page.$eval( + inputSelector, + (el) => + // @ts-ignore + el.value + ) - // For Windows and backward compatibility, the values of file inputs - // are always formatted starting with `C:\\fakepath\\` - expect(inputElementValue).toBe(`C:\\fakepath\\${testFilename}`) - }) + // For Windows and backward compatibility, the values of file inputs + // are always formatted starting with `C:\\fakepath\\` + expect(inputElementValue).toBe(`C:\\fakepath\\${testFilename}`) + } + ) }) describe('when selecting a file', () => { @@ -163,7 +157,7 @@ describe('/components/file-upload', () => { const [fileChooser] = await Promise.all([ page.waitForFileChooser(), - page.click(inputSelector) + page.click(buttonSelector) ]) await fileChooser.accept([testFilename]) }) @@ -206,7 +200,7 @@ describe('/components/file-upload', () => { const [fileChooser] = await Promise.all([ page.waitForFileChooser(), - page.click(inputSelector) + page.click(buttonSelector) ]) await fileChooser.accept(['testfile1.txt', 'testfile2.pdf']) }) @@ -354,11 +348,24 @@ describe('/components/file-upload', () => { }) it('uses the correct translation for the choose file button', async () => { - const buttonText = await page.$eval(buttonSelector, (el) => + const buttonElementText = await page.$eval( + pseudoButtonSelector, + (el) => el.innerHTML.trim() + ) + + const statusElementText = await page.$eval(statusSelector, (el) => el.innerHTML.trim() ) - expect(buttonText).toBe('Dewiswch ffeil') + const buttonAriaText = await page.$eval(buttonSelector, (el) => + el.getAttribute('aria-label') + ) + + expect(buttonElementText).toBe('Dewiswch ffeil') + expect(statusElementText).toBe("Dim ffeiliau wedi'u dewis") + expect(buttonAriaText).toBe( + "Llwythwch ffeil i fyny, Dewiswch ffeil, Dim ffeiliau wedi'u dewis" + ) }) describe('status element', () => { @@ -373,7 +380,7 @@ describe('/components/file-upload', () => { it('uses the correct translation when multiple files are selected', async () => { const [fileChooser] = await Promise.all([ page.waitForFileChooser(), - page.click(inputSelector) + page.click(buttonSelector) ]) await fileChooser.accept(['testfile1.txt', 'testfile2.pdf'])