From 0f52877e71849fabd3790464a51fffbb63151a74 Mon Sep 17 00:00:00 2001 From: Chris Pymm Date: Wed, 31 Jul 2024 15:48:33 +0100 Subject: [PATCH] feat(date picker): add date picker component (#601) * feat(date-picker): add date picker * refactor(date picker): updates to submitted datepicker component code Chnages to submitted code to use gov.uk styles where possible. Updates component to being fixed width by default, allowing users to either provide an alternative width class or remove it to have a fluid input. * refactor(date picker): update styles to match design system design Amends the styles of the submitted datepicker to match the tweaked design for inclusion in the Design System. Renames some classes to be slightly more consistent with BEM style. Reformat css file to remove nesting to conform to GDS recommended style. * refactor(date picker): add calendar button via JS for pregressive enhancement This PR moves the calendar popup toggle from in the template to within the JS, as the button shouldn't show up unless JS is available to toggle the popup dialog. Improvements were made to the accessible labelling of the table headers and the individual day buttons to aid screenreader users. * refactor(date picker): remove unnecessary data-button attributes * docs(date picker component): adding guidance for date picker component * docs(date picker component): updating hint text on component example * feat(date picker): add support for disabling dates and days Allow for disabling of arbitrary dates using the data-disableddays param. Also disable specific days of the week using the data-disableddays param. e.g. disable all weekends with `data-disableddays="saturday sunday"` * docs(date picker component): change to input field and example height * docs(documentation change): updating guidance for date picker component * docs(update to component documentation): adding examples to date picker component guidance * feat(date picker): updates to styling, and WIP changes to how disabled dates are handles * feat(date picker): update disabled and hover styles to match figma designs * feat(date picker): add leadingzeros config parameter Add config option for whether or not the date inserted into the field on selection has leadingzeros for days and months * feat(date picker): add config option for week start day Allows a weekStartDay option to be passed to the component to configure whether weeks start on a monday or sunday * feat(date picker): allow passing date ranges to disabledDates In order to make it easier to disabled a block of dates it is now possible to pass date ranges in the format "19/7/2024-26/7/24" in the disabledDates parameter * feat(date picker): add examples to date picker guidance page * ci(dockerfile): remove asset copy directive from the dockerfile Copying the assets directory is no longer needed as the assets are now within the docs directory * feat(date picker): add horizontal and vertical pairs example * refactor(date picker): refactor date picker template to be cleaner The previous template resulted in nested moj-form-group elements causing issues with error states. The template has now been refactored to more fully utilise the govuk-input macro and use the govuk-attributes macro too to impriove and simplify the external api to the component. * docs(date picker): remove from-to stacked examples * refactor(date picker): refactor date picker JS Extract some js into functions for slightly improved readability * fix(date picker): fix min-max date params in template * docs(date picker component): updating component guidance after content design review * docs(date picker component): changing example image on date picker guidance * docs(date picker component): updating example image for filtering with the date picker * docs(date picker component): update Changes to content * docs(date picker component): update content Refine content ready for release. * feat(date picker): code formatting and updates following accessibility review * add aria-expanded attribute onto calendar toggle button * add "excluded date" assisteive text to excluded dates * update excluded dates example to show both individual dates and days * reformat code to follow convention of element variables having a $ prefix * docs(date picker component): update content * feat(date picker): allow component to be configured via JS as well as via data-attributes This change updates the component to allow for component properties such as minDate, maxDate and excludedDates/Days to be passed in via the JS config object. This follows the GDS convention where component defaults are overridden by the JS config, which is overridden by data attributes. The code to do this is largely borrowed from GOV.UK frontend. Also added in this commit are JSDoc comments for all functions with arguments. * feat(date picker): update hover cover remove second excluded dates example * docs(date picker component): update content * feat(date picker): update diasbled dates with strikethrough and hover colors to darker grey Ensure metting WCAG color contrast rules by amending hover color, and add strikethrough for clarity of meaning on excluded dates * feat(date picker): fix example open in new window link styling * refactor(date picker): small final tidying tweaks to datepicker * fix(examples): adds example title to example tabs links to prevent many redundant links on the page This was raised in an accessibility review by Ben Proctor-Rogers. Each of the example links on the page has the same label, which is not a good experience for screen reader users. This change adds the example title to the link as visually hidden text. * docs(date picker): add figma link to first example * Revert "refactor(date picker): small final tidying tweaks to datepicker" This reverts commit a004155a453c7f1d4b917b9071eb77504a282347. * refactor(date picker): small formatting nits * docs(date picker): update date-picker component README within package * feat(date picker): fixes following code review Adjustments in response to code review * docs(date picker): change url in date picker readme * docs(date picker component): update content Updated content on accessibility for excluded dates. * docs(date picker component): update content Content tweaks * docs(date picker component): update content Correcting typo * docs(date picker component): update content Remove greyed out content which is a comment. * refactor(date picker): update sass to follow GOV.UK BEM conventions Updates the CSS classes to have only one block root per component. Also updates to follw GOV.UK recomended convention of preferring sass variables instead of colour functions where available. * docs(date picker): add date picker to what's new section on homepage * refactor(date picker): remove need for duplicate date regex The regex for a date was duplicated in the code. It wasn't necessary to wrap getting the date in the input in a conditional due to the fact that fomattedDateFromString falls back to todays date meaning if there is no date in the input or the input contains an invalid date, it will fall back to setting the currentDate for the calendar to today, which is what is required. * fix(date picker): move escape key event listener to dialog element The 'esc' keydown event listener was attached to the calendarDayButton instance(s) meaning that escape would only close the modal if one of the calendar days was focused. Escape should clode the dialog wherever you are focused within it. Moving the listener onto the parent element fixes this bug. * refactor(date picker): refactor event.keycode to event.key * fix(date picker): fixes and changes based on code review * docs(date picker component): update content New content about server side validation for text inputs. * refactor(date picker): remove unused css class on the dialog * docs(date picker): add figma link to all examples * fix(date picker): fix mindate and maxdate functionality * docs(date picker): update excluded dates example to also include min and max date --------- Co-authored-by: Greg Tyler Co-authored-by: Rob McCarthy Co-authored-by: helennickols <94117270+helennickols@users.noreply.github.com> Co-authored-by: helennickols --- .eleventy.js | 2 + Dockerfile | 1 - docs/_includes/arguments/date-picker.md | 16 + docs/_includes/example.njk | 20 +- docs/_includes/layouts/home.njk | 17 +- .../partials/suggest-a-change-and-help.njk | 24 +- .../images/date-picker-filter-example.svg | 15 + .../images/date-picker-question-example.svg | 15 + .../stylesheets/components/_example.scss | 10 +- docs/community/suggest-a-change.md | 2 +- docs/components/date-picker.md | 116 ++- docs/examples/date-picker-error/index.njk | 22 + .../date-picker-excluded-dates/index.njk | 23 + .../date-picker-excluded-days/index.njk | 19 + .../date-picker-horizontal-pair/index.njk | 34 + docs/examples/date-picker-min-max/index.njk | 23 + .../date-picker-vertical-pair/index.njk | 28 + docs/examples/date-picker/index.njk | 19 + docs/index.md | 2 - gulpfile.js | 2 +- package-lock.json | 297 ++---- package.json | 2 +- src/moj/all.js | 5 + src/moj/components/_all.scss | 1 + src/moj/components/date-picker/README.md | 36 + .../components/date-picker/_date-picker.scss | 293 ++++++ src/moj/components/date-picker/date-picker.js | 933 ++++++++++++++++++ src/moj/components/date-picker/macro.njk | 3 + src/moj/components/date-picker/template.njk | 50 + 29 files changed, 1760 insertions(+), 270 deletions(-) create mode 100644 docs/_includes/arguments/date-picker.md create mode 100644 docs/assets/images/date-picker-filter-example.svg create mode 100644 docs/assets/images/date-picker-question-example.svg create mode 100644 docs/examples/date-picker-error/index.njk create mode 100644 docs/examples/date-picker-excluded-dates/index.njk create mode 100644 docs/examples/date-picker-excluded-days/index.njk create mode 100644 docs/examples/date-picker-horizontal-pair/index.njk create mode 100644 docs/examples/date-picker-min-max/index.njk create mode 100644 docs/examples/date-picker-vertical-pair/index.njk create mode 100644 docs/examples/date-picker/index.njk create mode 100644 src/moj/components/date-picker/README.md create mode 100644 src/moj/components/date-picker/_date-picker.scss create mode 100644 src/moj/components/date-picker/date-picker.js create mode 100644 src/moj/components/date-picker/macro.njk create mode 100644 src/moj/components/date-picker/template.njk diff --git a/.eleventy.js b/.eleventy.js index a4cdd54ea..996107d5a 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 a4990e4c5..c9726233e 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 000000000..0789071eb --- /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 cb7f17638..24a3f2220 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 f68bb7eda..92c41ed40 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 c6f26d1ed..91a42fb86 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 000000000..706424253 --- /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 000000000..a69d561c0 --- /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 cc7c8ceb3..b904bce0d 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 8a5b136da..1866c21bb 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 988c72610..a20a4456e 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 000000000..c45ad0088 --- /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 000000000..1f0e14440 --- /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 000000000..9c04a9c3e --- /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 000000000..9374144b4 --- /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 000000000..cf34768a0 --- /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 000000000..c35818a3f --- /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 000000000..8c5012eb0 --- /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 17042dc98..6b6bc623a 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 c55fadf74..3899269c8 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 cfacd2089..0b1800a98 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 704aa426e..b4403a10d 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 a4090064e..f7c41dda3 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 ffa08e9f7..19812f8e0 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 000000000..ea4b42e1c --- /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 000000000..c218e57b2 --- /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 000000000..48fc6976f --- /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 000000000..edff875a2 --- /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 000000000..f3164c59e --- /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 + }) }} +