diff --git a/packages/nhsuk-frontend-review/src/examples/all-icons.njk b/packages/nhsuk-frontend-review/src/examples/all-icons.njk index ab7f0f688b..3d5ff39c71 100644 --- a/packages/nhsuk-frontend-review/src/examples/all-icons.njk +++ b/packages/nhsuk-frontend-review/src/examples/all-icons.njk @@ -30,6 +30,8 @@ "Chevron right circle": "chevron-right-circle", "Cross": "cross", "Tick": "tick", + "Minus": "minus", + "Plus": "plus", "Search": "search", "User": "user" } %} diff --git a/packages/nhsuk-frontend-review/src/layouts/page.njk b/packages/nhsuk-frontend-review/src/layouts/page.njk index 12d486c751..8abc0643ee 100644 --- a/packages/nhsuk-frontend-review/src/layouts/page.njk +++ b/packages/nhsuk-frontend-review/src/layouts/page.njk @@ -28,6 +28,7 @@ {% from "nhsuk/components/label/macro.njk" import label %} {% from "nhsuk/components/legend/macro.njk" import legend %} {% from "nhsuk/components/notification-banner/macro.njk" import notificationBanner %} +{% from "nhsuk/components/stepper-input/macro.njk" import stepperInput %} {% from "nhsuk/components/pagination/macro.njk" import pagination %} {% from "nhsuk/components/panel/macro.njk" import panel %} {% from "nhsuk/components/password-input/macro.njk" import passwordInput %} diff --git a/packages/nhsuk-frontend/src/nhsuk/components/_index.scss b/packages/nhsuk-frontend/src/nhsuk/components/_index.scss index 12dc6dc4fa..d15521119c 100644 --- a/packages/nhsuk-frontend/src/nhsuk/components/_index.scss +++ b/packages/nhsuk-frontend/src/nhsuk/components/_index.scss @@ -22,6 +22,7 @@ @forward "date-input"; @forward "file-upload"; @forward "password-input"; +@forward "stepper-input"; // Content presentation @forward "details"; diff --git a/packages/nhsuk-frontend/src/nhsuk/components/button/_index.scss b/packages/nhsuk-frontend/src/nhsuk/components/button/_index.scss index 4170eddbe0..eba908cab5 100644 --- a/packages/nhsuk-frontend/src/nhsuk/components/button/_index.scss +++ b/packages/nhsuk-frontend/src/nhsuk/components/button/_index.scss @@ -216,6 +216,10 @@ $button-shadow-size: $nhsuk-button-shadow-size; padding-top: 0; padding-bottom: 0; + // Prevent users from selecting icon button text + // e.g. When double clicking a stepper button + user-select: none; + .nhsuk-icon { margin: 0 nhsuk-spacing(1); diff --git a/packages/nhsuk-frontend/src/nhsuk/components/index.mjs b/packages/nhsuk-frontend/src/nhsuk/components/index.mjs index de53d082a7..cc22dca31b 100644 --- a/packages/nhsuk-frontend/src/nhsuk/components/index.mjs +++ b/packages/nhsuk-frontend/src/nhsuk/components/index.mjs @@ -5,6 +5,7 @@ export * from './error-summary/error-summary.mjs' export * from './file-upload/file-upload.mjs' export * from './header/header.mjs' export * from './notification-banner/notification-banner.mjs' +export * from './stepper-input/stepper-input.mjs' export * from './password-input/password-input.mjs' export * from './radios/radios.mjs' export * from './skip-link/skip-link.mjs' diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_index.scss b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_index.scss new file mode 100644 index 0000000000..8caa9ec0d8 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_index.scss @@ -0,0 +1,24 @@ +@use "../../core/settings" as *; +@use "../../core/tools" as *; +@forward "../button"; +@forward "../input"; + +//// +/// Stepper input component +/// +/// @group components/stepper-input +//// + +@include nhsuk-exports("nhsuk/components/stepper-input") { + // Hide the buttons by default, JS removes this attribute + .nhsuk-stepper-input__step-down[hidden], + .nhsuk-stepper-input__step-up[hidden] { + display: none; + } + + @include nhsuk-media-query($from: mobile) { + .nhsuk-stepper-input__input { + text-align: center; + } + } +} diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_stepper-input.scss b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_stepper-input.scss new file mode 100644 index 0000000000..0c7e92f6f8 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/_stepper-input.scss @@ -0,0 +1 @@ +@forward "."; diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/accessibility.puppeteer.test.mjs b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/accessibility.puppeteer.test.mjs new file mode 100644 index 0000000000..fbcc50647d --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/accessibility.puppeteer.test.mjs @@ -0,0 +1,19 @@ +import { + axe, + getOptions, + goToComponent +} from '@nhsuk/frontend-helpers/puppeteer.mjs' + +import { examples } from './fixtures.mjs' + +describe('Stepper input', () => { + describe.each(Object.entries(examples))('%s', (name, example) => { + it.each(getOptions(name, example))( + '$title passes accessibility tests', + async (options) => { + await goToComponent(page, 'stepper-input', options) + return expect(axe(page)).resolves.toHaveNoViolations() + } + ) + }) +}) diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/fixtures.mjs b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/fixtures.mjs new file mode 100644 index 0000000000..21608ebe96 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/fixtures.mjs @@ -0,0 +1,163 @@ +/** + * Nunjucks macro option examples + * + * @satisfies {{ [example: string]: MacroExample }} + */ +const fixtures = { + 'default': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + name: 'example', + min: 0 + } + }, + 'with hint': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + hint: { + text: 'Include additional and repeat images' + }, + id: 'with-hint-text', + name: 'example', + min: 0 + }, + screenshot: { + viewports: ['watch', 'mobile', 'tablet', 'desktop'] + } + }, + 'with error message': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + errorMessage: { + text: 'Enter how many images were taken' + }, + id: 'with-error-message', + name: 'example', + min: 0 + } + }, + 'with hint and error': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + hint: { + text: 'Include additional and repeat images' + }, + errorMessage: { + text: 'Enter how many images were taken' + }, + id: 'with-error-message', + name: 'example' + }, + screenshot: { + viewports: ['watch', 'mobile', 'tablet', 'desktop'] + } + }, + 'with button text': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + stepDownButton: { + text: 'Decrease' + }, + stepUpButton: { + text: 'Increase' + }, + id: 'with-button-text', + name: 'example', + min: 0, + value: 2 + } + }, + 'without page heading': { + context: { + label: { + text: 'How many images were taken?' + }, + id: 'without-heading', + name: 'example', + min: 0 + } + }, + 'min': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + id: 'width-class', + name: 'example' + }, + variants: [ + { + description: 'with 5', + context: { + min: 5 + } + }, + { + description: 'with 10', + context: { + min: 10 + } + } + ] + }, + 'max': { + context: { + label: { + text: 'How many images were taken?', + size: 'l', + isPageHeading: true + }, + id: 'width-class', + name: 'example', + min: 0 + }, + variants: [ + { + description: 'with 5', + context: { + max: 5 + } + }, + { + description: 'with 10', + context: { + max: 10 + } + } + ] + } +} + +/** + * Nunjucks macro option examples + * (with typed keys) + * + * @type {Record} + */ +export const examples = fixtures + +/** + * @import { MacroExample } from '#lib' + */ diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro-options.mjs b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro-options.mjs new file mode 100644 index 0000000000..7d990b65da --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro-options.mjs @@ -0,0 +1,232 @@ +export const name = 'Stepper input' + +/** + * Nunjucks macro option params + * + * @satisfies {{ [param: string]: MacroParam }} + */ +const options = { + id: { + type: 'string', + required: false, + description: 'The ID of the input. Defaults to the value of `name`.' + }, + name: { + type: 'string', + required: true, + description: 'The name of the input, which is submitted with the form data.' + }, + inputmode: { + type: 'string', + required: false, + description: + 'Optional value for [the `inputmode` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode). Defaults to `"numeric"`.' + }, + value: { + type: 'string', + required: false, + description: 'Optional initial value of the input.' + }, + min: { + type: 'number', + required: false, + description: 'The `min` attribute for the `input` tag.' + }, + max: { + type: 'number', + required: false, + description: 'The `max` attribute for the `input` tag.' + }, + step: { + type: 'number', + required: false, + description: 'The `step` attribute for the `input` tag.' + }, + disabled: { + type: 'boolean', + required: false, + description: 'If `true`, input will be disabled.' + }, + describedBy: { + type: 'string', + required: false, + description: + 'One or more element IDs to add to the `aria-describedby` attribute, used to provide additional descriptive information for screenreader users.' + }, + label: { + type: 'object', + required: true, + description: 'Options for the label component.', + isComponent: true + }, + hint: { + type: 'object', + required: false, + description: 'Options for the hint component.', + isComponent: true + }, + errorMessage: { + type: 'object', + required: false, + description: + 'Options for the error message component. The error message component will not display if you use a falsy value for `errorMessage`, for example `false` or `null`.', + isComponent: true + }, + formGroup: { + type: 'object', + required: false, + description: + 'Additional options for the form group containing the text input component.', + params: { + classes: { + type: 'string', + required: false, + description: + 'Classes to add to the form group (for example to show error state for the whole group).' + }, + attributes: { + type: 'object', + required: false, + description: + 'HTML attributes (for example data attributes) to add to the form group.' + }, + beforeInput: { + type: 'object', + required: false, + description: + 'Content to add before the input used by the text input component.', + params: { + text: { + type: 'string', + required: true, + description: + 'Text to add before the input. If `html` is provided, the `text` option will be ignored.' + }, + html: { + type: 'string', + required: true, + description: + 'HTML to add before the input. If `html` is provided, the `text` option will be ignored.' + } + } + }, + afterInput: { + type: 'object', + required: false, + description: + 'Content to add after the input used by the text input component.', + params: { + text: { + type: 'string', + required: true, + description: + 'Text to add after the input. If `html` is provided, the `text` option will be ignored.' + }, + html: { + type: 'string', + required: true, + description: + 'HTML to add after the input. If `html` is provided, the `text` option will be ignored.' + } + } + } + } + }, + classes: { + type: 'string', + required: false, + description: 'Classes to add to the input.' + }, + autocomplete: { + type: 'string', + required: false, + description: + 'Attribute to meet [WCAG success criterion 1.3.5: Identify input purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html), for instance `"bday-day"`. See the [Autofill section in the HTML standard](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill) for a full list of attributes that can be used.' + }, + pattern: { + type: 'string', + required: false, + description: + 'Attribute to provide a regular expression pattern, used to match allowed character combinations for the input value.' + }, + attributes: { + type: 'object', + required: false, + description: + 'HTML attributes (for example data attributes) to add to the input.' + }, + stepDownButton: { + type: 'object', + required: false, + description: + 'Optional object allowing customisation of the step down button.', + params: { + classes: { + type: 'string', + required: false, + description: 'Classes to add to the step down button.' + }, + text: { + type: 'string', + required: false, + description: + 'Text to use within the step down button. If `html` is provided, the `text` and `visuallyHiddenText` arguments will be ignored. Defaults to `"Decrease"`.' + }, + html: { + type: 'string', + required: false, + description: + 'HTML to use within the step down button. If `html` is provided, the `text` and `visuallyHiddenText` arguments will be ignored. Defaults to `"Decrease"`.' + }, + visuallyHiddenText: { + type: 'string', + required: false, + description: + 'Visually hidden text for the step down button icon. Defaults to `"Decrease"`.' + } + } + }, + stepUpButton: { + type: 'object', + required: false, + description: + 'Optional object allowing customisation of the step up button.', + params: { + classes: { + type: 'string', + required: false, + description: 'Classes to add to the step up button.' + }, + text: { + type: 'string', + required: false, + description: + 'Text to use within the step up button. If `html` is provided, the `text` and `visuallyHiddenText` arguments will be ignored. Defaults to `"Increase"`.' + }, + html: { + type: 'string', + required: false, + description: + 'HTML to use within the step up button. If `html` is provided, the `text` and `visuallyHiddenText` arguments will be ignored. Defaults to `"Increase"`.' + }, + visuallyHiddenText: { + type: 'string', + required: false, + description: + 'Visually hidden text for the step up button icon. Defaults to `"Increase"`.' + } + } + } +} + +/** + * Nunjucks macro option params + * (with typed keys) + * + * @type {Record} + */ +export const params = options + +/** + * @import { MacroParam } from '#lib' + */ diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro.njk b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro.njk new file mode 100644 index 0000000000..dc0df76ae5 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/macro.njk @@ -0,0 +1,3 @@ +{% macro stepperInput(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.jsdom.test.mjs b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.jsdom.test.mjs new file mode 100644 index 0000000000..4d44c53e7b --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.jsdom.test.mjs @@ -0,0 +1,242 @@ +import { components } from '@nhsuk/frontend-lib' +import { getByRole, getAllByRole } from '@testing-library/dom' +import { userEvent } from '@testing-library/user-event' + +import { examples } from './fixtures.mjs' +import { StepperInput, initStepperInputs } from './stepper-input.mjs' + +const user = userEvent.setup() + +describe('Stepper input', () => { + /** @type {HTMLElement} */ + let $root + + /** @type {HTMLButtonElement} */ + let $stepUpButton + + /** @type {HTMLButtonElement} */ + let $stepDownButton + + /** @type {HTMLInputElement} */ + let $input + + /** + * @param {keyof typeof examples} example + */ + function initExample(example) { + document.body.innerHTML = components.render( + 'stepper-input', + examples[example] + ) + + $root = /** @type {HTMLElement} */ ( + document.querySelector(`[data-module="${StepperInput.moduleName}"]`) + ) + + const $buttons = /** @type {HTMLButtonElement[]} */ ( + getAllByRole($root, 'button', { + hidden: true + }) + ) + + $stepDownButton = $buttons[0] + $stepUpButton = $buttons[1] + + $input = getByRole($root, 'textbox', { + name: 'How many images were taken?' + }) + + jest.spyOn($stepDownButton, 'addEventListener') + jest.spyOn($stepUpButton, 'addEventListener') + } + + beforeEach(() => { + initExample('with button text') + }) + + describe('Initialisation via init function', () => { + it('should add event listeners', () => { + initStepperInputs() + + // Adds listener for step down button click + expect($stepDownButton.addEventListener).toHaveBeenNthCalledWith( + 1, + 'click', + expect.any(Function) + ) + + // Adds listener for step up button click + expect($stepUpButton.addEventListener).toHaveBeenNthCalledWith( + 1, + 'click', + expect.any(Function) + ) + }) + + it('should throw with missing text input', () => { + $input.remove() + + expect(() => initStepperInputs()).toThrow( + `${StepperInput.moduleName}: Form field (\`.nhsuk-js-stepper-input-input\`) not found` + ) + }) + + it('should throw with missing step down button', () => { + $stepDownButton.remove() + + expect(() => initStepperInputs()).toThrow( + `${StepperInput.moduleName}: Step down button (\`.nhsuk-js-stepper-input-step-down\`) not found` + ) + }) + + it('should throw with missing step up button', () => { + $stepUpButton.remove() + + expect(() => initStepperInputs()).toThrow( + `${StepperInput.moduleName}: Step up button (\`.nhsuk-js-stepper-input-step-up\`) not found` + ) + }) + + it('should not throw with missing component', () => { + $root.remove() + expect(() => initStepperInputs()).not.toThrow() + }) + + it('should not throw with empty body', () => { + document.body.innerHTML = '' + expect(() => initStepperInputs()).not.toThrow() + }) + + it('should not throw with empty scope', () => { + const scope = document.createElement('div') + expect(() => initStepperInputs({ scope })).not.toThrow() + }) + }) + + describe('Initialisation via class', () => { + it('should not throw with $root element', () => { + expect(() => new StepperInput($root)).not.toThrow() + }) + + it('should throw with unsupported browser', () => { + document.body.classList.remove('nhsuk-frontend-supported') + + expect(() => new StepperInput($root)).toThrow( + 'NHS.UK frontend is not supported in this browser' + ) + }) + + it('should throw with missing $root element', () => { + // @ts-expect-error Parameter '$root' not provided + expect(() => new StepperInput()).toThrow( + `${StepperInput.moduleName}: Root element (\`$root\`) not found` + ) + }) + + it('should throw with wrong $root element type', () => { + const $svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + + expect(() => new StepperInput($svg)).toThrow( + `${StepperInput.moduleName}: Root element (\`$root\`) is not of type HTMLElement` + ) + }) + + it('should throw with missing $input element', () => { + $input.remove() + + expect(() => new StepperInput($root)).toThrow( + `${StepperInput.moduleName}: Form field (\`.nhsuk-js-stepper-input-input\`) not found` + ) + }) + + it('should throw with wrong $input element type', () => { + $input.setAttribute('type', 'email') + + expect(() => new StepperInput($root)).toThrow( + `${StepperInput.moduleName}: Form field (\`.nhsuk-js-stepper-input-input\`) is not of type HTMLInputElement with attribute (\`type="text"\`)` + ) + }) + + it('should throw when initialised twice', () => { + expect(() => { + new StepperInput($root) + new StepperInput($root) + }).toThrow( + `${StepperInput.moduleName}: Root element (\`$root\`) already initialised` + ) + }) + }) + + describe('Buttons', () => { + it('should be hidden by default', () => { + expect($stepUpButton).toHaveRole('button') + expect($stepUpButton).toHaveAttribute('hidden') + + expect($stepDownButton).toHaveRole('button') + expect($stepDownButton).toHaveAttribute('hidden') + }) + + it('should be visible when JavaScript is enabled', () => { + new StepperInput($root) + + expect($stepUpButton).not.toHaveAttribute('hidden') + expect($stepDownButton).not.toHaveAttribute('hidden') + }) + + it('should announce changes when clicked', async () => { + new StepperInput($root) + + const $liveRegion = /** @type {HTMLElement} */ ( + document.querySelector("[aria-live='polite']") + ) + + await user.click($stepUpButton) + expect($liveRegion.innerText).toBe('3') + + await user.click($stepDownButton) + expect($liveRegion.innerText).toBe('2') + }) + + describe('Increase', () => { + beforeEach(() => { + new StepperInput($root) + }) + + it('steps up when clicked', async () => { + expect($input).toHaveValue(2) + + await user.click($stepUpButton) + + expect($input).toHaveValue(3) + }) + + it('steps up from 1 if the input is empty and min is 0', async () => { + await user.clear($input) + await user.click($stepUpButton) + + expect($input).toHaveValue(1) + }) + }) + + describe('Decrease', () => { + beforeEach(() => { + new StepperInput($root) + }) + + it('steps down when clicked', async () => { + expect($input).toHaveValue(2) + + await user.click($stepDownButton) + + expect($input).toHaveValue(1) + }) + + it('steps down to min value if the input is empty', async () => { + await user.clear($input) + await user.click($stepDownButton) + + expect($input).toHaveValue(0) + }) + }) + }) +}) diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.mjs b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.mjs new file mode 100644 index 0000000000..170c2db762 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/stepper-input.mjs @@ -0,0 +1,264 @@ +import { normaliseOptions } from '../../common/configuration/index.mjs' +import { ConfigurableComponent } from '../../configurable-component.mjs' +import { ElementError } from '../../errors/index.mjs' + +/** + * Stepper input component + * + * @augments {ConfigurableComponent} + */ +export class StepperInput extends ConfigurableComponent { + /** + * @type {number | null} + */ + value = null + + /** + * @param {Element | null} $root - HTML element to use for component + * @param {Partial} [config] - Stepper input config + */ + constructor($root, config) { + super($root, config) + + const $input = this.$root.querySelector('.nhsuk-js-stepper-input-input') + if (!($input instanceof HTMLInputElement)) { + throw new ElementError({ + component: StepperInput, + element: $input, + expectedType: 'HTMLInputElement', + identifier: 'Form field (`.nhsuk-js-stepper-input-input`)' + }) + } + + if ($input.type !== 'text') { + throw new ElementError({ + component: StepperInput, + element: $input, + expectedType: 'HTMLInputElement with attribute (`type="text"`)', + identifier: 'Form field (`.nhsuk-js-stepper-input-input`)' + }) + } + + const $buttonStepDown = this.$root.querySelector( + '.nhsuk-js-stepper-input-step-down' + ) + + if (!($buttonStepDown instanceof HTMLButtonElement)) { + throw new ElementError({ + component: StepperInput, + element: $buttonStepDown, + expectedType: 'HTMLButtonElement', + identifier: 'Step down button (`.nhsuk-js-stepper-input-step-down`)' + }) + } + + const $buttonStepUp = this.$root.querySelector( + '.nhsuk-js-stepper-input-step-up' + ) + + if (!($buttonStepUp instanceof HTMLButtonElement)) { + throw new ElementError({ + component: StepperInput, + element: $buttonStepUp, + expectedType: 'HTMLButtonElement', + identifier: 'Step up button (`.nhsuk-js-stepper-input-step-up`)' + }) + } + + this.$input = $input + this.$buttonStepDown = $buttonStepDown + this.$buttonStepUp = $buttonStepUp + + // Promote from text to number input + $input.type = 'number' + + // Use number input attributes + for (const attribute of /** @type {const} */ (['min', 'max', 'step'])) { + const value = this.config[attribute] + + if (typeof value === 'number') { + $input.setAttribute(attribute, `${value}`) + $input.removeAttribute(`data-${attribute}`) + } + } + + // Prevent mouse wheel changing number input value + $input.addEventListener('wheel', (event) => { + event.preventDefault() + }) + + // Check for input changes to (optionally) disable step buttons + $input.addEventListener('input', (event) => this.handleInput(event)) + + $buttonStepDown.addEventListener('click', (event) => + this.handleStepDown(event) + ) + + $buttonStepUp.addEventListener('click', (event) => this.handleStepUp(event)) + + // Initial check to (optionally) disable step buttons + this.handleInput() + + // Show step buttons + $buttonStepDown.removeAttribute('hidden') + $buttonStepUp.removeAttribute('hidden') + + // Create and append the status text for screen readers. + this.$screenReaderStatusMessage = document.createElement('div') + this.$screenReaderStatusMessage.setAttribute('aria-live', 'polite') + this.$screenReaderStatusMessage.classList.add( + 'nhsuk-stepper-input__sr-status', + 'nhsuk-u-visually-hidden' + ) + + this.$input.insertAdjacentElement( + 'afterend', + this.$screenReaderStatusMessage + ) + } + + /** + * Step up number input value + * + * @param {MouseEvent} [event] - Click event + */ + handleStepUp(event) { + this.$input.stepUp() + this.handleInput(event) + } + + /** + * Step down number input value + * + * @param {MouseEvent} [event] - Click event + */ + handleStepDown(event) { + this.$input.stepDown() + this.handleInput(event) + } + + /** + * Handle number input value change + * + * @param {Event | MouseEvent} [event] - Input or click event (optional) + */ + handleInput(event) { + const { $input, config, value } = this + const min = config.min ?? 0 + + // Browsers automatically populate the min or max value using arrow keys + // but we must handle this manually when clicking step buttons + const isEmpty = Number.isNaN($input.valueAsNumber) + const isButton = event?.type === 'click' + + // Polyfill default value on step down + if (isEmpty && event?.currentTarget === this.$buttonStepDown) { + $input.valueAsNumber = min + } + + // Polyfill default value on step up + if (isEmpty && event?.currentTarget === this.$buttonStepUp) { + $input.valueAsNumber = min === 0 ? 1 : min + } + + // Polyfill event dispatch when clicking step buttons + if (isButton && value !== $input.valueAsNumber) { + $input.dispatchEvent(new Event('input', { bubbles: true })) + $input.dispatchEvent(new Event('change', { bubbles: true })) + } + + // Handle input number min value + if (typeof config.min === 'number') { + this.$buttonStepDown.disabled = $input.valueAsNumber <= config.min + } + + // Handle input number max value + if (typeof config.max === 'number') { + this.$buttonStepUp.disabled = $input.valueAsNumber >= config.max + } + + // Announce value when clicking step buttons + if (isButton) { + this.announceInput($input.valueAsNumber) + } + + // Update saved value + this.value = $input.valueAsNumber + } + + /** + * Announce number input value + * + * @param {number} value - Number input value + */ + announceInput(value) { + if (this.value === null) { + return + } + + this.$screenReaderStatusMessage.innerText = `${value}` + } + + /** + * Name for the component used when initialising using data-module attributes + */ + static moduleName = 'nhsuk-stepper-input' + + /** + * Character count default config + * + * @see {@link StepperInputConfig} + * @constant + * @type {StepperInputConfig} + */ + static defaults = Object.freeze({ + step: 1 + }) + + /** + * Character count config schema + * + * @constant + * @satisfies {Schema} + */ + static schema = Object.freeze({ + properties: { + min: { type: 'number' }, + max: { type: 'number' }, + step: { type: 'number' } + } + }) +} + +/** + * Initialise number input component + * + * @deprecated Use {@link createAll | `createAll(StepperInput, options)`} instead. + * @param {InitOptions & Partial} [options] + */ +export function initStepperInputs(options) { + const { scope: $scope } = normaliseOptions(options) + + const $stepperInputs = $scope?.querySelectorAll( + `[data-module="${StepperInput.moduleName}"]` + ) + + $stepperInputs?.forEach(($root) => { + new StepperInput($root, options) + }) +} + +/** + * Stepper input config + * + * @see {@link StepperInput.defaults} + * @typedef {object} StepperInputConfig + * @property {number} [min] - The minimum value + * @property {number} [max] - The maximum value + * @property {number} [step=1] - The stepping interval when changing the value + */ + +/** + * @import { createAll, InitOptions } from '../../index.mjs' + * @import { Schema } from '../../common/configuration/index.mjs' + */ diff --git a/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/template.njk b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/template.njk new file mode 100644 index 0000000000..ce5471ff11 --- /dev/null +++ b/packages/nhsuk-frontend/src/nhsuk/components/stepper-input/template.njk @@ -0,0 +1,148 @@ +{% from "nhsuk/macros/attributes.njk" import nhsukAttributes -%} +{% from "nhsuk/macros/icon.njk" import nhsukIcon -%} +{% from "nhsuk/components/button/macro.njk" import button -%} +{% from "nhsuk/components/input/macro.njk" import input -%} + +{%- set id = params.id if params.id else params.name -%} +{%- set value = params.value | default(0) -%} + +{%- set hasStepDownButtonText = true if params.stepDownButton and (params.stepDownButton.text or params.stepDownButton.html) else false %} +{%- set hasStepUpButtonText = true if params.stepUpButton and (params.stepUpButton.text or params.stepUpButton.html) else false %} + +{% if params.max >= 10000 %} + {% set width = 5 %} +{% elif params.max >= 1000 %} + {% set width = 4 %} +{% else %} + {% set width = 3 %} +{% endif -%} + +{% set attributesHtml -%} + {{- nhsukAttributes({ + "data-module": "nhsuk-stepper-input", + "data-min": { + value: params.min, + optional: true + }, + "data-max": { + value: params.max, + optional: true + }, + "data-step": { + value: params.step, + optional: true + } + }) -}} + + {{- nhsukAttributes(params.formGroup.attributes) -}} +{%- endset -%} + +{%- set beforeInputButtonHtml %} +{{ nhsukIcon("minus") | trim }} +{% if params.stepDownButton.html or params.stepDownButton.text -%} + {{- params.stepDownButton.html | safe | trim if params.stepDownButton.html else params.stepDownButton.text -}} +{%- endif -%} +{% endset -%} + +{%- set beforeInputHtml %} +{%- if params.formGroup.beforeInput %} + {{ params.formGroup.beforeInput.html | safe | trim if params.formGroup.beforeInput.html else params.formGroup.beforeInput.text }} +{% endif %} +{{ button({ + type: "button", + classes: "nhsuk-button--secondary nhsuk-button--icon nhsuk-button--small nhsuk-stepper-input__step-down nhsuk-js-stepper-input-step-down", + html: beforeInputButtonHtml | trim | indent(2), + attributes: { + "aria-controls": id, + "aria-describedby": id ~ "-label", + "aria-label": { + value: params.stepDownButton.visuallyHiddenText | default("Decrease", true) + if not hasStepDownButtonText + else false, + optional: true + }, + "disabled": { + value: params.min is defined and value <= params.min, + optional: true + }, + "hidden": { + value: true, + optional: true + } + } +}) | trim }} +{% endset -%} + +{%- set afterInputButtonHtml %} +{{ nhsukIcon("plus") | trim }} +{% if params.stepUpButton.html or params.stepUpButton.text -%} + {{- params.stepUpButton.html | safe | trim if params.stepUpButton.html else params.stepUpButton.text -}} +{%- endif -%} +{% endset -%} + +{%- set afterInputHtml %} +{{ button({ + type: "button", + classes: "nhsuk-button--secondary nhsuk-button--icon nhsuk-button--small nhsuk-stepper-input__step-up nhsuk-js-stepper-input-step-up", + html: afterInputButtonHtml | trim | indent(2), + attributes: { + "aria-controls": id, + "aria-describedby": id ~ "-label", + "aria-label": { + value: params.stepUpButton.visuallyHiddenText | default("Increase", true) + if not hasStepUpButtonText + else false, + optional: true + }, + "disabled": { + value: params.max is defined and value >= params.max, + optional: true + }, + "hidden": { + value: true, + optional: true + } + } +}) | trim }} +{% if params.formGroup.afterInput %} + {{- params.formGroup.afterInput.html | safe | trim if params.formGroup.afterInput.html else params.formGroup.afterInput.text }} +{% endif -%} +{% endset -%} + +{{ input({ + formGroup: { + classes: "nhsuk-stepper-input" ~ (" " ~ params.formGroup.classes if params.formGroup.classes), + attributes: attributesHtml, + beforeInput: { + html: beforeInputHtml + }, + afterInput: { + html: afterInputHtml + } + }, + label: { + html: params.label.html, + text: params.label.text, + id: id ~ "-label", + classes: params.label.classes, + size: params.label.size, + isPageHeading: params.label.isPageHeading, + attributes: params.label.attributes, + for: id + }, + hint: params.hint, + classes: "nhsuk-stepper-input__input nhsuk-input--width-" ~ width ~ " nhsuk-js-stepper-input-input" ~ (" " ~ params.classes if params.classes), + errorMessage: params.errorMessage, + id: id, + name: params.name, + type: "text", + inputmode: params.inputmode if params.inputmode else "numeric", + spellcheck: false, + autocapitalize: "none", + autocomplete: params.autocomplete, + value: params.value, + disabled: params.disabled, + describedBy: params.describedBy, + pattern: params.pattern, + attributes: params.attributes +}) | trim }} diff --git a/packages/nhsuk-frontend/src/nhsuk/core/objects/_input-wrapper.scss b/packages/nhsuk-frontend/src/nhsuk/core/objects/_input-wrapper.scss index 10826f0818..a817b2251d 100644 --- a/packages/nhsuk-frontend/src/nhsuk/core/objects/_input-wrapper.scss +++ b/packages/nhsuk-frontend/src/nhsuk/core/objects/_input-wrapper.scss @@ -28,6 +28,10 @@ } .nhsuk-input-wrapper { + // Prevent users from accidentally selecting wrapper inputs + // e.g. When double clicking a disabled stepper button + user-select: none; + .nhsuk-input, .nhsuk-select, .nhsuk-button { @@ -71,6 +75,11 @@ // Buttons are normally 100% wide on mobile, but we don't want that here width: auto; } + + // Remove gap with preceding hidden button + .nhsuk-button[hidden] + * { + margin-left: 0; + } } } } diff --git a/packages/nhsuk-frontend/src/nhsuk/index.jsdom.test.mjs b/packages/nhsuk-frontend/src/nhsuk/index.jsdom.test.mjs index 82ff88389f..2330dce5e2 100644 --- a/packages/nhsuk-frontend/src/nhsuk/index.jsdom.test.mjs +++ b/packages/nhsuk-frontend/src/nhsuk/index.jsdom.test.mjs @@ -7,6 +7,7 @@ import { ErrorSummary, Header, NotificationBanner, + StepperInput, PasswordInput, Radios, SkipLink, @@ -27,6 +28,7 @@ jest.mock('./components/error-summary/error-summary.mjs') jest.mock('./components/file-upload/file-upload.mjs') jest.mock('./components/header/header.mjs') jest.mock('./components/notification-banner/notification-banner.mjs') +jest.mock('./components/stepper-input/stepper-input.mjs') jest.mock('./components/password-input/password-input.mjs') jest.mock('./components/radios/radios.mjs') jest.mock('./components/skip-link/skip-link.mjs') @@ -44,6 +46,7 @@ describe('NHS.UK frontend', () => { 'CharacterCount', 'ErrorSummary', 'NotificationBanner', + 'StepperInput', 'PasswordInput' ] @@ -75,6 +78,7 @@ describe('NHS.UK frontend', () => { expect(NHSUKFrontend).toHaveProperty('ErrorSummary') expect(NHSUKFrontend).toHaveProperty('Header') expect(NHSUKFrontend).toHaveProperty('NotificationBanner') + expect(NHSUKFrontend).toHaveProperty('StepperInput') expect(NHSUKFrontend).toHaveProperty('PasswordInput') expect(NHSUKFrontend).toHaveProperty('Radios') expect(NHSUKFrontend).toHaveProperty('SkipLink') @@ -93,6 +97,7 @@ describe('NHS.UK frontend', () => { expect(NHSUKFrontend).toHaveProperty('initErrorSummary') expect(NHSUKFrontend).toHaveProperty('initHeader') expect(NHSUKFrontend).toHaveProperty('initNotificationBanners') + expect(NHSUKFrontend).toHaveProperty('initStepperInputs') expect(NHSUKFrontend).toHaveProperty('initPasswordInputs') expect(NHSUKFrontend).toHaveProperty('initRadios') expect(NHSUKFrontend).toHaveProperty('initSkipLinks') @@ -111,6 +116,7 @@ describe('NHS.UK frontend', () => {
+
@@ -222,6 +228,7 @@ describe('NHS.UK frontend', () => { expect(ErrorSummary).not.toHaveBeenCalled() expect(Header).not.toHaveBeenCalled() expect(NotificationBanner).not.toHaveBeenCalled() + expect(StepperInput).not.toHaveBeenCalled() expect(PasswordInput).not.toHaveBeenCalled() expect(Radios).not.toHaveBeenCalled() expect(SkipLink).not.toHaveBeenCalled() @@ -246,6 +253,7 @@ describe('NHS.UK frontend', () => { expect(ErrorSummary).not.toHaveBeenCalled() expect(Header).not.toHaveBeenCalled() expect(NotificationBanner).not.toHaveBeenCalled() + expect(StepperInput).not.toHaveBeenCalled() expect(PasswordInput).not.toHaveBeenCalled() expect(Radios).not.toHaveBeenCalled() expect(SkipLink).not.toHaveBeenCalled() @@ -270,6 +278,7 @@ describe('NHS.UK frontend', () => { expect(ErrorSummary).not.toHaveBeenCalled() expect(Header).not.toHaveBeenCalled() expect(NotificationBanner).not.toHaveBeenCalled() + expect(StepperInput).not.toHaveBeenCalled() expect(PasswordInput).not.toHaveBeenCalled() expect(Radios).not.toHaveBeenCalled() expect(SkipLink).not.toHaveBeenCalled() diff --git a/packages/nhsuk-frontend/src/nhsuk/index.mjs b/packages/nhsuk-frontend/src/nhsuk/index.mjs index d1966b70cc..b0bda6a60a 100644 --- a/packages/nhsuk-frontend/src/nhsuk/index.mjs +++ b/packages/nhsuk-frontend/src/nhsuk/index.mjs @@ -8,6 +8,7 @@ import { FileUpload, Header, NotificationBanner, + StepperInput, PasswordInput, Radios, SkipLink, @@ -75,6 +76,7 @@ export function initAll(scopeOrConfig = {}) { [FileUpload, config.fileUpload], [Header, config.header], [NotificationBanner, config.notificationBanner], + [StepperInput, config.stepperInput], [PasswordInput, config.passwordInput], [Radios, config.radios], [SkipLink, config.skipLink], @@ -210,6 +212,7 @@ export * from './errors/index.mjs' * @property {ComponentConfig} [fileUpload] - File upload config * @property {ComponentConfig} [header] - Header config * @property {ComponentConfig} [notificationBanner] - Notification Banner config + * @property {ComponentConfig} [stepperInput] - Number Input config * @property {ComponentConfig} [passwordInput] - Password Input config * @property {ComponentConfig} [radios] - Radios config * @property {ComponentConfig} [skipLink] - Skip Link config diff --git a/packages/nhsuk-frontend/src/nhsuk/lib/components.mjs b/packages/nhsuk-frontend/src/nhsuk/lib/components.mjs index 32611fa56d..07588c639a 100644 --- a/packages/nhsuk-frontend/src/nhsuk/lib/components.mjs +++ b/packages/nhsuk-frontend/src/nhsuk/lib/components.mjs @@ -19,7 +19,7 @@ export function render(component, options) { * Nunjucks macro option config * * @typedef {object} MacroParam - * @property {'array' | 'boolean' | 'integer' | 'nunjucks-block' | 'object' | 'string'} type - Option type + * @property {'array' | 'boolean' | 'number' | 'integer' | 'nunjucks-block' | 'object' | 'string'} type - Option type * @property {boolean} required - Option required * @property {string} description - Option description * @property {true} [isComponent] - Option is another component diff --git a/packages/nhsuk-frontend/src/nhsuk/macros/icon.njk b/packages/nhsuk-frontend/src/nhsuk/macros/icon.njk index 9e4313a8a3..1d9a126c9d 100644 --- a/packages/nhsuk-frontend/src/nhsuk/macros/icon.njk +++ b/packages/nhsuk-frontend/src/nhsuk/macros/icon.njk @@ -38,10 +38,14 @@ {% elif name == "cross" %} -{% elif name == "search" %} - {% elif name == "tick" %} +{% elif name == "minus" %} + +{% elif name == "plus" %} + +{% elif name == "search" %} + {% elif name == "user" %} {% endif %} diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_desktop.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_desktop.png new file mode 100644 index 0000000000..8e990d06fd Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_desktop.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_mobile.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_mobile.png new file mode 100644 index 0000000000..3341d2dbba Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_mobile.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_tablet.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_tablet.png new file mode 100644 index 0000000000..7dc3a9e91d Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_tablet.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_watch.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_watch.png new file mode 100644 index 0000000000..916def3b1d Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_and_error_watch.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_desktop.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_desktop.png new file mode 100644 index 0000000000..b4d63d79c3 Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_desktop.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_mobile.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_mobile.png new file mode 100644 index 0000000000..264f1f2d0f Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_mobile.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_tablet.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_tablet.png new file mode 100644 index 0000000000..adc9bc96e6 Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_tablet.png differ diff --git a/tests/backstop/bitmaps_reference/Stepper_input_with_hint_watch.png b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_watch.png new file mode 100644 index 0000000000..a9eaddf9a2 Binary files /dev/null and b/tests/backstop/bitmaps_reference/Stepper_input_with_hint_watch.png differ