@defra/lis-infra-ui-services 0.12.0 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -25,3 +25,34 @@
25
25
  .lis-sortable-table__icon {
26
26
  flex-shrink: 0;
27
27
  }
28
+
29
+ // A focusable region so keyboard users can scroll a table that's wider than
30
+ // the viewport.
31
+ .lis-sortable-table__wrapper {
32
+ // Contains the table's absolutely positioned visually-hidden text, which
33
+ // would otherwise escape the overflow and make the whole page scroll.
34
+ position: relative;
35
+ overflow-x: auto;
36
+
37
+ // The GOV.UK non-text focus state - yellow alone has too little contrast
38
+ // against a white page. Both rings sit outside the box, clear of the text.
39
+ &:focus {
40
+ outline: $govuk-focus-width solid govuk-functional-colour("focus");
41
+ outline-offset: $govuk-focus-width;
42
+ box-shadow: 0 0 0 $govuk-focus-width govuk-functional-colour("focus-text");
43
+ }
44
+
45
+ // A header never wraps - on a narrow screen the table scrolls inside the
46
+ // wrapper instead.
47
+ .govuk-table__header {
48
+ white-space: nowrap;
49
+ }
50
+ }
51
+
52
+ // Also keeps every cell on one line. Opt-in, since it suits tables of short
53
+ // values but not ones with free text. Pass as `classes`.
54
+ .lis-sortable-table--no-wrap {
55
+ .govuk-table__cell {
56
+ white-space: nowrap;
57
+ }
58
+ }
@@ -0,0 +1,72 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import nunjucks from 'nunjucks'
5
+ import { describe, expect, test } from 'vitest'
6
+
7
+ import { formatDate } from '../../format-date.js'
8
+
9
+ const componentRoot = path.dirname(fileURLToPath(import.meta.url))
10
+ const environment = new nunjucks.Environment(
11
+ new nunjucks.FileSystemLoader([path.dirname(componentRoot)]),
12
+ { autoescape: true, trimBlocks: true, lstripBlocks: true }
13
+ )
14
+ environment.addFilter('formatDate', formatDate)
15
+
16
+ describe('lisDate()', () => {
17
+ test('it formats an ISO date string as day, short month and year', () => {
18
+ // Arrange
19
+ const params = { value: '2024-11-29' }
20
+
21
+ // Act
22
+ const html = environment.render('date/template.njk', { params })
23
+
24
+ // Assert
25
+ expect(html.trim()).toEqual('29 Nov 2024')
26
+ })
27
+
28
+ test('it does not zero-pad a single-digit day', () => {
29
+ // Arrange
30
+ const params = { value: '2023-02-01' }
31
+
32
+ // Act
33
+ const html = environment.render('date/template.njk', { params })
34
+
35
+ // Assert
36
+ expect(html.trim()).toEqual('1 Feb 2023')
37
+ })
38
+
39
+ test('it formats a Date value', () => {
40
+ // Arrange
41
+ const params = { value: new Date(2024, 10, 29) }
42
+
43
+ // Act
44
+ const html = environment.render('date/template.njk', { params })
45
+
46
+ // Assert
47
+ expect(html.trim()).toEqual('29 Nov 2024')
48
+ })
49
+
50
+ test('it renders nothing when there is no value', () => {
51
+ // Arrange
52
+ const params = { value: null }
53
+
54
+ // Act
55
+ const html = environment.render('date/template.njk', { params })
56
+
57
+ // Assert
58
+ expect(html.trim()).toEqual('')
59
+ })
60
+
61
+ test('it renders with no surrounding whitespace when called inline', () => {
62
+ // Arrange
63
+ const template =
64
+ '{% from "date/macro.njk" import lisDate %}<td>{{ lisDate(value) }}</td>'
65
+
66
+ // Act
67
+ const html = environment.renderString(template, { value: '2024-11-29' })
68
+
69
+ // Assert
70
+ expect(html).toEqual('<td>29 Nov 2024</td>')
71
+ })
72
+ })
@@ -0,0 +1,3 @@
1
+ {% macro lisDate(value) -%}
2
+ {%- if value %}{{ value | formatDate("d MMM yyyy") }}{% endif -%}
3
+ {%- endmacro %}
@@ -0,0 +1,3 @@
1
+ {% from "date/macro.njk" import lisDate %}
2
+
3
+ {{ lisDate(params.value) }}
@@ -36,6 +36,25 @@ describe('searchForm()', () => {
36
36
  expect(html).toContain('name="search"')
37
37
  })
