@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 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.49.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
+ }