diff --git a/.eleventy.js b/.eleventy.js index a4cdd54e..996107d5 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -98,6 +98,8 @@ module.exports = function (eleventyConfig) { }); }); + eleventyConfig.addShortcode("dateInCurrentMonth", (day) => `${day}/${new Date().getMonth()+1}/${new Date().getFullYear()}`); + eleventyConfig.addShortcode("lastUpdated", function (component) { if (process.env.STAGING) return ''; diff --git a/Dockerfile b/Dockerfile index a4990e4c..c9726233 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ COPY package.json package.json COPY package-lock.json package-lock.json RUN npm ci -COPY assets assets COPY docs docs COPY src src COPY package package diff --git a/docs/_includes/arguments/date-picker.md b/docs/_includes/arguments/date-picker.md new file mode 100644 index 00000000..0789071e --- /dev/null +++ b/docs/_includes/arguments/date-picker.md @@ -0,0 +1,16 @@ +| Name | Type | Required | Description | +| ------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| id | string | Yes | The ID of the input. | +| name | string | Yes | The name of the input, which is submitted with the form data. | +| value | string | No | Optional initial value of the input. | +| formGroup | object | No | Additional options for the form group containing the text input component. See [formGroup](#options-date-picker-form-group). | +| label | object | Yes | The label used by the text input component. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for label options. | +| hint | object | No | Can be used to add a hint to a text input component. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for hint options. | +| errorMessage | object | No | Can be used to add an error message to the text input component. The error message component will not display if you use a falsy value for `errorMessage`, for example `false` or `null`. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for errorMessage options. | +| minDate | string | No | Earliest date that can be selected (format dd/mm/yyyy) | +| maxDate | string | No | Latest date that can be selected (format dd/mm/yyyy) | +| exludedDates | string | No | String of space separated dates that cannot be selected | +| excludedDays | string | No | String of space separated days of the week that cannot be selected | +| weekStartDay | string | No | Day of the week the calendar starts on. Either 'monday' or 'sunday'. Defaults to 'monday' | + + diff --git a/docs/_includes/example.njk b/docs/_includes/example.njk index cb7f1763..24a3f222 100644 --- a/docs/_includes/example.njk +++ b/docs/_includes/example.njk @@ -6,10 +6,22 @@
diff --git a/docs/_includes/layouts/home.njk b/docs/_includes/layouts/home.njk index f68bb7ed..92c41ed4 100644 --- a/docs/_includes/layouts/home.njk +++ b/docs/_includes/layouts/home.njk @@ -24,6 +24,17 @@
+
+
+
+

What’s new

+

29 July 2024: We’ve released a new date picker component to help users select a date quickly and easily.

+

Sign up to get emails about the MoJ Design System.

+
+
+
+
+
@@ -32,7 +43,7 @@

Components

Save time with reusable, accessible components for forms, navigation, panels, tables and more.

- Find a component + Find a component

@@ -44,13 +55,15 @@

Patterns

Help users complete common tasks like uploading files, filtering lists, and getting help.

- Find a pattern + Find a pattern

+
+ {{ content | safe }}
diff --git a/docs/_includes/layouts/partials/suggest-a-change-and-help.njk b/docs/_includes/layouts/partials/suggest-a-change-and-help.njk index c6f26d1e..91a42fb8 100644 --- a/docs/_includes/layouts/partials/suggest-a-change-and-help.njk +++ b/docs/_includes/layouts/partials/suggest-a-change-and-help.njk @@ -3,25 +3,25 @@
-
+
-

- Suggest a change -

-

- To help improve the MoJ Design System, you can suggest changes. -

+

+ Suggest a change +

- Tell us about the change you're proposing by using the suggest a change form. The MoJ Design System Group will be notified of your suggestion and will review it. + You can suggest a change to improve the MoJ Design System. +

+

+ The MoJ Design System team will review it.


-

- Need help? -

+

+ Get help +

- The MoJ Design System Group provides support for users of the MoJ Design System. Contact us to ask for help. + Contact the MoJ Design System team for support.

diff --git a/docs/assets/images/date-picker-filter-example.svg b/docs/assets/images/date-picker-filter-example.svg new file mode 100644 index 00000000..70642425 --- /dev/null +++ b/docs/assets/images/date-picker-filter-example.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/assets/images/date-picker-question-example.svg b/docs/assets/images/date-picker-question-example.svg new file mode 100644 index 00000000..a69d561c --- /dev/null +++ b/docs/assets/images/date-picker-question-example.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/assets/stylesheets/components/_example.scss b/docs/assets/stylesheets/components/_example.scss index cc7c8ceb..b904bce0 100644 --- a/docs/assets/stylesheets/components/_example.scss +++ b/docs/assets/stylesheets/components/_example.scss @@ -24,7 +24,7 @@ } .app-example__new-window { - @include govuk-font($size: 14); + @include govuk-font($size: 16); border: 1px solid $govuk-border-colour; position: absolute; top: -1px; left: -1px; @@ -34,18 +34,18 @@ background-color: white; color: govuk-colour("blue"); display: block; - padding: 5px 10px; + margin: 8px; text-decoration: none; } a:hover { - color: govuk-colour("light-blue"); + color: $govuk-link-hover-colour; } a:focus { - // color: $govuk-focus-text-colour; + color: $govuk-focus-text-colour; background-color: $govuk-focus-colour; - // box-shadow: 0 -2px $govuk-focus-colour, 0 4px $govuk-focus-text-colour; + box-shadow: 0 -2px $govuk-focus-colour, 0 4px $govuk-focus-text-colour; // border-color: $govuk-focus-text-colour; } diff --git a/docs/community/suggest-a-change.md b/docs/community/suggest-a-change.md index 8a5b136d..1866c21b 100644 --- a/docs/community/suggest-a-change.md +++ b/docs/community/suggest-a-change.md @@ -1,6 +1,6 @@ --- +title: Suggest a Change layout: layouts/community.njk -title: Suggest a change --- To help improve the MoJ Design System, you can suggest changes to components and patterns. diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md index 988c7261..a20a4456 100644 --- a/docs/components/date-picker.md +++ b/docs/components/date-picker.md @@ -3,51 +3,115 @@ layout: layouts/component.njk title: Date picker --- -This component has recently been contributed to the MoJ Design System and is being developed. +The date picker component enables users to select a date from a calendar. -## Status of development +{% example "/examples/date-picker", 590 %} -The below criteria all need to be met for a component to be considered as fully developed for use within the MoJ Design System. -This page will be updated as the component is developed. +## Overview + +When users first open the date picker's calendar it'll show today's date. Users do not have to use this calendar view to select a date - they can also enter one directly into the text field. + +### When to use + +Users might want to use the calendar view: + +- for a relative date or one they need to look up, for example last Thursday or next Wednesday +- to enter today's date more quickly +- for available dates only, such as for prison visits + +### When not to use + +Do not use the date picker: + +- for a memorable date, such as a user's date of birth +- for a date that users know or can easily look up, like an appointment date on a letter +- when only a rough date is needed, for example just a month and year + +Use the [GOV.UK Design System's date input component](https://design-system.service.gov.uk/components/date-input/) instead. + +### Things to consider + +Date pickers are fully navigable using a keyboard, but can be slow for keyboard-only and screen reader users. + +### Similar or linked components + +There's also the ['Ask users for dates' pattern in the GOV.UK Design System](https://design-system.service.gov.uk/patterns/dates/). + + +## How to use + +### Hint text + +The date picker hint text is set to 17/5/2024. This can be changed to a more helpful date, for example the start of a scheme. Add a full stop at the end. + +### Excluding dates + +You can exclude (or disable) specific dates and days of the week from the date picker, for example bank holidays or every weekend. + +{% example "/examples/date-picker-excluded-dates", 590 %} + +You need to add server-side validation for when users enter an unavailable date directly into the text field (rather than in the calendar). This will show them an error message. + +Excluded dates have the correct colour contrast ratio with the date text and calendar background. This is WCAG 2.2 compliant. However, these dates may be harder to view for users with low vision or colour blindness, so there’s also a strikethrough. Numbers with a strikethrough can be harder for people with dyscalculia to read. + +If there are not many available dates, users will have to navigate a lot to find one. Consider listing these dates with radio buttons instead. + +### Error messages + +Follow the [GOV.UK Design System guidance on error messages](https://design-system.service.gov.uk/components/error-message/). + +{% example "/examples/date-picker-error", 590 %} - - + + - - + + - - + + - - + + - - + + - - + +
Development criteriaStatusError stateError message
WCAG 2.2 compliant - Being reviewed - No date is entered or selected from the calendarEnter or select a date
HTML / Nunjucks version - In progress - The date is in the wrong formatEnter the date in the correct format, for example, 17/5/2024
Figma version - In progress - The date does not existEnter a real date
Documentation - Being reviewed - The date is incompleteEnter a full date, for example 17/5/2024
Researched and tested - Not started - The date is excludedSelect an available date from the calendar
+ +### Using multiple date pickers + +If you're using more than one date picker, give each text field its own error summary and message (even if the error is the same). + + +## Examples + +### Filtering information with a date picker + +

A screenshot with the title 'Attended appointments'. In a grey box is the title Filter, underneath is the title Date and then a text input field. The calendar icon and a green 'Apply filter' button is on the right. Below this element is the text '7 appointments'. Details of these appointments are shown.

+ +### Asking a question with a date picker + +

A screenshot with the title 'What date do you want to view appointments for?' Underneath is the title 'Date' and then a text input field with the calendar icon. Underneath that is a green 'Continue' button.

+ + +## Contributors + +Thanks to Dom Billington, Eddie Shannon, David Middleton, and the DPS Connect team for contributing this component. + +This component was based on the [Scottish Government Design System date picker](https://designsystem.gov.scot/components/date-picker). diff --git a/docs/examples/date-picker-error/index.njk b/docs/examples/date-picker-error/index.njk new file mode 100644 index 00000000..c45ad008 --- /dev/null +++ b/docs/examples/date-picker-error/index.njk @@ -0,0 +1,22 @@ +--- +layout: layouts/example.njk +title: Date Picker (example) +arguments: date-picker +figma_link: https://www.figma.com/design/N2xqOFkyehXwcD9DxU1gEq/MoJ-Figma-Kit?node-id=792-861&t=6DfPOX7RAnjrVE0j-0 +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{{ mojDatePicker({ + id: "date", + name: "date", + label: { + text: "Date" + }, + hint: { + text: "For example, 17/5/2024." + }, + errorMessage: { + text: 'Enter or select a date' + } +}) }} diff --git a/docs/examples/date-picker-excluded-dates/index.njk b/docs/examples/date-picker-excluded-dates/index.njk new file mode 100644 index 00000000..1f0e1444 --- /dev/null +++ b/docs/examples/date-picker-excluded-dates/index.njk @@ -0,0 +1,23 @@ +--- +layout: layouts/example.njk +title: Date Picker Excluded Dates (example) +figma_link: https://www.figma.com/design/N2xqOFkyehXwcD9DxU1gEq/MoJ-Figma-Kit?node-id=792-861&t=6DfPOX7RAnjrVE0j-0 +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{{ mojDatePicker({ + id: "date", + name: "date", + label: { + text: "Date" + }, + hint: { + text: "For example, 17/5/2024." + }, + value: "10/04/2025", + minDate: "01/04/2025", + maxDate: "30/04/2025", + excludedDates: "02/04/2025 18/04/2025 21/04/2025", + excludedDays: "saturday sunday" +}) }} diff --git a/docs/examples/date-picker-excluded-days/index.njk b/docs/examples/date-picker-excluded-days/index.njk new file mode 100644 index 00000000..9c04a9c3 --- /dev/null +++ b/docs/examples/date-picker-excluded-days/index.njk @@ -0,0 +1,19 @@ +--- +layout: layouts/example.njk +title: Date Picker Excluded Days (example) +figma_link: https://www.figma.com/design/N2xqOFkyehXwcD9DxU1gEq/MoJ-Figma-Kit?node-id=792-861&t=6DfPOX7RAnjrVE0j-0 +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{{ mojDatePicker({ + id: "date", + name: "date", + label: { + text: "Date" + }, + hint: { + text: "For example, 17/5/2024." + }, + disabledDays: "saturday sunday" +}) }} diff --git a/docs/examples/date-picker-horizontal-pair/index.njk b/docs/examples/date-picker-horizontal-pair/index.njk new file mode 100644 index 00000000..9374144b --- /dev/null +++ b/docs/examples/date-picker-horizontal-pair/index.njk @@ -0,0 +1,34 @@ +--- +layout: layouts/example.njk +title: Date Picker Vertical Pair (example) +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +
+
+{{ mojDatePicker({ + id: "from-date", + name: "from-date", + label: { + text: "From" + }, + hint: { + text: "For example, 17/5/2024." + } +}) }} +
+
+ +{{ mojDatePicker({ + id: "to-date", + name: "to-date", + label: { + text: "To" + }, + hint: { + text: "For example, 17/5/2024." + } +}) }} +
+
diff --git a/docs/examples/date-picker-min-max/index.njk b/docs/examples/date-picker-min-max/index.njk new file mode 100644 index 00000000..cf34768a --- /dev/null +++ b/docs/examples/date-picker-min-max/index.njk @@ -0,0 +1,23 @@ +--- +layout: layouts/example.njk +title: Date Picker Min and Max Date (example) +figma_link: https://www.figma.com/design/N2xqOFkyehXwcD9DxU1gEq/MoJ-Figma-Kit?node-id=792-861&t=6DfPOX7RAnjrVE0j-0 +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{% set minDate %}{% dateInCurrentMonth 05 %}{% endset %} +{% set maxDate %}{% dateInCurrentMonth 25 %}{% endset %} + +{{ mojDatePicker({ + id: "date", + name: "date", + label: { + text: "Date" + }, + hint: { + text: "For example, 17/5/2024." + }, + minDate: minDate, + maxDate: maxDate +}) }} diff --git a/docs/examples/date-picker-vertical-pair/index.njk b/docs/examples/date-picker-vertical-pair/index.njk new file mode 100644 index 00000000..c35818a3 --- /dev/null +++ b/docs/examples/date-picker-vertical-pair/index.njk @@ -0,0 +1,28 @@ +--- +layout: layouts/example.njk +title: Date Picker Vertical Pair (example) +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{{ mojDatePicker({ + id: "from-date", + name: "from-date", + label: { + text: "From" + }, + hint: { + text: "For example, 17/5/2024." + } +}) }} + +{{ mojDatePicker({ + id: "to-date", + name: "to-date", + label: { + text: "To" + }, + hint: { + text: "For example, 17/5/2024." + } +}) }} diff --git a/docs/examples/date-picker/index.njk b/docs/examples/date-picker/index.njk new file mode 100644 index 00000000..8c5012eb --- /dev/null +++ b/docs/examples/date-picker/index.njk @@ -0,0 +1,19 @@ +--- +layout: layouts/example.njk +title: Date Picker (example) +arguments: date-picker +figma_link: https://www.figma.com/design/N2xqOFkyehXwcD9DxU1gEq/MoJ-Figma-Kit?node-id=792-861&t=6DfPOX7RAnjrVE0j-0 +--- + +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} + +{{ mojDatePicker({ + id: "date", + name: "date", + label: { + text: "Date" + }, + hint: { + text: "For example, 17/5/2024." + } +}) }} diff --git a/docs/index.md b/docs/index.md index 17042dc9..6b6bc623 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,6 @@ layout: layouts/home.njk title: Design, build, and deliver accessible and consistent services --- ---- - ## Contribute to the MoJ Design System Anyone can contribute to the MoJ Design System by proposing a new style, component, or pattern. diff --git a/gulpfile.js b/gulpfile.js index c55fadf7..3899269c 100755 --- a/gulpfile.js +++ b/gulpfile.js @@ -38,7 +38,7 @@ gulp.task( gulp.task( "watch:styles", () => { gulp.watch( - ["docs/assets/**.*.scss", "src/moj/components/**/*.scss"], + ["docs/assets/**/*.scss", "src/moj/components/**/*.scss"], gulp.series(["docs:styles"]), ) } diff --git a/package-lock.json b/package-lock.json index cfacd208..0b1800a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "clipboard": "^2.0.8", "del": "^7.0.0", "esbuild": "^0.23.0", - "govuk-frontend": "^5.0.0", + "govuk-frontend": "^5.4.1", "gulp": "^4.0.2", "gulp-cache": "^1.1.3", "gulp-concat": "^2.6.1", @@ -10947,9 +10947,10 @@ } }, "node_modules/govuk-frontend": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-5.1.0.tgz", - "integrity": "sha512-Dc3J+uOI4i2VR3BVyfxbf6qVjTT4n4bBqbD0/Io6feP8pt/4IfKdP1vWimZf+BwMKKMXacw10hmdy5UcD6Cr8w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-5.4.1.tgz", + "integrity": "sha512-Gmd8LV++TRh9OF6tA+9KQTpwvlsLcri7qRjViz9ji4YuwZvX+c9TD7tyE+dnJcqsQsJfhr9Fp38m3Hu3H7EIcQ==", + "license": "MIT", "engines": { "node": ">= 4.2.0" } @@ -15157,48 +15158,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.0", "dev": true, @@ -15214,87 +15173,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/@isaacs/string-locale-compare": { "version": "1.1.0", "dev": true, @@ -18029,6 +17907,21 @@ "node": ">=8" } }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/npm/node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -18041,6 +17934,19 @@ "node": ">=8" } }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/npm/node_modules/supports-color": { "version": "9.4.0", "dev": true, @@ -18255,6 +18161,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.0.1", "dev": true, @@ -32054,9 +31978,9 @@ } }, "govuk-frontend": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-5.1.0.tgz", - "integrity": "sha512-Dc3J+uOI4i2VR3BVyfxbf6qVjTT4n4bBqbD0/Io6feP8pt/4IfKdP1vWimZf+BwMKKMXacw10hmdy5UcD6Cr8w==" + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-5.4.1.tgz", + "integrity": "sha512-Gmd8LV++TRh9OF6tA+9KQTpwvlsLcri7qRjViz9ji4YuwZvX+c9TD7tyE+dnJcqsQsJfhr9Fp38m3Hu3H7EIcQ==" }, "graceful-fs": { "version": "4.2.10", @@ -34885,36 +34809,6 @@ "strip-ansi": "^7.0.1" } }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, "strip-ansi": { "version": "7.1.0", "bundled": true, @@ -34922,61 +34816,6 @@ "requires": { "ansi-regex": "^6.0.1" } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - } - } - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "string-width": { - "version": "4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } } } }, @@ -36835,6 +36674,16 @@ "strip-ansi": "^6.0.1" } }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "bundled": true, + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "strip-ansi": { "version": "6.0.1", "bundled": true, @@ -36843,6 +36692,14 @@ "ansi-regex": "^5.0.1" } }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, "supports-color": { "version": "9.4.0", "bundled": true, @@ -37030,6 +36887,16 @@ } } }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "bundled": true, diff --git a/package.json b/package.json index 704aa426..b4403a10 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "clipboard": "^2.0.8", "del": "^7.0.0", "esbuild": "^0.23.0", - "govuk-frontend": "^5.0.0", + "govuk-frontend": "^5.4.1", "gulp": "^4.0.2", "gulp-cache": "^1.1.3", "gulp-concat": "^2.6.1", diff --git a/src/moj/all.js b/src/moj/all.js index a4090064..f7c41dda 100644 --- a/src/moj/all.js +++ b/src/moj/all.js @@ -66,4 +66,9 @@ MOJFrontend.initAll = function (options) { table: $table }); }); + + const $datepickers = document.querySelectorAll('[data-module="moj-date-picker"]') + MOJFrontend.nodeListForEach($datepickers, function ($datepicker) { + new MOJFrontend.DatePicker($datepicker, {}).init(); + }) } diff --git a/src/moj/components/_all.scss b/src/moj/components/_all.scss index ffa08e9f..19812f8e 100755 --- a/src/moj/components/_all.scss +++ b/src/moj/components/_all.scss @@ -5,6 +5,7 @@ @import "button-menu/button-menu"; @import "cookie-banner/cookie-banner"; @import "currency-input/currency-input"; +@import "date-picker/date-picker"; @import "filter/filter"; @import "header/header"; @import "identity-bar/identity-bar"; diff --git a/src/moj/components/date-picker/README.md b/src/moj/components/date-picker/README.md new file mode 100644 index 00000000..ea4b42e1 --- /dev/null +++ b/src/moj/components/date-picker/README.md @@ -0,0 +1,36 @@ +# Date picker + +- [Guidance](https://design-patterns.service.justice.gov.uk/components/date-picker +picker) + +## Example + +``` +{{ mojDatePicker({ + id: "appointment-date", + name: "appointment-date" + label: "Appointment date" + hint: For example, 17/5/2024. +}) }} +``` + +## Arguments + +This component accepts the following arguments. + +| Name | Type | Required | Description | +| ------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| id | string | Yes | The ID of the input. | +| name | string | Yes | The name of the input, which is submitted with the form data. | +| value | string | No | Optional initial value of the input. | +| formGroup | object | No | Additional options for the form group containing the text input component. See [formGroup](#options-date-picker-form-group). | +| label | object | Yes | The label used by the text input component. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for label options. | +| hint | object | No | Can be used to add a hint to a text input component. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for hint options. | +| errorMessage | object | No | Can be used to add an error message to the text input component. The error message component will not display if you use a falsy value for `errorMessage`, for example `false` or `null`. See [GOV.UK text input documentation](https://design-system.service.gov.uk/components/text-input/) for errorMessage options. | +| minDate | string | No | Earliest date that can be selected (format dd/mm/yyyy) | +| maxDate | string | No | Latest date that can be selected (format dd/mm/yyyy) | +| exludedDates | string | No | String of pace separated dates that cannot be selected | +| excludedDays | string | No | String of space separated days of the week that cannot be selected | +| weekStartDay | string | No | Day of the week the calendar starts on. Either 'monday' or 'sunday'. Defaults to 'monday' | + + diff --git a/src/moj/components/date-picker/_date-picker.scss b/src/moj/components/date-picker/_date-picker.scss new file mode 100644 index 00000000..c218e57b --- /dev/null +++ b/src/moj/components/date-picker/_date-picker.scss @@ -0,0 +1,293 @@ +// Custom colour required for passing WCAG 2.2 AA contrast text/background and +// background/surrounding +$moj-datepicker-mid-grey: #949494; + +.moj-datepicker { + position: relative; + @include govuk-font(16); +} + +.moj-datepicker__dialog { + display: none; + position: absolute; + top: 0; + min-width: 280px; + padding: govuk-spacing(4); + outline: 2px solid $govuk-text-colour; + outline-offset: -2px; + background-color: govuk-colour('white'); + transition: background-color 0.2s, outline-color 0.2s; + z-index: 2; +} + +.moj-datepicker__dialog--open { + display: block; +} + +.moj-datepicker__dialog-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: govuk-spacing(2); +} + +.moj-datepicker__dialog-title { + @include govuk-font(16); + font-weight: bold; + margin-top: 0; + margin-bottom: 0; +} + +.moj-datepicker__dialog-navbuttons { + display: flex; + align-items: center; +} + +.moj-datepicker__calendar { + border-collapse: collapse; + margin-bottom: govuk-spacing(4); + + tbody:focus-within { + outline: 2px solid $govuk-focus-colour; + } + + td { + border: 0; + margin: 0; + outline: 0; + padding: 0; + } + + th { + @include govuk-font(16); + font-weight: bold; + color: $govuk-text-colour; + } + +} + +.moj-datepicker__dialog > .govuk-button-group { + margin-bottom: 0; + + > * { + margin-bottom: 0; + } +} + +.moj-datepicker__button { + @include govuk-font(16); + background-color: transparent; + outline: 2px solid rgba(0, 0, 0, 0); + outline-offset: -2px; + border-width: 0; + color: $govuk-text-colour; + height: 40px; + margin: 0; + padding: 0; + width: 44px; + position: relative; + + @media (forced-colors: active) { + // Don't show the bottom bar in forced-color modes as it blocks the outline + &:after { + display: none + } + } + + &:after { + content: ""; + position: absolute; + bottom: 0px; + height: 4px; + left: 0; + right: 0; + background-color: transparent; + } + + &[aria-disabled="true"], + &[aria-disabled="true"]:hover { + background-color: govuk-colour('light-grey'); + color: $govuk-text-colour; + cursor: not-allowed; + text-decoration: line-through; + } + + &:hover { + color: $govuk-text-colour; + background-color: $moj-datepicker-mid-grey; + text-decoration: none; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + cursor: pointer; + } + + &:focus { + color: $govuk-text-colour; + background-color: $govuk-focus-colour; + outline-color: transparent; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + &:after { + background-color: $govuk-text-colour; + } + } + + &:focus:hover { + background-color: $moj-datepicker-mid-grey; + outline-color: $govuk-focus-colour; + &:after { + background-color: transparent; + } + } + + &--current:not(:focus) { + background-color: $govuk-link-colour; + color: govuk-colour('white'); + outline-color: $govuk-link-colour; + &:after { + background-color: $govuk-link-colour; + } + } + + &--current[tabindex="-1"] { + background: transparent; + color: currentColor; + outline-color: transparent; + &:after { + background-color: transparent; + } + } + + &--today { + border: 2px solid $govuk-text-colour; + } + + &--selected:not(:focus) { + background-color: $govuk-link-colour; + color: govuk-colour('white'); + + &:after { + background-color: $govuk-link-colour; + } + + &:hover { + outline-color: $govuk-link-colour; + background-color: $moj-datepicker-mid-grey; + color: $govuk-text-colour; + + &:after { + background-color: transparent; + } + } + } + +} + +/* + Default input with to .govuk-input--width-10 (10 chars) + Allow that to be overriden by the input width modifiers or global width overrides. + Width classes less than 10ch not included as that is narrower than a date. +*/ +.moj-datepicker input { + max-width: 11.5em; // govuk-input--width-10 + + &.govuk-input--width-30 { + max-width: 29.5em; + } + + &.govuk-input--width-20 { + max-width: 20.5em; + } + + &.govuk-\!-width-full { + width: 100% !important; + max-width: none; + } + + &.govuk-\!-width-three-quarters { + width: 100% !important; + max-width: none; + + @include govuk-media-query($from: tablet) { + width: 75% !important; + } + } + + &.govuk-\!-width-two-thirds { + width: 100% !important; + max-width: none; + + @include govuk-media-query($from: tablet) { + width: 66.66% !important; + } + } + + &.govuk-\!-width-one-half { + width: 100% !important; + max-width: none; + + @include govuk-media-query($from: tablet) { + width: 50% !important; + } + } + + &.govuk-\!-width-one-third { + width: 100% !important; + max-width: none; + + @include govuk-media-query($from: tablet) { + width: 33.33% !important; + } + } + + &.govuk-\!-width-one-quarter { + width: 100% !important; + max-width: none; + + @include govuk-media-query($from: tablet) { + width: 25% !important; + } + } +} + +.moj-datepicker__wrapper { + position: relative; +} + + +@media (min-width: 768px) { + .moj-datepicker__dialog { + width: auto; + } +} + +.moj-datepicker__toggle { + background-color: $govuk-text-colour; + color: govuk-colour('white'); + outline: 3px solid rgba(0, 0, 0, 0); + outline-offset: -3px; + height: 40px; + padding-top: 6px; + border: none; + border-bottom: 4px solid rgba(0, 0, 0, 0); + cursor: pointer; + + &:focus { + background-color: $govuk-focus-colour; + color: $govuk-text-colour; + border-bottom: 4px solid $govuk-text-colour; + } + + &:hover { + background-color: $moj-datepicker-mid-grey; + color: $govuk-text-colour; + border-bottom: 4px solid $moj-datepicker-mid-grey; + } + + &:focus:hover { + background-color: $moj-datepicker-mid-grey; + color: $govuk-text-colour; + border-bottom: 4px solid $govuk-text-colour; + } +} diff --git a/src/moj/components/date-picker/date-picker.js b/src/moj/components/date-picker/date-picker.js new file mode 100644 index 00000000..48fc6976 --- /dev/null +++ b/src/moj/components/date-picker/date-picker.js @@ -0,0 +1,933 @@ +/** + * Datepicker config + * + * @typedef {object} DatepickerConfig + * @property {string} [excludedDates] - Dates that cannot be selected + * @property {string} [excludedDays] - Days that cannot be selected + * @property {boolean} [leadingZeroes] - Whether to add leading zeroes when populating the field + * @property {string} [minDate] - The earliest available date + * @property {string} [maxDate] - The latest available date + * @property {string} [weekStartDay] - First day of the week in calendar view + */ + +/** + * @param {HTMLElement} $module - HTML element + * @param {DatepickerConfig} config - config object + * @constructor + */ +function Datepicker($module, config) { + if (!$module) { + return this; + } + + const schema = Object.freeze({ + properties: { + excludedDates: { type: "string" }, + excludedDays: { type: "string" }, + leadingZeros: { type: "string" }, + maxDate: { type: "string" }, + minDate: { type: "string" }, + weekStartDay: { type: "string" }, + }, + }); + + const defaults = { + leadingZeros: false, + weekStartDay: "monday", + }; + + // data attributes override JS config, which overrides defaults + this.config = this.mergeConfigs( + defaults, + config, + this.parseDataset(schema, $module.dataset), + ); + + this.dayLabels = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + + this.monthLabels = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + this.currentDate = new Date(); + this.currentDate.setHours(0, 0, 0, 0); + this.calendarDays = []; + this.excludedDates = []; + this.excludedDays = []; + + this.buttonClass = "moj-datepicker__button"; + this.selectedDayButtonClass = "moj-datepicker__button--selected"; + this.currentDayButtonClass = "moj-datepicker__button--current"; + this.todayButtonClass = "moj-datepicker__button--today"; + + this.$module = $module; + this.$input = $module.querySelector(".moj-js-datepicker-input"); +} + +Datepicker.prototype.init = function () { + // Check that required elements are present + if (!this.$input) { + return; + } + + this.setOptions(); + this.initControls(); +}; + +Datepicker.prototype.initControls = function () { + this.id = `datepicker-${this.$input.id}`; + + this.$dialog = this.createDialog(); + this.createCalendarHeaders(); + + const $componentWrapper = document.createElement("div"); + const $inputWrapper = document.createElement("div"); + $componentWrapper.classList.add("moj-datepicker__wrapper"); + $inputWrapper.classList.add("govuk-input__wrapper"); + + this.$input.parentNode.insertBefore($componentWrapper, this.$input); + $componentWrapper.appendChild($inputWrapper); + $inputWrapper.appendChild(this.$input); + + $inputWrapper.insertAdjacentHTML("beforeend", this.toggleTemplate()); + $componentWrapper.insertAdjacentElement("beforeend", this.$dialog); + + this.$calendarButton = this.$module.querySelector( + ".moj-js-datepicker-toggle", + ); + this.$dialogTitle = this.$dialog.querySelector( + ".moj-js-datepicker-month-year", + ); + + this.createCalendar(); + + this.$prevMonthButton = this.$dialog.querySelector( + ".moj-js-datepicker-prev-month", + ); + this.$prevYearButton = this.$dialog.querySelector( + ".moj-js-datepicker-prev-year", + ); + this.$nextMonthButton = this.$dialog.querySelector( + ".moj-js-datepicker-next-month", + ); + this.$nextYearButton = this.$dialog.querySelector( + ".moj-js-datepicker-next-year", + ); + this.$cancelButton = this.$dialog.querySelector(".moj-js-datepicker-cancel"); + this.$okButton = this.$dialog.querySelector(".moj-js-datepicker-ok"); + + // add event listeners + this.$prevMonthButton.addEventListener("click", (event) => + this.focusPreviousMonth(event, false), + ); + this.$prevYearButton.addEventListener("click", (event) => + this.focusPreviousYear(event, false), + ); + this.$nextMonthButton.addEventListener("click", (event) => + this.focusNextMonth(event, false), + ); + this.$nextYearButton.addEventListener("click", (event) => + this.focusNextYear(event, false), + ); + this.$cancelButton.addEventListener("click", (event) => { + event.preventDefault(); + this.closeDialog(event); + }); + this.$okButton.addEventListener("click", () => { + this.selectDate(this.currentDate); + }); + + const dialogButtons = this.$dialog.querySelectorAll( + 'button:not([disabled="true"])', + ); + // eslint-disable-next-line prefer-destructuring + this.$firstButtonInDialog = dialogButtons[0]; + this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1]; + this.$firstButtonInDialog.addEventListener("keydown", (event) => + this.firstButtonKeydown(event), + ); + this.$lastButtonInDialog.addEventListener("keydown", (event) => + this.lastButtonKeydown(event), + ); + + this.$calendarButton.addEventListener("click", (event) => + this.toggleDialog(event), + ); + + this.$dialog.addEventListener("keydown", (event) => { + if (event.key == "Escape") { + this.closeDialog(); + event.preventDefault(); + event.stopPropagation(); + } + }); + + document.body.addEventListener("mouseup", (event) => + this.backgroundClick(event), + ); + + // populates calendar with initial dates, avoids Wave errors about null buttons + this.updateCalendar(); +}; + +Datepicker.prototype.createDialog = function () { + const titleId = `datepicker-title-${this.$input.id}`; + const $dialog = document.createElement("div"); + + $dialog.id = this.id; + $dialog.setAttribute("class", "moj-datepicker__dialog"); + $dialog.setAttribute("role", "dialog"); + $dialog.setAttribute("aria-modal", "true"); + $dialog.setAttribute("aria-labelledby", titleId); + $dialog.innerHTML = this.dialogTemplate(titleId); + + return $dialog; +}; + +Datepicker.prototype.createCalendar = function () { + const $tbody = this.$dialog.querySelector("tbody"); + let dayCount = 0; + for (let i = 0; i < 6; i++) { + // create row + const $row = $tbody.insertRow(i); + + for (let j = 0; j < 7; j++) { + // create cell (day) + const $cell = document.createElement("td"); + const $dateButton = document.createElement("button"); + + $cell.appendChild($dateButton); + $row.appendChild($cell); + + const calendarDay = new DSCalendarDay($dateButton, dayCount, i, j, this); + calendarDay.init(); + this.calendarDays.push(calendarDay); + dayCount++; + } + } +}; + +Datepicker.prototype.toggleTemplate = function () { + return ``; +}; + +/** + * HTML template for calendar dialog + * + * @param {string} [titleId] - Id attribute for dialog title + * @return {string} + */ +Datepicker.prototype.dialogTemplate = function (titleId) { + return `
+
+ + + +
+ +

June 2020

+ +
+ + + +
+
+ + + + + + + +
+ +
+ + +
`; +}; + +Datepicker.prototype.createCalendarHeaders = function () { + this.dayLabels.forEach((day) => { + const html = `${day}`; + const $headerRow = this.$dialog.querySelector("thead > tr"); + $headerRow.insertAdjacentHTML("beforeend", html); + }); +}; + +/** + * Pads given number with leading zeros + * + * @param {number} value - The value to be padded + * @param {number} length - The length in characters of the output + * @return {string} + */ +Datepicker.prototype.leadingZeros = function (value, length = 2) { + let ret = value.toString(); + + while (ret.length < length) { + ret = `0${ret}`; + } + + return ret; +}; + +Datepicker.prototype.setOptions = function () { + this.setMinAndMaxDatesOnCalendar(); + this.setExcludedDates(); + this.setExcludedDays(); + this.setLeadingZeros(); + this.setWeekStartDay(); +}; + +Datepicker.prototype.setMinAndMaxDatesOnCalendar = function () { + if (this.config.minDate) { + this.minDate = this.formattedDateFromString( + this.config.minDate, + null, + ); + if (this.minDate && this.currentDate < this.minDate) { + this.currentDate = this.minDate; + } + } + + if (this.config.maxDate) { + this.maxDate = this.formattedDateFromString( + this.config.maxDate, + null, + ); + if (this.maxDate && this.currentDate > this.maxDate) { + this.currentDate = this.maxDate; + } + } +}; + +Datepicker.prototype.setExcludedDates = function () { + if (this.config.excludedDates) { + this.excludedDates = this.config.excludedDates + .replace(/\s+/, " ") + .split(" ") + .map((item) => { + if (item.includes("-")) { + // parse the date range from the format "dd/mm/yyyy-dd/mm/yyyy" + const [startDate, endDate] = item + .split("-") + .map((d) => this.formattedDateFromString(d, null)); + if (startDate && endDate) { + const date = new Date(startDate.getTime()); + const dates = []; + while (date <= endDate) { + dates.push(new Date(date)); + date.setDate(date.getDate() + 1); + } + return dates; + } + } else { + return this.formattedDateFromString(item, null); + } + }) + .flat() + .filter((item) => item); + } +}; + +Datepicker.prototype.setExcludedDays = function () { + if (this.config.excludedDays) { + // lowercase and arrange dayLabels to put indexOf sunday == 0 for comparison + // with getDay() function + let weekDays = this.dayLabels.map((item) => item.toLowerCase()); + if (this.config.weekStartDay === "monday") { + weekDays.unshift(weekDays.pop()); + } + + this.excludedDays = this.config.excludedDays + .replace(/\s+/, " ") + .toLowerCase() + .split(" ") + .map((item) => weekDays.indexOf(item)) + .filter((item) => item !== -1); + } +}; + +Datepicker.prototype.setLeadingZeros = function () { + if (typeof this.config.leadingZeros !== "boolean") { + if (this.config.leadingZeros.toLowerCase() === "true") { + this.config.leadingZeros = true; + } + if (this.config.leadingZeros.toLowerCase() === "false") { + this.config.leadingZeros = false; + } + } +}; + +Datepicker.prototype.setWeekStartDay = function () { + const weekStartDayParam = this.config.weekStartDay; + if (weekStartDayParam?.toLowerCase() === "sunday") { + this.config.weekStartDay = "sunday"; + // Rotate dayLabels array to put Sunday as the first item + this.dayLabels.unshift(this.dayLabels.pop()); + } + if (weekStartDayParam?.toLowerCase() === "monday") { + this.config.weekStartDay = "monday"; + } +}; + +/** + * Determine if a date is selecteable + * + * @param {Date} date - the date to check + * @return {boolean} + * + */ +Datepicker.prototype.isExcludedDate = function (date) { + if (this.minDate && this.minDate > date) { + return true; + } + + if (this.maxDate && this.maxDate < date) { + return true; + } + + for (const excludedDate of this.excludedDates) { + if (date.toDateString() === excludedDate.toDateString()) { + return true; + } + } + + if (this.excludedDays.includes(date.getDay())) { + return true; + } + + return false; +}; + +/** + * Get a Date object from a string + * + * @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy + * @param {Date} fallback - date object to return if formatting fails + * @return {Date} + */ +Datepicker.prototype.formattedDateFromString = function ( + dateString, + fallback = new Date(), +) { + let formattedDate = null; + // Accepts d/m/yyyy and dd/mm/yyyy + const dateFormatPattern = /(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})/; + + if (!dateFormatPattern.test(dateString)) return fallback; + + const match = dateString.match(dateFormatPattern); + const day = match[1]; + const month = match[3]; + const year = match[4]; + + formattedDate = new Date(`${month}-${day}-${year}`); + if (formattedDate instanceof Date && !isNaN(formattedDate)) { + return formattedDate; + } + return fallback; +}; + +/** + * Get a formatted date string from a Date object + * + * @param {Date} date - date to format to a string + * @return {string} + */ +Datepicker.prototype.formattedDateFromDate = function (date) { + if (this.config.leadingZeros) { + return `${this.leadingZeros(date.getDate())}/${this.leadingZeros(date.getMonth() + 1)}/${date.getFullYear()}`; + } else { + return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`; + } +}; + +/** + * Get a human readable date in the format Monday 2 March 2024 + * + * @param {Date} - date to format + * @return {string} + */ +Datepicker.prototype.formattedDateHuman = function (date) { + return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`; +}; + +Datepicker.prototype.backgroundClick = function (event) { + if ( + this.isOpen() && + !this.$dialog.contains(event.target) && + !this.$input.contains(event.target) && + !this.$calendarButton.contains(event.target) + ) { + event.preventDefault(); + this.closeDialog(); + } +}; + +Datepicker.prototype.firstButtonKeydown = function (event) { + if (event.key === "Tab" && event.shiftKey) { + this.$lastButtonInDialog.focus(); + event.preventDefault(); + } +}; + +Datepicker.prototype.lastButtonKeydown = function (event) { + if (event.key === "Tab" && !event.shiftKey) { + this.$firstButtonInDialog.focus(); + event.preventDefault(); + } +}; + +// render calendar +Datepicker.prototype.updateCalendar = function () { + this.$dialogTitle.innerHTML = `${this.monthLabels[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`; + + const day = this.currentDate; + const firstOfMonth = new Date(day.getFullYear(), day.getMonth(), 1); + let dayOfWeek; + + if (this.config.weekStartDay === "monday") { + dayOfWeek = firstOfMonth.getDay() === 0 ? 6 : firstOfMonth.getDay() - 1; // Change logic to make Monday first day of week, i.e. 0 + } else { + dayOfWeek = firstOfMonth.getDay(); + } + + firstOfMonth.setDate(firstOfMonth.getDate() - dayOfWeek); + + const thisDay = new Date(firstOfMonth); + + // loop through our days + for (let i = 0; i < this.calendarDays.length; i++) { + const hidden = thisDay.getMonth() !== day.getMonth(); + const disabled = this.isExcludedDate(thisDay); + + this.calendarDays[i].update(thisDay, hidden, disabled); + + thisDay.setDate(thisDay.getDate() + 1); + } +}; + +Datepicker.prototype.setCurrentDate = function (focus = true) { + const { currentDate } = this; + + this.calendarDays.forEach((calendarDay) => { + calendarDay.button.classList.add("moj-datepicker__button"); + calendarDay.button.classList.add("moj-datepicker__calendar-day"); + calendarDay.button.setAttribute("tabindex", -1); + calendarDay.button.classList.remove(this.selectedDayButtonClass); + const calendarDayDate = calendarDay.date; + calendarDayDate.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if ( + calendarDayDate.getTime() === + currentDate.getTime() /* && !calendarDay.button.disabled */ + ) { + if (focus) { + calendarDay.button.setAttribute("tabindex", 0); + calendarDay.button.focus(); + calendarDay.button.classList.add(this.selectedDayButtonClass); + } + } + + if ( + this.inputDate && + calendarDayDate.getTime() === this.inputDate.getTime() + ) { + calendarDay.button.classList.add(this.currentDayButtonClass); + calendarDay.button.setAttribute("aria-selected", true); + } else { + calendarDay.button.classList.remove(this.currentDayButtonClass); + calendarDay.button.removeAttribute("aria-selected"); + } + + if (calendarDayDate.getTime() === today.getTime()) { + calendarDay.button.classList.add(this.todayButtonClass); + } else { + calendarDay.button.classList.remove(this.todayButtonClass); + } + }); + + // if no date is tab-able, make the first non-disabled date tab-able + if (!focus) { + const enabledDays = this.calendarDays.filter((calendarDay) => { + return ( + window.getComputedStyle(calendarDay.button).display === "block" && + !calendarDay.button.disabled + ); + }); + + enabledDays[0].button.setAttribute("tabindex", 0); + + this.currentDate = enabledDays[0].date; + } +}; + +Datepicker.prototype.selectDate = function (date) { + if (this.isExcludedDate(date)) { + return; + } + + this.$calendarButton.querySelector("span").innerText = + `Choose date. Selected date is ${this.formattedDateHuman(date)}`; + this.$input.value = this.formattedDateFromDate(date); + + const changeEvent = new Event("change", { bubbles: true, cancelable: true }); + this.$input.dispatchEvent(changeEvent); + + this.closeDialog(); +}; + +Datepicker.prototype.isOpen = function () { + return this.$dialog.classList.contains("moj-datepicker__dialog--open"); +}; + +Datepicker.prototype.toggleDialog = function (event) { + event.preventDefault(); + if (this.isOpen()) { + this.closeDialog(); + } else { + this.setMinAndMaxDatesOnCalendar(); + this.openDialog(); + } +}; + +Datepicker.prototype.openDialog = function () { + this.$dialog.classList.add("moj-datepicker__dialog--open"); + this.$calendarButton.setAttribute("aria-expanded", "true"); + + // position the dialog + // if input is wider than dialog pin it to the right + if (this.$input.offsetWidth > this.$dialog.offsetWidth) { + this.$dialog.style.right = `0px`; + } + this.$dialog.style.top = `${this.$input.offsetHeight + 3}px`; + + // get the date from the input element + this.inputDate = this.formattedDateFromString(this.$input.value); + this.currentDate = this.inputDate; + this.currentDate.setHours(0, 0, 0, 0); + + this.updateCalendar(); + this.setCurrentDate(); +}; + +Datepicker.prototype.closeDialog = function () { + this.$dialog.classList.remove("moj-datepicker__dialog--open"); + this.$calendarButton.setAttribute("aria-expanded", "false"); + this.$calendarButton.focus(); +}; + +Datepicker.prototype.goToDate = function (date, focus) { + const current = this.currentDate; + this.currentDate = date; + + if ( + current.getMonth() !== this.currentDate.getMonth() || + current.getFullYear() !== this.currentDate.getFullYear() + ) { + this.updateCalendar(); + } + + this.setCurrentDate(focus); +}; + +// day navigation +Datepicker.prototype.focusNextDay = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() + 1); + this.goToDate(date); +}; + +Datepicker.prototype.focusPreviousDay = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() - 1); + this.goToDate(date); +}; + +// week navigation +Datepicker.prototype.focusNextWeek = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() + 7); + this.goToDate(date); +}; + +Datepicker.prototype.focusPreviousWeek = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() - 7); + this.goToDate(date); +}; + +Datepicker.prototype.focusFirstDayOfWeek = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() - date.getDay()); + this.goToDate(date); +}; + +Datepicker.prototype.focusLastDayOfWeek = function () { + const date = new Date(this.currentDate); + date.setDate(date.getDate() - date.getDay() + 6); + this.goToDate(date); +}; + +// month navigation +Datepicker.prototype.focusNextMonth = function (event, focus = true) { + event.preventDefault(); + const date = new Date(this.currentDate); + date.setMonth(date.getMonth() + 1, 1); + this.goToDate(date, focus); +}; + +Datepicker.prototype.focusPreviousMonth = function (event, focus = true) { + event.preventDefault(); + const date = new Date(this.currentDate); + date.setMonth(date.getMonth() - 1, 1); + this.goToDate(date, focus); +}; + +// year navigation +Datepicker.prototype.focusNextYear = function (event, focus = true) { + event.preventDefault(); + const date = new Date(this.currentDate); + date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1); + this.goToDate(date, focus); +}; + +Datepicker.prototype.focusPreviousYear = function (event, focus = true) { + event.preventDefault(); + const date = new Date(this.currentDate); + date.setFullYear(date.getFullYear() - 1, date.getMonth(), 1); + this.goToDate(date, focus); +}; + +/** + * Parse dataset + * + * Loop over an object and normalise each value using {@link normaliseString}, + * optionally expanding nested `i18n.field` + * + * @param {{ schema: Schema }} Component - Component class + * @param {DOMStringMap} dataset - HTML element dataset + * @returns {Object} Normalised dataset + */ +Datepicker.prototype.parseDataset = function (schema, dataset) { + const parsed = {}; + + for (const [field, attributes] of Object.entries(schema.properties)) { + if (field in dataset) { + parsed[field] = dataset[field]; + } + } + + return parsed; +}; + +/** + * Config merging function + * + * Takes any number of objects and combines them together, with + * greatest priority on the LAST item passed in. + * + * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge + * @returns {{ [key: string]: unknown }} A merged config object + */ +Datepicker.prototype.mergeConfigs = function (...configObjects) { + const formattedConfigObject = {}; + + // Loop through each of the passed objects + for (const configObject of configObjects) { + for (const key of Object.keys(configObject)) { + const option = formattedConfigObject[key]; + const override = configObject[key]; + + // Push their keys one-by-one into formattedConfigObject. Any duplicate + // keys with object values will be merged, otherwise the new value will + // override the existing value. + if (typeof option === "object" && typeof override === "object") { + // @ts-expect-error Index signature for type 'string' is missing + formattedConfigObject[key] = this.mergeConfigs(option, override); + } else { + formattedConfigObject[key] = override; + } + } + } + + return formattedConfigObject; +}; + +/** + * + * @param {HTMLElement} button + * @param {number} index + * @param {number} row + * @param {number} column + * @param {Datepicker} picker + * @constructor + */ +function DSCalendarDay(button, index, row, column, picker) { + this.index = index; + this.row = row; + this.column = column; + this.button = button; + this.picker = picker; + + this.date = new Date(); +} + +DSCalendarDay.prototype.init = function () { + this.button.addEventListener("keydown", this.keyPress.bind(this)); + this.button.addEventListener("click", this.click.bind(this)); +}; + +/** + * @param {Date} day - the Date for the calendar day + * @param {boolean} hidden - visibility of the day + * @param {boolean} disabled - is the day selectable or excluded + */ +DSCalendarDay.prototype.update = function (day, hidden, disabled) { + let label = day.getDate(); + let accessibleLabel = this.picker.formattedDateHuman(day); + + if (disabled) { + this.button.setAttribute("aria-disabled", true); + accessibleLabel = "Excluded date, " + accessibleLabel; + } else { + this.button.removeAttribute("aria-disabled"); + } + + if (hidden) { + this.button.style.display = "none"; + } else { + this.button.style.display = "block"; + } + + this.button.innerHTML = `${accessibleLabel}`; + this.date = new Date(day); +}; + +DSCalendarDay.prototype.click = function (event) { + this.picker.goToDate(this.date); + this.picker.selectDate(this.date); + + event.stopPropagation(); + event.preventDefault(); +}; + +DSCalendarDay.prototype.keyPress = function (event) { + let calendarNavKey = true; + + switch (event.key) { + case "ArrowLeft": + this.picker.focusPreviousDay(); + break; + case "ArrowRight": + this.picker.focusNextDay(); + break; + case "ArrowUp": + this.picker.focusPreviousWeek(); + break; + case "ArrowDown": + this.picker.focusNextWeek(); + break; + case "Home": + this.picker.focusFirstDayOfWeek(); + break; + case "End": + this.picker.focusLastDayOfWeek(); + break; + case "PageUp": + // eslint-disable-next-line no-unused-expressions + event.shiftKey + ? this.picker.focusPreviousYear(event) + : this.picker.focusPreviousMonth(event); + break; + case "PageDown": + // eslint-disable-next-line no-unused-expressions + event.shiftKey + ? this.picker.focusNextYear(event) + : this.picker.focusNextMonth(event); + break; + default: + calendarNavKey = false; + break; + } + + if (calendarNavKey) { + event.preventDefault(); + event.stopPropagation(); + } +}; + +MOJFrontend.DatePicker = Datepicker; + +/** + * Schema for component config + * + * @typedef {object} Schema + * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties + */ + +/** + * Schema property for component config + * + * @typedef {object} SchemaProperty + * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type + */ diff --git a/src/moj/components/date-picker/macro.njk b/src/moj/components/date-picker/macro.njk new file mode 100644 index 00000000..edff875a --- /dev/null +++ b/src/moj/components/date-picker/macro.njk @@ -0,0 +1,3 @@ +{% macro mojDatePicker(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/src/moj/components/date-picker/template.njk b/src/moj/components/date-picker/template.njk new file mode 100644 index 00000000..f3164c59 --- /dev/null +++ b/src/moj/components/date-picker/template.njk @@ -0,0 +1,50 @@ +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/macros/attributes.njk" import govukAttributes %} + +{% set classNames = "moj-js-datepicker-input " %} + +{%- if params.classes %} + {% set classNames = classNames + " " + params.classes %} +{% endif %} + +{% set attributes = { + "data-module": 'moj-date-picker', + "data-min-date": { + value: params.minDate, + optional: true + }, + "data-max-date": { + value: params.maxDate, + optional: true + }, + "data-excluded-dates": { + value: params.excludedDates, + optional: true + }, + "data-excluded-days": { + value: params.excludedDays, + optional: true + }, + "data-leading-zeros": { + value: params.leadingZeros, + optional: true + }, + "data-week-start-day": { + value: params.weekStartDay, + optional: true + } +} %} + +
+ {{ govukInput({ + classes: classNames, + id: params.id, + name: params.name, + value: params.value, + autocomplete: "off", + formGroup: params.formGroup, + label: params.label, + hint: params.hint, + errorMessage: params.errorMessage + }) }} +