diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a68ad0c9..70f9ccaeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,54 @@ For advice on how to use these release notes see [our guidance on staying up to ### New features +#### Use our improved File upload component + +We've added a [JavaScript enhancement to the File upload component](https://design-system.service.gov.uk/components/file-upload/#using-the-improved-file-upload-component) which: + +- makes the component easier to use for drag and drop +- allows the text of the component to be translated +- fixes accessibility issues for users of Dragon, a speech recognition software + +This improvement is opt-in, as it's a substantial visual change which risks shifting other content on the page. + +To enable this improvement for your users, you'll first need to update the markup of your File upload component: + +- if you use our Nunjucks macro, using the new `javascript` option of `govukFileUpload` + + ```njk + {{ govukFileUpload({ + id: "file-upload", + name: "photo", + label: { + text: "Upload your photo" + }, + javascript: true + }) }} + ``` + +- if you're using HTML, wrapping the `` of the File upload markup in a `
` + + ```html +
+ +
+ +
+
+ ``` + +If you're importing components individually in your JavaScript, which we recommend for better performance, you'll then need to import and initialise the new `FileUpload` component. + +```js +import {FileUpload} from 'govuk-frontend' + +createAll(FileUpload) +``` + +This change was introduced in [pull request #5305: Add progressively enhanced File Upload component](https://github.com/alphagov/govuk-frontend/pull/5305) + #### Form control components now have default `id` attributes If you're using the included Nunjucks macros, the Text input, Textarea, Password input, Character count, File upload, and Select components now automatically use the value of the `name` parameter for the `id` parameter. diff --git a/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk b/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk index 196640ef9a..2cfdd32168 100644 --- a/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk +++ b/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk @@ -49,6 +49,10 @@ "text": "Problem with file", "href": "#file" }, + { + "text": "Problem with file (enhanced)", + "href": "#file-enhanced" + }, { "text": "Problem with radios", "href": "#radios" @@ -192,6 +196,18 @@ } }) }} + {{ govukFileUpload({ + label: { + "text": 'Label for enhanced file upload' + }, + id: "file-enhanced", + name: "file-enhanced", + errorMessage: { + "text": "Problem with file" + }, + javascript: true + }) }} + {{ govukRadios({ fieldset: { legend: { diff --git a/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk b/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk index f5183726f7..5697b1bd6b 100644 --- a/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk +++ b/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk @@ -28,6 +28,7 @@
  • Date pattern
  • Select box
  • File upload
  • +
  • File upload (enhanced)
  • @@ -174,5 +175,17 @@ }) -}} {% endcall %} + {% call govukFieldset() %} + + {{- govukFileUpload({ + id: 'file-upload-2', + name: 'file-upload-2', + label: { + text: 'Upload a file' + }, + javascript: true + }) -}} + {% endcall %} +
    {% endblock %} diff --git a/packages/govuk-frontend-review/src/views/examples/translated/index.njk b/packages/govuk-frontend-review/src/views/examples/translated/index.njk index 3ba09e3c52..6e22722075 100644 --- a/packages/govuk-frontend-review/src/views/examples/translated/index.njk +++ b/packages/govuk-frontend-review/src/views/examples/translated/index.njk @@ -340,6 +340,24 @@ } }) }} + {{ govukFileUpload({ + id: "file-upload-1", + name: "file-upload-1", + label: { + text: "Llwythwch ffeil i fyny" + }, + javascript: true, + chooseFilesButtonText: "Dewiswch ffeil", + dropInstructionText: "neu ollwng ffeil", + noFileChosenText: "Dim ffeil wedi'i dewis", + multipleFilesChosenText: { + other: "%{count} ffeil wedi'u dewis", + one: "%{count} ffeil wedi'i dewis" + }, + enteredDropZoneText: "Wedi mynd i mewn i'r parth gollwng", + leftDropZoneText: "Parth gollwng i'r chwith" + }) }} + {{ govukFooter({ classes: "govuk-!-margin-bottom-4", navigation: [ diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk index 244c8247b9..43716cba34 100644 --- a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk +++ b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk @@ -69,7 +69,8 @@ scenario: | hint: { text: "Your photo must be at least 50KB and no more than 10MB" }, - errorMessage: errors["photo"] + errorMessage: errors["photo"], + javascript: true }) }} {{ govukCheckboxes({ diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk index 6612671c5e..c45e7b1986 100644 --- a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk +++ b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk @@ -71,7 +71,8 @@ scenario: | hint: { text: "Your photo must be at least 50KB and no more than 10MB" }, - errorMessage: errors["photo"] + errorMessage: errors["photo"], + javascript: true }) }} {{ govukCheckboxes({ diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index 3cc7ab2bcc..6eeff733a9 100644 --- a/packages/govuk-frontend/src/govuk/all.mjs +++ b/packages/govuk-frontend/src/govuk/all.mjs @@ -5,6 +5,7 @@ export { CharacterCount } from './components/character-count/character-count.mjs export { Checkboxes } from './components/checkboxes/checkboxes.mjs' export { ErrorSummary } from './components/error-summary/error-summary.mjs' export { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs' +export { FileUpload } from './components/file-upload/file-upload.mjs' export { Header } from './components/header/header.mjs' export { NotificationBanner } from './components/notification-banner/notification-banner.mjs' export { PasswordInput } from './components/password-input/password-input.mjs' diff --git a/packages/govuk-frontend/src/govuk/all.puppeteer.test.js b/packages/govuk-frontend/src/govuk/all.puppeteer.test.js index 254fbaa26b..387449bec5 100644 --- a/packages/govuk-frontend/src/govuk/all.puppeteer.test.js +++ b/packages/govuk-frontend/src/govuk/all.puppeteer.test.js @@ -72,6 +72,7 @@ describe('GOV.UK Frontend', () => { 'ConfigurableComponent', 'ErrorSummary', 'ExitThisPage', + 'FileUpload', 'Header', 'NotificationBanner', 'PasswordInput', 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 5862ab9cc3..5f0dd039c8 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss +++ b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss @@ -3,7 +3,11 @@ @import "../label/index"; @include govuk-exports("govuk/component/file-upload") { + $file-upload-border-width: 2px; $component-padding: govuk-spacing(1); + $empty-button-background-colour: govuk-colour("white"); + $empty-pseudo-button-background-colour: govuk-colour("light-grey"); + $empty-status-background-colour: govuk-tint(govuk-colour("blue"), 70%); .govuk-file-upload { @include govuk-font($size: 19); @@ -46,4 +50,167 @@ cursor: not-allowed; } } + + .govuk-drop-zone { + display: block; + position: relative; + z-index: 0; + background-color: $govuk-body-background-colour; + } + + // required because disabling pointer events + // on the button means that the cursor style + // be applied on the button itself + .govuk-drop-zone--disabled { + cursor: not-allowed; + } + + .govuk-file-upload-button__pseudo-button { + width: auto; + margin-right: govuk-spacing(2); + margin-bottom: $govuk-border-width-form-element + 1; + flex-shrink: 0; + } + + .govuk-file-upload-button__instruction { + margin-top: govuk-spacing(2) - ($govuk-border-width-form-element + 1); + margin-bottom: 0; + text-align: left; + } + + .govuk-file-upload-button__status { + display: block; + margin-bottom: govuk-spacing(2); + padding: govuk-spacing(3) govuk-spacing(2); + background-color: govuk-colour("white"); + text-align: left; + } + + // bugs documented with button using flex + // https://github.com/philipwalton/flexbugs#flexbug-9 + // so we need a container here + .govuk-file-upload-button__pseudo-button-container { + display: flex; + align-items: baseline; + flex-wrap: wrap; + } + + .govuk-file-upload-button { + width: 100%; + // align the padding to be same as notification banner and error summary accounting for the thicker borders + padding: (govuk-spacing(3) + $govuk-border-width - $file-upload-border-width); + border: $file-upload-border-width govuk-colour("mid-grey") solid; + background-color: govuk-colour("light-grey"); + cursor: pointer; + + @include govuk-media-query($from: tablet) { + padding: (govuk-spacing(4) + $govuk-border-width - $file-upload-border-width); + } + + .govuk-file-upload-button__pseudo-button { + background-color: govuk-colour("white"); + } + + &:hover { + background-color: govuk-tint(govuk-colour("mid-grey"), 20%); + + .govuk-file-upload-button__pseudo-button { + background-color: govuk-shade(govuk-colour("light-grey"), 10%); + } + + .govuk-file-upload-button__status { + background-color: govuk-tint(govuk-colour("blue"), 80%); + } + } + + &:active, + &:focus { + border: $file-upload-border-width solid govuk-colour("black"); + outline: $govuk-focus-width solid $govuk-focus-colour; + // Ensure outline appears outside of the element + outline-offset: 0; + background-color: govuk-tint(govuk-colour("mid-grey"), 20%); + // Double the border by adding its width again. Use `box-shadow` for this + // instead of changing `border-width` - this is for consistency with + // components such as textarea where we avoid changing `border-width` as + // it will change the element size. Also, `outline` cannot be utilised + // here as it is already used for the yellow focus state. + box-shadow: inset 0 0 0 $govuk-border-width-form-element; + + .govuk-file-upload-button__pseudo-button { + background-color: $govuk-focus-colour; + box-shadow: 0 2px 0 govuk-colour("black"); + } + + &:hover .govuk-file-upload-button__pseudo-button { + 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--empty { + border-style: dashed; + background-color: $empty-button-background-colour; + + .govuk-file-upload-button__pseudo-button { + background-color: $empty-pseudo-button-background-colour; + } + + .govuk-file-upload-button__status { + color: govuk-shade(govuk-colour("blue"), 60%); + background-color: $empty-status-background-colour; + } + + &:hover, + &:focus, + &:active { + background-color: govuk-colour("light-grey"); + + .govuk-file-upload-button__status { + background-color: govuk-tint(govuk-colour("blue"), 80%); + } + } + } + + .govuk-file-upload-button--dragging { + border-style: solid; + border-color: govuk-colour("black"); + + // extra specificity to apply when + // empty + &.govuk-file-upload-button { + background-color: govuk-tint(govuk-colour("mid-grey"), 20%); + } + + &.govuk-file-upload-button--empty { + background-color: govuk-colour("light-grey"); + } + + &.govuk-file-upload-button--empty:not(:disabled) .govuk-file-upload-button__status, + &.govuk-file-upload-button--empty .govuk-file-upload-button__pseudo-button { + background-color: govuk-colour("white"); + } + + .govuk-file-upload-button__pseudo-button { + background-color: govuk-shade(govuk-colour("light-grey"), 10%); + } + } + + .govuk-file-upload-button:disabled { + pointer-events: none; + opacity: 0.5; + + background-color: $empty-button-background-colour; + + .govuk-file-upload-button__pseudo-button { + background-color: $empty-pseudo-button-background-colour; + } + + .govuk-file-upload-button__status { + background-color: $empty-status-background-colour; + } + } } diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs b/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs index 33f590b06a..1a410b6dd5 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs @@ -7,9 +7,37 @@ describe('/components/file-upload', () => { const examples = await getExamples('file-upload') for (const exampleName in examples) { - await render(page, 'file-upload', examples[exampleName]) + // JavaScript enhancements being optional, some examples will not have + // any element with `data-module="govuk-file-upload"`. This causes an error + // as `render` assumes that if a component is exported by GOV.UK Frontend + // its rendered markup will have a `data-module` and tried to initialise + // the JavaScript component, even if no element with the right `data-module` + // is on the page. + // + // Because of this, we need to filter `ElementError` thrown by the JavaScript + // component to examples that actually run the JavaScript enhancements + try { + await render(page, 'file-upload', examples[exampleName]) + } catch (e) { + const macroOptions = /** @type {MacroOptions} */ ( + examples[exampleName].context + ) + + const exampleUsesJavaScript = macroOptions.javascript + const exampleLackedRoot = e.message.includes( + 'govuk-file-upload: Root element' + ) + + if (!exampleLackedRoot || exampleUsesJavaScript) { + throw e + } + } await expect(axe(page)).resolves.toHaveNoViolations() } }, 120000) }) }) + +/** + * @import {MacroOptions} from '@govuk-frontend/lib/components' + */ 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 new file mode 100644 index 0000000000..6e1a8f9ac3 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -0,0 +1,469 @@ +import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' +import { ConfigurableComponent } from '../../common/configuration.mjs' +import { formatErrorMessage } from '../../common/index.mjs' +import { ElementError } from '../../errors/index.mjs' +import { I18n } from '../../i18n.mjs' + +/** + * File upload component + * + * @preserve + * @augments ConfigurableComponent + */ +export class FileUpload extends ConfigurableComponent { + /** + * @private + * @type {HTMLFileInputElement} + */ + $input + + /** + * @private + */ + $button + + /** + * @private + */ + $status + + /** @private */ + i18n + + /** @private */ + id + + /** + * @param {Element | null} $root - File input element + * @param {FileUploadConfig} [config] - File Upload config + */ + constructor($root, config = {}) { + super($root, config) + + const $input = this.$root.querySelector('input') + + if ($input === null) { + throw new ElementError({ + component: FileUpload, + identifier: 'File inputs (``)' + }) + } + + if ($input.type !== 'file') { + throw new ElementError( + formatErrorMessage( + FileUpload, + 'File input (``) attribute (`type`) is not `file`' + ) + ) + } + + this.$input = /** @type {HTMLFileInputElement} */ ($input) + this.$input.setAttribute('hidden', 'true') + + if (!this.$input.id) { + throw new ElementError({ + component: FileUpload, + identifier: 'File input (``) attribute (`id`)' + }) + } + + this.id = this.$input.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') + }) + + const $label = this.findLabel() + // Add an ID to the label if it doesn't have one already + // so it can be referenced by `aria-labelledby` + if (!$label.id) { + $label.id = `${this.id}-label` + } + + // 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.$input.id = `${this.id}-input` + + // Create the file selection button + const $button = document.createElement('button') + $button.classList.add('govuk-file-upload-button') + $button.type = 'button' + $button.id = this.id + $button.classList.add('govuk-file-upload-button--empty') + + // Copy `aria-describedby` if present so hints and errors + // are associated to the `