38
38
 
39
+ test('links the hint to the input so it is read out with the label', () => {
40
+ // Act
41
+ const html = render({ label: 'Search', hint: 'Search by ear tag' })
42
+
43
+ // Assert
44
+ expect(html).toContain(
45
+ '<p class="govuk-body" id="search-hint">Search by ear tag</p>'
46
+ )
47
+ expect(html).toContain('aria-describedby="search-hint"')
48
+ })
49
+
50
+ test('does not describe the input by a hint when none is given', () => {
51
+ // Act
52
+ const html = render({ label: 'Search' })
53
+
54
+ // Assert
55
+ expect(html).not.toContain('aria-describedby')
56
+ })
57
+
39
58
  test('defaults the input name and id to "search"', () => {
40
59
  // Act
41
60
  const html = render({ label: 'Search' })
@@ -8,7 +8,7 @@
8
8
  </h2>
9
9
 
10
10
  {%- if params.hint -%}
11
- <p class="govuk-body">{{ params.hint }}</p>
11
+ <p class="govuk-body" id="{{ id }}-hint">{{ params.hint }}</p>
12
12
  {%- endif -%}
13
13
 
14
14
  <form method="GET" class="lis-search-form">
@@ -19,6 +19,7 @@
19
19
  id="{{ id }}"
20
20
  name="{{ params.name or 'search' }}"
21
21
  value="{{ params.value }}"
22
+ {% if params.hint %}aria-describedby="{{ id }}-hint"{% endif %}
22
23
  >
23
24
  <div class="lis-search-form__submit-wrapper">
24
25
  <button class="lis-search-form__submit" type="submit">
@@ -123,6 +123,56 @@ test('passes rows straight through to the table', () => {
123
123
  assert.match(html, /<td class="govuk-table__cell">UK 324537 113234<\/td>/)
124
124
  })
125
125
 
126
+ test('tells screen reader users that the column headers are sortable', () => {
127
+ const html = render({
128
+ caption: 'Animals',
129
+ columns,
130
+ rows,
131
+ baseHref: '/animals'
132
+ })
133
+
134
+ assert.match(
135
+ html,
136
+ /<caption class="govuk-table__caption govuk-visually-hidden">\s*Animals<span class="govuk-visually-hidden"> \(column headers with links are sortable\)\.<\/span>\s*<\/caption>/
137
+ )
138
+ })
139
+
140
+ test('omits the sortable hint when no column has a sortKey', () => {
141
+ const html = render({
142
+ caption: 'Animals',
143
+ columns: [{ text: 'Ear tag number' }, { text: 'Age' }, { text: 'Breed' }],
144
+ rows,
145
+ baseHref: '/animals'
146
+ })
147
+
148
+ assert.doesNotMatch(html, /are sortable/)
149
+ })
150
+
151
+ test('renders no caption, and a generic region label, when no caption is given', () => {
152
+ const html = render({
153
+ columns,
154
+ rows,
155
+ baseHref: '/animals'
156
+ })
157
+
158
+ assert.doesNotMatch(html, /<caption/)
159
+ assert.match(html, /role="region" aria-label="Table">/)
160
+ })
161
+
162
+ test('wraps the table in a focusable, labelled region so it can be scrolled by keyboard', () => {
163
+ const html = render({
164
+ caption: 'Animals',
165
+ columns,
166
+ rows,
167
+ baseHref: '/animals'
168
+ })
169
+
170
+ assert.match(
171
+ html,
172
+ /<div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="Animals table">\s*<table/
173
+ )
174
+ })
175
+
126
176
  test('renders the results summary but no pagination nav when there is only one page', () => {
127
177
  const html = render({
128
178
  caption: 'Animals',
@@ -4,27 +4,30 @@
4
4
  {%- set query = params.query or {} -%}
5
5
 
6
6
  {%- set upArrow -%}
7
- <svg width="22" height="22" focusable="false" aria-hidden="true" role="img" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
7
+ <svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
8
8
  <path d="M6.5625 15.5L11 6.63125L15.4375 15.5H6.5625Z" fill="currentColor"></path>
9
9
  </svg>
10
10
  {%- endset -%}
11
11
 
12
12
  {%- set downArrow -%}
13
- <svg width="22" height="22" focusable="false" aria-hidden="true" role="img" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
13
+ <svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
14
14
  <path d="M15.4375 7L11 15.8687L6.5625 7L15.4375 7Z" fill="currentColor"></path>
15
15
  </svg>
16
16
  {%- endset -%}
17
17
 
18
18
  {%- set upDownArrow -%}
19
- <svg width="22" height="22" focusable="false" aria-hidden="true" role="img" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
19
+ <svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
20
20
  <path d="M8.1875 9.5L10.9609 3.95703L13.7344 9.5H8.1875Z" fill="currentColor"></path>
21
21
  <path d="M13.7344 12.0781L10.9609 17.6211L8.1875 12.0781H13.7344Z" fill="currentColor"></path>
22
22
  </svg>
23
23
  {%- endset -%}
24
24
 
25
25
  {%- set head = [] -%}
26
+ {#- a list, since a set inside the loop wouldn't be visible outside it -#}
27
+ {%- set sortableColumns = [] -%}
26
28
  {%- for column in params.columns -%}
27
29
  {%- if column.sortKey -%}
30
+ {%- set _ = sortableColumns.push(column.sortKey) -%}
28
31
  {%- set isActive = params.sort == column.sortKey -%}
29
32
  {%- set ariaSort = "ascending" if (isActive and params.direction == "asc") else ("descending" if (isActive and params.direction == "desc") else "none") -%}
30
33
  {%- set nextDirection = "desc" if ariaSort == "ascending" else "asc" -%}
@@ -39,14 +42,20 @@
39
42
  {%- endif -%}
40
43
  {%- endfor -%}
41
44
 
42
- {{ govukTable({
43
- caption: params.caption,
44
- captionClasses: params.captionClasses or "govuk-visually-hidden",
45
- classes: params.classes,
46
- firstCellIsHeader: params.firstCellIsHeader,
47
- head: head,
48
- rows: params.rows
49
- }) }}
45
+ {%- set captionHtml -%}
46
+ {{ params.caption }}{% if sortableColumns | length %}<span class="govuk-visually-hidden"> (column headers with links are sortable).</span>{% endif %}
47
+ {%- endset -%}
48
+
49
+ <div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="{{ (params.caption ~ ' table') if params.caption else 'Table' }}">
50
+ {{ govukTable({
51
+ caption: (captionHtml | safe) if params.caption else undefined,
52
+ captionClasses: params.captionClasses or "govuk-visually-hidden",
53
+ classes: params.classes,
54
+ firstCellIsHeader: params.firstCellIsHeader,
55
+ head: head,
56
+ rows: params.rows
57
+ }) }}
58
+ </div>
50
59
 
51
60
  {%- if params.pagination -%}
52
61
  {%- set currentPage = params.pagination.currentPage -%}
@@ -1,6 +1,8 @@
1
1
  import assign from 'lodash/assign.js'
2
2
 
3
+ export { formatAge } from './format-age.js'
3
4
  export { formatDate } from './format-date.js'
5
+ export { formatEarTag } from './format-ear-tag.js'
4
6
  export { formatCurrency } from './format-currency.js'
5
7
  export { queryString } from './query-string.js'
6
8
 
@@ -0,0 +1,38 @@
1
+ import { intervalToDuration, isDate, isValid, parseISO } from 'date-fns'
2
+
3
+ /**
4
+ * @param {Date | string} dateOfBirth
5
+ * @param {Date} [now]
6
+ * @returns {string} e.g. "3 years, 6 months", "3 years" for a whole number
7
+ * of years, or "6 months" for an animal under a year old. Empty when there
8
+ * is no usable date of birth - missing, unparseable or in the future - so
9
+ * a page never shows a made-up age.
10
+ */
11
+ export function formatAge(dateOfBirth, now = new Date()) {
12
+ if (!dateOfBirth) {
13
+ return ''
14
+ }
15
+
16
+ const start = isDate(dateOfBirth) ? dateOfBirth : parseISO(dateOfBirth)
17
+
18
+ if (!isValid(start) || start > now) {
19
+ return ''
20
+ }
21
+
22
+ // intervalToDuration omits a unit entirely (rather than returning 0) when
23
+ // its value is zero, so years/months need a default.
24
+ const { years = 0, months = 0 } = intervalToDuration({ start, end: now })
25
+
26
+ const yearsText = `${years} year${years === 1 ? '' : 's'}`
27
+ const monthsText = `${months} month${months === 1 ? '' : 's'}`
28
+
29
+ if (years === 0) {
30
+ return months === 0 ? 'Less than 1 month' : monthsText
31
+ }
32
+
33
+ if (months === 0) {
34
+ return yearsText
35
+ }
36
+
37
+ return `${yearsText}, ${monthsText}`
38
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { formatAge } from './format-age.js'
4
+
5
+ describe('formatAge()', () => {
6
+ test('it computes whole years and months', () => {
7
+ // Arrange
8
+ const dateOfBirth = '2023-03-10'
9
+ const now = new Date('2026-09-10')
10
+
11
+ // Act
12
+ const age = formatAge(dateOfBirth, now)
13
+
14
+ // Assert
15
+ expect(age).toBe('3 years, 6 months')
16
+ })
17
+
18
+ test('it uses singular units for exactly 1 year and 1 month', () => {
19
+ // Arrange
20
+ const dateOfBirth = '2025-08-10'
21
+ const now = new Date('2026-09-10')
22
+
23
+ // Act
24
+ const age = formatAge(dateOfBirth, now)
25
+
26
+ // Assert
27
+ expect(age).toBe('1 year, 1 month')
28
+ })
29
+
30
+ test('it omits the months when the age is a whole number of years', () => {
31
+ // Arrange
32
+ const dateOfBirth = '2023-08-19'
33
+ const now = new Date('2026-09-11')
34
+
35
+ // Act
36
+ const age = formatAge(dateOfBirth, now)
37
+
38
+ // Assert
39
+ expect(age).toBe('3 years')
40
+ })
41
+
42
+ test('it uses the singular "year" for a whole number of exactly 1 year', () => {
43
+ // Arrange
44
+ const dateOfBirth = '2025-08-19'
45
+ const now = new Date('2026-09-11')
46
+
47
+ // Act
48
+ const age = formatAge(dateOfBirth, now)
49
+
50
+ // Assert
51
+ expect(age).toBe('1 year')
52
+ })
53
+
54
+ test('it shows only the months for an animal under a year old', () => {
55
+ // Arrange
56
+ const dateOfBirth = '2026-03-10'
57
+ const now = new Date('2026-09-10')
58
+
59
+ // Act
60
+ const age = formatAge(dateOfBirth, now)
61
+
62
+ // Assert
63
+ expect(age).toBe('6 months')
64
+ })
65
+
66
+ test('it uses the singular "month" for an animal exactly 1 month old', () => {
67
+ // Arrange
68
+ const dateOfBirth = '2026-08-10'
69
+ const now = new Date('2026-09-10')
70
+
71
+ // Act
72
+ const age = formatAge(dateOfBirth, now)
73
+
74
+ // Assert
75
+ expect(age).toBe('1 month')
76
+ })
77
+
78
+ test('it shows "Less than 1 month" for an animal under a month old', () => {
79
+ // Arrange
80
+ const dateOfBirth = '2026-09-01'
81
+ const now = new Date('2026-09-10')
82
+
83
+ // Act
84
+ const age = formatAge(dateOfBirth, now)
85
+
86
+ // Assert
87
+ expect(age).toBe('Less than 1 month')
88
+ })
89
+
90
+ test('it accepts a Date value', () => {
91
+ // Arrange
92
+ const dateOfBirth = new Date('2023-03-10')
93
+ const now = new Date('2026-09-10')
94
+
95
+ // Act
96
+ const age = formatAge(dateOfBirth, now)
97
+
98
+ // Assert
99
+ expect(age).toBe('3 years, 6 months')
100
+ })
101
+
102
+ test('it returns an empty string when there is no date of birth', () => {
103
+ // Arrange
104
+ const dateOfBirth = null
105
+ const now = new Date('2026-09-10')
106
+
107
+ // Act
108
+ const age = formatAge(dateOfBirth, now)
109
+
110
+ // Assert
111
+ expect(age).toBe('')
112
+ })
113
+
114
+ test('it returns an empty string for an unparseable date of birth', () => {
115
+ // Arrange
116
+ const dateOfBirth = 'not-a-date'
117
+ const now = new Date('2026-09-10')
118
+
119
+ // Act
120
+ const age = formatAge(dateOfBirth, now)
121
+
122
+ // Assert
123
+ expect(age).toBe('')
124
+ })
125
+
126
+ test('it returns an empty string for a date of birth in the future', () => {
127
+ // Arrange
128
+ const dateOfBirth = '2028-01-01'
129
+ const now = new Date('2026-09-10')
130
+
131
+ // Act
132
+ const age = formatAge(dateOfBirth, now)
133
+
134
+ // Assert
135
+ expect(age).toBe('')
136
+ })
137
+ })
@@ -0,0 +1,26 @@
1
+ // The UK country code, then a six-digit herd mark and a six-digit animal
2
+ // number - e.g. "UK324537113236".
3
+ const earTagPattern = /^(UK)(\d{6})(\d{6})$/
4
+
5
+ /**
6
+ * @param {string} earTag
7
+ * @returns {string[] | undefined} the country code, herd mark and animal
8
+ * number, or undefined if the ear tag isn't in that format
9
+ */
10
+ function earTagGroups(earTag) {
11
+ return earTag.replace(/\s+/g, '').toUpperCase().match(earTagPattern)?.slice(1)
12
+ }
13
+
14
+ /**
15
+ * @param {string} earTag e.g. "UK324537113236"
16
+ * @returns {string} e.g. "UK 324537 113236" - the country code, herd mark
17
+ * and animal number. Anything else - an ear tag in another format, or a
18
+ * value that isn't a string - is returned as-is.
19
+ */
20
+ export function formatEarTag(earTag) {
21
+ if (typeof earTag !== 'string') {
22
+ return earTag
23
+ }
24
+
25
+ return earTagGroups(earTag)?.join(' ') ?? earTag
26
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { formatEarTag } from './format-ear-tag.js'
4
+
5
+ describe('formatEarTag()', () => {
6
+ test('it splits an ear tag into country code, herd mark and animal number', () => {
7
+ // Arrange
8
+ const earTag = 'UK324537113236'
9
+
10
+ // Act
11
+ const formatted = formatEarTag(earTag)
12
+
13
+ // Assert
14
+ expect(formatted).toBe('UK 324537 113236')
15
+ })
16
+
17
+ test('it leaves an already formatted ear tag unchanged', () => {
18
+ // Arrange
19
+ const earTag = 'UK 324537 113236'
20
+
21
+ // Act
22
+ const formatted = formatEarTag(earTag)
23
+
24
+ // Assert
25
+ expect(formatted).toBe('UK 324537 113236')
26
+ })
27
+
28
+ test('it upper-cases the country code', () => {
29
+ // Arrange
30
+ const earTag = 'uk324537113236'
31
+
32
+ // Act
33
+ const formatted = formatEarTag(earTag)
34
+
35
+ // Assert
36
+ expect(formatted).toBe('UK 324537 113236')
37
+ })
38
+
39
+ test('it returns an ear tag with another country code as-is', () => {
40
+ // Arrange
41
+ const earTag = 'IE151234567890'
42
+
43
+ // Act
44
+ const formatted = formatEarTag(earTag)
45
+
46
+ // Assert
47
+ expect(formatted).toBe('IE151234567890')
48
+ })
49
+
50
+ test('it returns an ear tag of the wrong length as-is', () => {
51
+ // Arrange
52
+ const earTag = 'UK32453711323'
53
+
54
+ // Act
55
+ const formatted = formatEarTag(earTag)
56
+
57
+ // Assert
58
+ expect(formatted).toBe('UK32453711323')
59
+ })
60
+
61
+ test('it returns an ear tag in another format exactly as given', () => {
62
+ // Arrange
63
+ const earTag = 'ab 1234/x'
64
+
65
+ // Act
66
+ const formatted = formatEarTag(earTag)
67
+
68
+ // Assert
69
+ expect(formatted).toBe('ab 1234/x')
70
+ })
71
+
72
+ test('it returns a missing ear tag as-is', () => {
73
+ // Arrange
74
+ const earTag = null
75
+
76
+ // Act
77
+ const formatted = formatEarTag(earTag)
78
+
79
+ // Assert
80
+ expect(formatted).toBeNull()
81
+ })
82
+ })