@defra/lis-infra-ui-services 0.49.0 → 0.50.0-lreg-875.alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/package.json +5 -1
- package/src/components/date-picker-calendar-day.js +135 -0
- package/src/components/date-picker-dates.js +119 -0
- package/src/components/date-picker-options.test.js +464 -0
- package/src/components/date-picker-templates.js +97 -0
- package/src/components/date-picker.js +776 -0
- package/src/components/date-picker.scss +275 -0
- package/src/components/date-picker.test.js +734 -0
- package/src/nunjucks/components/date-picker/date-picker.test.js +185 -0
- package/src/nunjucks/components/date-picker/macro.njk +3 -0
- package/src/nunjucks/components/date-picker/template.njk +61 -0
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ const nunjucksConfig = createNunjucksConfig({
|
|
|
53
53
|
await server.register(nunjucksConfig)
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
The template components are `appHeading`, `appAutocomplete`, `lisSubNavigation`, plus the search form,
|
|
56
|
+
The template components are `appHeading`, `appAutocomplete`, `lisDatePicker`, `lisSubNavigation`, plus the search form,
|
|
57
57
|
sortable paginated table and date templates. `createNunjucksContextBuilder` (also exported from
|
|
58
58
|
`/nunjucks-context`) builds just the view context. The filters `formatAge`, `formatDate`,
|
|
59
59
|
`formatEarTag`, `formatCurrency` and `queryString` are also exported from `/nunjucks/filters`, and the
|
|
@@ -294,10 +294,35 @@ import { initAllAutocompletes } from '@defra/lis-infra-ui-services/components/au
|
|
|
294
294
|
initAllAutocompletes()
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
#### Date picker
|
|
298
|
+
|
|
299
|
+
The `lisDatePicker` template component adds a calendar dialog to a GOV.UK text input, adapted from the [MoJ Frontend date picker](https://design-patterns.service.justice.gov.uk/components/date-picker) (MIT licence). It takes the same `id`, `name`, `value`, `label`, `hint`, `errorMessage`, `formGroup`, `classes` and `attributes` as `govukInput`, plus `minDate` and `maxDate` (`d/m/yyyy`), `excludedDates` (space-separated dates or `d/m/yyyy-d/m/yyyy` ranges), `excludedDays` (space-separated day names), `leadingZeros` (picked dates are `27/3/2026` by default, `27/03/2026` when `true`) and `weekStartDay` (`monday` or `sunday`, default `monday`).
|
|
300
|
+
|
|
301
|
+
```njk
|
|
302
|
+
{% from "date-picker/macro.njk" import lisDatePicker %}
|
|
303
|
+
|
|
304
|
+
{{ lisDatePicker({
|
|
305
|
+
id: "movement-date",
|
|
306
|
+
name: "movementDate",
|
|
307
|
+
label: { text: "Date of movement" },
|
|
308
|
+
hint: { text: "For example, 27/3/2026" },
|
|
309
|
+
maxDate: "5/11/2026"
|
|
310
|
+
}) }}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Start `DatePicker` from `@defra/lis-infra-ui-services/components/date-picker` with `createAll`, alongside the other components. Without JavaScript the plain text input remains.
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { createAll } from 'govuk-frontend'
|
|
317
|
+
import { DatePicker } from '@defra/lis-infra-ui-services/components/date-picker'
|
|
318
|
+
|
|
319
|
+
createAll(DatePicker)
|
|
320
|
+
```
|
|
321
|
+
|
|
297
322
|
### Styles
|
|
298
323
|
|
|
299
324
|
Import the Sass files you need from your app's stylesheet: `components/autocomplete.scss`,
|
|
300
|
-
`components/search-form.scss`, `components/service-navigation.scss`,
|
|
325
|
+
`components/date-picker.scss`, `components/search-form.scss`, `components/service-navigation.scss`,
|
|
301
326
|
`components/sortable-paginated-table.scss` and `components/sub-navigation.scss`.
|
|
302
327
|
|
|
303
328
|
```scss
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/lis-infra-ui-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0-lreg-875.alpha.2",
|
|
4
4
|
"description": "UI-Services shared definitions for the livestock taxonomy topology.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"./cookies/cookies-policy": "./src/cookies/cookies-policy.js",
|
|
16
16
|
"./components/autocomplete": "./src/components/autocomplete.js",
|
|
17
17
|
"./components/cookie-banner": "./src/components/cookie-banner.js",
|
|
18
|
+
"./components/date-picker": "./src/components/date-picker.js",
|
|
18
19
|
"./components/error-pages": "./src/components/error-pages.js",
|
|
19
20
|
"./components/layout": "./src/components/layout.js",
|
|
20
21
|
"./components/scrollable-table": "./src/components/scrollable-table.js",
|
|
21
22
|
"./components/autocomplete.scss": "./src/components/autocomplete.scss",
|
|
23
|
+
"./components/date-picker.scss": "./src/components/date-picker.scss",
|
|
22
24
|
"./components/search-form.scss": "./src/components/search-form.scss",
|
|
23
25
|
"./components/service-navigation.scss": "./src/components/service-navigation.scss",
|
|
24
26
|
"./components/sortable-paginated-table.scss": "./src/components/sortable-paginated-table.scss",
|
|
@@ -86,6 +88,8 @@
|
|
|
86
88
|
"@defra/lis-hubs-infra-registry": "^0.13.0",
|
|
87
89
|
"@defra/lis-infra-eslint-config": "^0.6.0",
|
|
88
90
|
"@hapi/hapi": "^21.4.10",
|
|
91
|
+
"@testing-library/dom": "^10.4.2",
|
|
92
|
+
"@testing-library/user-event": "^14.6.8",
|
|
89
93
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
90
94
|
"audit-ci": "^7.1.0",
|
|
91
95
|
"convict": "^6.2.5",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Adapted from the date picker in MoJ Frontend 12.0.0
|
|
2
|
+
// (https://github.com/ministryofjustice/moj-frontend), MIT licence.
|
|
3
|
+
// Copyright (c) 2019 Ministry of Justice
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Picks the picker's navigation for a calendar key press.
|
|
7
|
+
* @param {object} picker - the date picker that owns the day
|
|
8
|
+
* @param {object} event - keydown event
|
|
9
|
+
* @returns {Function | undefined} the navigation, or undefined for any other key
|
|
10
|
+
*/
|
|
11
|
+
function navigationForKey(picker, event) {
|
|
12
|
+
const navigations = new Map([
|
|
13
|
+
['ArrowLeft', () => picker.focusPreviousDay()],
|
|
14
|
+
['ArrowRight', () => picker.focusNextDay()],
|
|
15
|
+
['ArrowUp', () => picker.focusPreviousWeek()],
|
|
16
|
+
['ArrowDown', () => picker.focusNextWeek()],
|
|
17
|
+
['Home', () => picker.focusFirstDayOfWeek()],
|
|
18
|
+
['End', () => picker.focusLastDayOfWeek()],
|
|
19
|
+
[
|
|
20
|
+
'PageUp',
|
|
21
|
+
() =>
|
|
22
|
+
event.shiftKey
|
|
23
|
+
? picker.focusPreviousYear(event)
|
|
24
|
+
: picker.focusPreviousMonth(event)
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
'PageDown',
|
|
28
|
+
() =>
|
|
29
|
+
event.shiftKey
|
|
30
|
+
? picker.focusNextYear(event)
|
|
31
|
+
: picker.focusNextMonth(event)
|
|
32
|
+
]
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
return navigations.get(event.key)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One day button in the calendar grid.
|
|
40
|
+
*/
|
|
41
|
+
export class CalendarDay {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} $button - the day's button element
|
|
44
|
+
* @param {number} index - position in the grid
|
|
45
|
+
* @param {number} row - grid row
|
|
46
|
+
* @param {number} column - grid column
|
|
47
|
+
* @param {object} picker - the date picker that owns the day
|
|
48
|
+
*/
|
|
49
|
+
constructor($button, index, row, column, picker) {
|
|
50
|
+
this.index = index
|
|
51
|
+
this.row = row
|
|
52
|
+
this.column = column
|
|
53
|
+
this.$button = $button
|
|
54
|
+
this.picker = picker
|
|
55
|
+
this.date = new Date()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Date} day - the Date for the calendar day
|
|
60
|
+
* @param {boolean} hidden - visibility of the day
|
|
61
|
+
* @param {boolean} disabled - is the day selectable or excluded
|
|
62
|
+
*/
|
|
63
|
+
update(day, hidden, disabled) {
|
|
64
|
+
const label = day.getDate()
|
|
65
|
+
let accessibleLabel = this.picker.formattedDateHuman(day)
|
|
66
|
+
|
|
67
|
+
if (disabled) {
|
|
68
|
+
this.$button.setAttribute('aria-disabled', 'true')
|
|
69
|
+
accessibleLabel = `Excluded date, ${accessibleLabel}`
|
|
70
|
+
} else {
|
|
71
|
+
this.$button.removeAttribute('aria-disabled')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.$button.hidden = hidden
|
|
75
|
+
this.$button.dataset.testid = this.picker.formattedDateFromDate(day)
|
|
76
|
+
|
|
77
|
+
this.$button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`
|
|
78
|
+
this.date = new Date(day)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Called by the picker's delegated click listener on the grid.
|
|
83
|
+
* @param {object} event - click event
|
|
84
|
+
*/
|
|
85
|
+
click(event) {
|
|
86
|
+
this.picker.goToDate(this.date)
|
|
87
|
+
this.picker.selectDate(this.date)
|
|
88
|
+
|
|
89
|
+
event.stopPropagation()
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Called by the picker's delegated keydown listener on the grid.
|
|
95
|
+
* @param {object} event - keydown event
|
|
96
|
+
*/
|
|
97
|
+
keyPress(event) {
|
|
98
|
+
const navigation = navigationForKey(this.picker, event)
|
|
99
|
+
|
|
100
|
+
if (navigation) {
|
|
101
|
+
navigation()
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
event.stopPropagation()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DAYS_IN_WEEK = 7
|
|
109
|
+
const CALENDAR_WEEKS = 6
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fills the calendar's body with six weeks of day buttons.
|
|
113
|
+
* @param {object} $tbody - the calendar table's body
|
|
114
|
+
* @param {object} picker - the date picker that owns the days
|
|
115
|
+
* @returns {CalendarDay[]} the days, in grid order
|
|
116
|
+
*/
|
|
117
|
+
export function createCalendarDays($tbody, picker) {
|
|
118
|
+
const calendarDays = []
|
|
119
|
+
for (let row = 0; row < CALENDAR_WEEKS; row++) {
|
|
120
|
+
const $row = $tbody.insertRow(row)
|
|
121
|
+
|
|
122
|
+
for (let column = 0; column < DAYS_IN_WEEK; column++) {
|
|
123
|
+
const $cell = document.createElement('td')
|
|
124
|
+
$row.appendChild($cell)
|
|
125
|
+
const $dateButton = document.createElement('button')
|
|
126
|
+
$dateButton.setAttribute('type', 'button')
|
|
127
|
+
$cell.appendChild($dateButton)
|
|
128
|
+
|
|
129
|
+
calendarDays.push(
|
|
130
|
+
new CalendarDay($dateButton, calendarDays.length, row, column, picker)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return calendarDays
|
|
135
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Adapted from the date picker in MoJ Frontend 12.0.0
|
|
2
|
+
// (https://github.com/ministryofjustice/moj-frontend), MIT licence.
|
|
3
|
+
// Copyright (c) 2019 Ministry of Justice
|
|
4
|
+
|
|
5
|
+
// Accepts d/m/yyyy and dd/mm/yyyy, with -, /, comma, full stop or space between parts.
|
|
6
|
+
const DATE_FORMAT_PATTERN = /(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})/
|
|
7
|
+
const DAY_GROUP = 1
|
|
8
|
+
const MONTH_GROUP = 3
|
|
9
|
+
const YEAR_GROUP = 4
|
|
10
|
+
const PADDED_LENGTH = 2
|
|
11
|
+
const SUNDAY_FIRST_DAYS = [
|
|
12
|
+
'sunday',
|
|
13
|
+
'monday',
|
|
14
|
+
'tuesday',
|
|
15
|
+
'wednesday',
|
|
16
|
+
'thursday',
|
|
17
|
+
'friday',
|
|
18
|
+
'saturday'
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pads given number with leading zeros
|
|
23
|
+
* @param {number} value - The value to be padded
|
|
24
|
+
* @param {number} length - The length in characters of the output
|
|
25
|
+
* @returns {string} the padded value
|
|
26
|
+
*/
|
|
27
|
+
export function padNumber(value, length = PADDED_LENGTH) {
|
|
28
|
+
return value.toString().padStart(length, '0')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get a Date object from a string
|
|
33
|
+
* @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
|
|
34
|
+
* @param {Date | null} fallback - value to return if parsing fails
|
|
35
|
+
* @returns {Date | null} the parsed date, or the fallback
|
|
36
|
+
*/
|
|
37
|
+
export function parseDate(dateString, fallback) {
|
|
38
|
+
const match = DATE_FORMAT_PATTERN.exec(dateString)
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
return fallback
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const date = new Date(
|
|
45
|
+
Number(match[YEAR_GROUP]),
|
|
46
|
+
Number(match[MONTH_GROUP]) - 1,
|
|
47
|
+
Number(match[DAY_GROUP])
|
|
48
|
+
)
|
|
49
|
+
return Number.isFinite(date.getTime()) ? date : fallback
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parses a daterange string into an array of dates
|
|
54
|
+
* @param {string} rangeString - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
|
|
55
|
+
* @returns {Date[]} every date in the range, or none when either end is invalid
|
|
56
|
+
*/
|
|
57
|
+
export function parseDateRange(rangeString) {
|
|
58
|
+
const dates = []
|
|
59
|
+
const [startDate, endDate] = rangeString
|
|
60
|
+
.split('-')
|
|
61
|
+
.map((part) => parseDate(part, null))
|
|
62
|
+
|
|
63
|
+
if (startDate && endDate) {
|
|
64
|
+
let date = new Date(startDate)
|
|
65
|
+
while (date <= endDate) {
|
|
66
|
+
dates.push(date)
|
|
67
|
+
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return dates
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses space-separated dates and date ranges.
|
|
75
|
+
* @param {string} excludedDates - e.g. "1/6/2026 10/6/2026-12/6/2026"
|
|
76
|
+
* @returns {Date[]} the valid dates
|
|
77
|
+
*/
|
|
78
|
+
export function parseExcludedDates(excludedDates) {
|
|
79
|
+
return excludedDates
|
|
80
|
+
.replace(/\s+/, ' ')
|
|
81
|
+
.split(' ')
|
|
82
|
+
.map((item) =>
|
|
83
|
+
item.includes('-') ? parseDateRange(item) : [parseDate(item, new Date())]
|
|
84
|
+
)
|
|
85
|
+
.reduce((dates, items) => dates.concat(items), [])
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parses space-separated day names into Date.getDay() indexes.
|
|
91
|
+
* @param {string} excludedDays - e.g. "saturday sunday"
|
|
92
|
+
* @returns {number[]} the indexes of the recognised days
|
|
93
|
+
*/
|
|
94
|
+
export function parseExcludedDays(excludedDays) {
|
|
95
|
+
// Sunday first, so a day's index matches Date.getDay(). MoJ's version reads
|
|
96
|
+
// the week start day before it is normalised, which shifts every excluded
|
|
97
|
+
// day back by one when the week starts on Sunday.
|
|
98
|
+
|
|
99
|
+
return excludedDays
|
|
100
|
+
.replace(/\s+/, ' ')
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.split(' ')
|
|
103
|
+
.map((item) => SUNDAY_FIRST_DAYS.indexOf(item))
|
|
104
|
+
.filter((item) => item !== -1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get a formatted date string from a Date object
|
|
109
|
+
* @param {Date} date - date to format to a string
|
|
110
|
+
* @param {boolean} leadingZeros - pad the day and month to two digits
|
|
111
|
+
* @returns {string} the date as d/m/yyyy, or dd/mm/yyyy with leading zeros
|
|
112
|
+
*/
|
|
113
|
+
export function formatDate(date, leadingZeros) {
|
|
114
|
+
if (leadingZeros) {
|
|
115
|
+
return `${padNumber(date.getDate())}/${padNumber(date.getMonth() + 1)}/${date.getFullYear()}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
|
|
119
|
+
}
|