@defra/lis-infra-ui-services 0.49.0 → 0.50.0-lreg-875.alpha.3

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 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,37 @@ 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, see [LICENCE-moj-frontend.txt](./LICENCE-moj-frontend.txt)). It takes the same `id`, `name`, `value`, `label`, `hint`, `errorMessage`, `formGroup`, `classes` and `attributes` as `govukInput`, plus `minDate` and `maxDate` (`d/m/yyyy`), `excludedDates`, `excludedDays` (day names separated by spaces), `leadingZeros` (picked dates are `27/3/2026` by default, `27/03/2026` when `true`) and `weekStartDay` (`monday` or `sunday`, default `monday`).
300
+
301
+ `excludedDates` takes dates and ranges separated by spaces, for example `"1/6/2026 10/6/2026-12/6/2026"`. A hyphen always marks a range, so dates there must use `/` (or `.` or `,`) between their parts, never `-`. Anything that is not a real date, such as `31/4/2026` or `1-6-2026`, is ignored.
302
+
303
+ ```njk
304
+ {% from "date-picker/macro.njk" import lisDatePicker %}
305
+
306
+ {{ lisDatePicker({
307
+ id: "movement-date",
308
+ name: "movementDate",
309
+ label: { text: "Date of movement" },
310
+ hint: { text: "For example, 27/3/2026" },
311
+ maxDate: "5/11/2026"
312
+ }) }}
313
+ ```
314
+
315
+ Start `DatePicker` from `@defra/lis-infra-ui-services/components/date-picker` with `createAll`, alongside the other components. Without JavaScript the plain text input remains.
316
+
317
+ ```js
318
+ import { createAll } from 'govuk-frontend'
319
+ import { DatePicker } from '@defra/lis-infra-ui-services/components/date-picker'
320
+
321
+ createAll(DatePicker)
322
+ ```
323
+
297
324
  ### Styles
298
325
 
299
326
  Import the Sass files you need from your app's stylesheet: `components/autocomplete.scss`,
300
- `components/search-form.scss`, `components/service-navigation.scss`,
327
+ `components/date-picker.scss`, `components/search-form.scss`, `components/service-navigation.scss`,
301
328
  `components/sortable-paginated-table.scss` and `components/sub-navigation.scss`.
302
329
 
