-
- 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 %}
-
-
+
+
-
-
- Being reviewed
-
+
+ Enter or select a date
-
-
- In progress
-
+
+ Enter the date in the correct format, for example, 17/5/2024
-
-
- In progress
-
+
+ Enter a real date
-
-
- Being reviewed
-
+
+ Enter a full date, for example 17/5/2024
-
-
- Not started
-
+
+ Select 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
+
+
+
+### Asking a question with a date picker
+
+
+
+
+## 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 `
+ Choose date
+
+
+
+
+
+ `;
+};
+
+/**
+ * HTML template for calendar dialog
+ *
+ * @param {string} [titleId] - Id attribute for dialog title
+ * @return {string}
+ */
+Datepicker.prototype.dialogTemplate = function (titleId) {
+ return `
+
+
+
+
+ Select
+ Close
+
`;
+};
+
+Datepicker.prototype.createCalendarHeaders = function () {
+ this.dayLabels.forEach((day) => {
+ const html = `
${day.substring(0, 3)} ${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} ${label} `;
+ 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
+ }) }}
+