303
330
  ```scss
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.49.0",
3
+ "version": "0.50.0-lreg-875.alpha.3",
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,125 @@
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
6
+ // parts, and nothing else around them but whitespace.
7
+ const DATE_FORMAT_PATTERN = /^\s*(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})\s*$/
8
+ const DAY_GROUP = 1
9
+ const MONTH_GROUP = 3
10
+ const YEAR_GROUP = 4
11
+ const PADDED_LENGTH = 2
12
+ const SUNDAY_FIRST_DAYS = [
13
+ 'sunday',
14
+ 'monday',
15
+ 'tuesday',
16
+ 'wednesday',
17
+ 'thursday',
18
+ 'friday',
19
+ 'saturday'
20
+ ]
21
+
22
+ /**
23
+ * Pads given number with leading zeros
24
+ * @param {number} value - The value to be padded
25
+ * @param {number} length - The length in characters of the output
26
+ * @returns {string} the padded value
27
+ */
28
+ function padNumber(value, length = PADDED_LENGTH) {
29
+ return value.toString().padStart(length, '0')
30
+ }
31
+
32
+ /**
33
+ * Get a Date object from a string
34
+ * @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
35
+ * @param {Date | null} fallback - value to return if parsing fails
36
+ * @returns {Date | null} the parsed date, or the fallback
37
+ */
38
+ export function parseDate(dateString, fallback) {
39
+ const match = DATE_FORMAT_PATTERN.exec(dateString)
40
+
41
+ if (!match) {
42
+ return fallback
43
+ }
44
+
45
+ const year = Number(match[YEAR_GROUP])
46
+ const monthIndex = Number(match[MONTH_GROUP]) - 1
47
+ const day = Number(match[DAY_GROUP])
48
+ const date = new Date(year, monthIndex, day)
49
+
50
+ // Date rolls impossible dates over (31/4 becomes 1/5), so those are rejected.
51
+ const isRealDate =
52
+ date.getFullYear() === year &&
53
+ date.getMonth() === monthIndex &&
54
+ date.getDate() === day
55
+ return isRealDate ? date : fallback
56
+ }
57
+
58
+ /**
59
+ * Parses a daterange string into an array of dates
60
+ * @param {string} rangeString - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
61
+ * @returns {Date[]} every date in the range, or none when either end is invalid
62
+ */
63
+ export function parseDateRange(rangeString) {
64
+ const dates = []
65
+ const [startDate, endDate] = rangeString
66
+ .split('-')
67
+ .map((part) => parseDate(part, null))
68
+
69
+ if (startDate && endDate) {
70
+ let date = new Date(startDate)
71
+ while (date <= endDate) {
72
+ dates.push(date)
73
+ date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
74
+ }
75
+ }
76
+ return dates
77
+ }
78
+
79
+ /**
80
+ * Parses whitespace-separated dates and date ranges. A hyphen always marks a
81
+ * range, so the dates must not use it between their parts.
82
+ * @param {string} excludedDates - e.g. "1/6/2026 10/6/2026-12/6/2026"
83
+ * @returns {Date[]} the valid dates; anything that is not a date is dropped
84
+ */
85
+ export function parseExcludedDates(excludedDates) {
86
+ return excludedDates
87
+ .trim()
88
+ .split(/\s+/)
89
+ .map((item) =>
90
+ item.includes('-') ? parseDateRange(item) : [parseDate(item, null)]
91
+ )
92
+ .reduce((dates, items) => dates.concat(items), [])
93
+ .filter(Boolean)
94
+ }
95
+
96
+ /**
97
+ * Parses whitespace-separated day names into Date.getDay() indexes.
98
+ * @param {string} excludedDays - e.g. "saturday sunday"
99
+ * @returns {number[]} the indexes of the recognised days
100
+ */
101
+ export function parseExcludedDays(excludedDays) {
102
+ // Sunday first, so a day's index matches Date.getDay(). MoJ's version reads
103
+ // the week start day before it is normalised, which shifts every excluded
104
+ // day back by one when the week starts on Sunday.
105
+ return excludedDays
106
+ .trim()
107
+ .toLowerCase()
108
+ .split(/\s+/)
109
+ .map((item) => SUNDAY_FIRST_DAYS.indexOf(item))
110
+ .filter((item) => item !== -1)
111
+ }
112
+
113
+ /**
114
+ * Get a formatted date string from a Date object
115
+ * @param {Date} date - date to format to a string
116
+ * @param {boolean} leadingZeros - pad the day and month to two digits
117
+ * @returns {string} the date as d/m/yyyy, or dd/mm/yyyy with leading zeros
118
+ */
119
+ export function formatDate(date, leadingZeros) {
120
+ if (leadingZeros) {
121
+ return `${padNumber(date.getDate())}/${padNumber(date.getMonth() + 1)}/${date.getFullYear()}`
122
+ }
123
+
124
+ return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
125
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import {
4
+ formatDate,
5
+ parseDate,
6
+ parseDateRange,
7
+ parseExcludedDates,
8
+ parseExcludedDays
9
+ } from './date-picker-dates.js'
10
+
11
+ const FALLBACK = new Date(2000, 0, 1)
12
+
13
+ describe('date-picker-dates', () => {
14
+ describe('parseDate()', () => {
15
+ test.each([
16
+ ['27/3/2026', new Date(2026, 2, 27)],
17
+ ['07/03/2026', new Date(2026, 2, 7)],
18
+ ['27-3-2026', new Date(2026, 2, 27)],
19
+ ['27.3.2026', new Date(2026, 2, 27)],
20
+ ['27,3,2026', new Date(2026, 2, 27)],
21
+ ['27 3 2026', new Date(2026, 2, 27)],
22
+ [' 27/3/2026 ', new Date(2026, 2, 27)],
23
+ ['29/2/2024', new Date(2024, 1, 29)]
24
+ ])('reads %j as a date', (dateString, expected) => {
25
+ // Act
26
+ const result = parseDate(dateString, FALLBACK)
27
+
28
+ // Assert
29
+ expect(result).toStrictEqual(expected)
30
+ })
31
+
32
+ test.each([
33
+ '31/2/2026',
34
+ '29/2/2026',
35
+ '31/4/2026',
36
+ '45/13/2026',
37
+ '0/1/2026',
38
+ '1/0/2026'
39
+ ])('rejects the impossible date %j', (dateString) => {
40
+ // Act
41
+ const result = parseDate(dateString, FALLBACK)
42
+
43
+ // Assert
44
+ expect(result).toBe(FALLBACK)
45
+ })
46
+
47
+ test.each([
48
+ '127/3/20261',
49
+ '27/3/20261',
50
+ '27/3/26',
51
+ 'on 27/3/2026',
52
+ '27/3/2026 or later',
53
+ '27/3-2026',
54
+ '',
55
+ 'soon'
56
+ ])('rejects %j, which is not just a date', (dateString) => {
57
+ // Act
58
+ const result = parseDate(dateString, FALLBACK)
59
+
60
+ // Assert
61
+ expect(result).toBe(FALLBACK)
62
+ })
63
+
64
+ test('returns a null fallback when asked', () => {
65
+ // Act
66
+ const result = parseDate('31/4/2026', null)
67
+
68
+ // Assert
69
+ expect(result).toBeNull()
70
+ })
71
+ })
72
+
73
+ describe('parseDateRange()', () => {
74
+ test('returns every date from the start to the end', () => {
75
+ // Act
76
+ const result = parseDateRange('30/4/2026-2/5/2026')
77
+
78
+ // Assert
79
+ expect(result).toStrictEqual([
80
+ new Date(2026, 3, 30),
81
+ new Date(2026, 4, 1),
82
+ new Date(2026, 4, 2)
83
+ ])
84
+ })
85
+
86
+ test('returns no dates when the end is before the start', () => {
87
+ // Act
88
+ const result = parseDateRange('2/5/2026-30/4/2026')
89
+
90
+ // Assert
91
+ expect(result).toStrictEqual([])
92
+ })
93
+
94
+ test.each(['1/5/2026-31/4/2026', '1/5/2026-soon', '1-5-2026'])(
95
+ 'returns no dates for %j',
96
+ (rangeString) => {
97
+ // Act
98
+ const result = parseDateRange(rangeString)
99
+
100
+ // Assert
101
+ expect(result).toStrictEqual([])
102
+ }
103
+ )
104
+ })
105
+
106
+ describe('parseExcludedDates()', () => {
107
+ test.each([
108
+ ['a trailing space', '1/6/2026 ', [new Date(2026, 5, 1)]],
109
+ [
110
+ 'leading and repeated spaces',
111
+ ' 1/6/2026 3/6/2026',
112
+ [new Date(2026, 5, 1), new Date(2026, 5, 3)]
113
+ ],
114
+ [
115
+ 'newlines and tabs',
116
+ '1/6/2026\n\t3/6/2026\n',
117
+ [new Date(2026, 5, 1), new Date(2026, 5, 3)]
118
+ ],
119
+ [
120
+ 'an invalid token',
121
+ '1/6/2026 soon 3/6/2026',
122
+ [new Date(2026, 5, 1), new Date(2026, 5, 3)]
123
+ ],
124
+ [
125
+ 'an impossible date',
126
+ '1/6/2026 31/6/2026 3/6/2026',
127
+ [new Date(2026, 5, 1), new Date(2026, 5, 3)]
128
+ ],
129
+ [
130
+ 'a date with hyphens between its parts',
131
+ '1/6/2026 2-6-2026 3/6/2026',
132
+ [new Date(2026, 5, 1), new Date(2026, 5, 3)]
133
+ ]
134
+ ])('keeps only the real dates with %s', (_, excludedDates, expected) => {
135
+ // Act
136
+ const result = parseExcludedDates(excludedDates)
137
+
138
+ // Assert
139
+ expect(result).toStrictEqual(expected)
140
+ })
141
+
142
+ test('combines single dates and ranges', () => {
143
+ // Act
144
+ const result = parseExcludedDates('1/6/2026 10/6/2026-12/6/2026')
145
+
146
+ // Assert
147
+ expect(result).toStrictEqual([
148
+ new Date(2026, 5, 1),
149
+ new Date(2026, 5, 10),
150
+ new Date(2026, 5, 11),
151
+ new Date(2026, 5, 12)
152
+ ])
153
+ })
154
+
155
+ test.each(['', ' ', 'soon'])(
156
+ 'returns no dates for %j',
157
+ (excludedDates) => {
158
+ // Act
159
+ const result = parseExcludedDates(excludedDates)
160
+
161
+ // Assert
162
+ expect(result).toStrictEqual([])
163
+ }
164
+ )
165
+ })
166
+
167
+ describe('parseExcludedDays()', () => {
168
+ test('reads day names in any case and spacing as Date.getDay() indexes', () => {
169
+ // Act
170
+ const result = parseExcludedDays(' Saturday \n sunday MONDAY ')
171
+
172
+ // Assert
173
+ expect(result).toStrictEqual([6, 0, 1])
174
+ })
175
+
176
+ test('drops words that are not day names', () => {
177
+ // Act
178
+ const result = parseExcludedDays('sunday someday sat')
179
+
180
+ // Assert
181
+ expect(result).toStrictEqual([0])
182
+ })
183
+ })
184
+
185
+ describe('formatDate()', () => {
186
+ test.each([
187
+ [false, '7/3/2026'],
188
+ [true, '07/03/2026']
189
+ ])('with leading zeros %s formats as %s', (leadingZeros, expected) => {
190
+ // Act
191
+ const result = formatDate(new Date(2026, 2, 7), leadingZeros)
192
+
193
+ // Assert
194
+ expect(result).toBe(expected)
195
+ })
196
+ })
197
+ })