@defra/lis-infra-ui-services 0.24.0 → 0.25.0-lreg-666.alpha.1

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
@@ -15,7 +15,7 @@ Install dependencies for this package locally with `npm install` from this direc
15
15
  | 503 | `service-unavailable` |
16
16
  | anything else | `problem-with-service` |
17
17
 
18
- The links on these pages come from the `errorPages` option of `createNunjucksConfig` (or `createNunjucksContextBuilder`), exposed to templates as `errorPages`. Every key is optional:
18
+ The contact link on these pages comes from the `errorPages` option of `createNunjucksConfig` (or `createNunjucksContextBuilder`), exposed to templates as `errorPages`. The key is optional:
19
19
 
20
20
  ```js
21
21
  createNunjucksConfig({
@@ -24,13 +24,41 @@ createNunjucksConfig({
24
24
  getRequestBasePath,
25
25
  errorPages: {
26
26
  contactHref:
27
- 'https://www.gov.uk/government/organisations/british-cattle-movement-service', // default
28
- homeHref: '/', // default, "Home page" on page not found
29
- getStartedHref: '/' // default, "Get started" on page not found
27
+ 'https://www.gov.uk/government/organisations/british-cattle-movement-service' // default
30
28
  }
31
29
  })
32
30
  ```
33
31
 
32
+ ## Layout scripts
33
+
34
+ `layoutComponents` from `@defra/lis-infra-ui-services/components/layout` is the list of GOV.UK Frontend components the layout template renders: Service navigation and Skip link.
35
+
36
+ `errorPageComponents` from `@defra/lis-infra-ui-services/components/error-pages` is the list the shared error pages render (Button, for the not-authorised sign-in button). Every app that serves the shared error pages must add it.
37
+
38
+ Start each with `createAll`, alongside any page-level components (see the full example under [Scrollable table](#scrollable-table)).
39
+
40
+ Wrap the combined list in a `Set`, so a component that appears in more than one list (or that the app also lists itself) is only initialised once: govuk's base class logs an InitError on double init.
41
+
42
+ ### Scrollable table
43
+
44
+ Apps that render the sortable paginated table macro also start `ScrollableTable` from `@defra/lis-infra-ui-services/components/scrollable-table`. It drops the tab stop (and the region role and label) from a table wrapper that fits, and restores them when the table overflows, re-checking whenever the wrapper or table changes size. Without JavaScript the wrapper stays focusable.
45
+
46
+ ```js
47
+ import { createAll, Tabs } from 'govuk-frontend'
48
+ import { errorPageComponents } from '@defra/lis-infra-ui-services/components/error-pages'
49
+ import { layoutComponents } from '@defra/lis-infra-ui-services/components/layout'
50
+ import { ScrollableTable } from '@defra/lis-infra-ui-services/components/scrollable-table'
51
+
52
+ for (const component of new Set([
53
+ ...layoutComponents,
54
+ ...errorPageComponents,
55
+ ScrollableTable,
56
+ Tabs
57
+ ])) {
58
+ createAll(component)
59
+ }
60
+ ```
61
+
34
62
  ## Scheduled maintenance
35
63
 
36
64
  Each hub switches maintenance on with its own config value, `maintenance.availableFrom`: the UK local date-time the service is back, in ISO 8601 form without an offset, e.g. `2026-11-19T09:00` or `2026-11-19T09:00:00`. Values with a `Z` or `+hh:mm` offset, and dates without a time, throw at start-up. An empty value leaves the service running as normal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.24.0",
3
+ "version": "0.25.0-lreg-666.alpha.1",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -11,6 +11,9 @@
11
11
  ".": "./src/index.js",
12
12
  "./base-path": "./src/base-path.js",
13
13
  "./components/autocomplete": "./src/components/autocomplete.js",
14
+ "./components/error-pages": "./src/components/error-pages.js",
15
+ "./components/layout": "./src/components/layout.js",
16
+ "./components/scrollable-table": "./src/components/scrollable-table.js",
14
17
  "./components/autocomplete.scss": "./src/components/autocomplete.scss",
15
18
  "./components/search-form.scss": "./src/components/search-form.scss",
16
19
  "./components/service-navigation.scss": "./src/components/service-navigation.scss",
@@ -71,7 +74,8 @@
71
74
  "peerDependencies": {
72
75
  "@defra/lis-hubs-infra-access": ">=0.29.0 <1.0.0",
73
76
  "@defra/lis-hubs-infra-core": ">=0.4.0 <1.0.0",
74
- "eslint": "^9.0.0"
77
+ "eslint": "^9.0.0",
78
+ "govuk-frontend": ">=6.5.0 <7.0.0"
75
79
  },
76
80
  "devDependencies": {
77
81
  "@defra/lis-hubs-infra-access": "^0.29.0",
@@ -81,7 +85,9 @@
81
85
  "@vitest/coverage-istanbul": "^4.1.10",
82
86
  "audit-ci": "^7.1.0",
83
87
  "eslint": "^9.0.0",
88
+ "govuk-frontend": "6.5.0",
84
89
  "husky": "^9.1.7",
90
+ "jsdom": "^29.1.1",
85
91
  "prettier": "^3.8.4",
86
92
  "vitest": "^4.1.10"
87
93
  }
@@ -0,0 +1,3 @@
1
+ import { Button } from 'govuk-frontend'
2
+
3
+ export const errorPageComponents = [Button]
@@ -0,0 +1,18 @@
1
+ // @vitest-environment jsdom
2
+ import { Button } from 'govuk-frontend'
3
+ import { describe, expect, test } from 'vitest'
4
+
5
+ import { errorPageComponents } from './error-pages.js'
6
+
7
+ describe('errorPageComponents', () => {
8
+ test('contains the error page components in order', () => {
9
+ // Arrange
10
+ const expected = [Button]
11
+
12
+ // Act
13
+ const actual = errorPageComponents
14
+
15
+ // Assert
16
+ expect(actual).toEqual(expected)
17
+ })
18
+ })
@@ -0,0 +1,3 @@
1
+ import { ServiceNavigation, SkipLink } from 'govuk-frontend'
2
+
3
+ export const layoutComponents = [ServiceNavigation, SkipLink]
@@ -0,0 +1,18 @@
1
+ // @vitest-environment jsdom
2
+ import { ServiceNavigation, SkipLink } from 'govuk-frontend'
3
+ import { describe, expect, test } from 'vitest'
4
+
5
+ import { layoutComponents } from './layout.js'
6
+
7
+ describe('layoutComponents', () => {
8
+ test('contains the layout components in order', () => {
9
+ // Arrange
10
+ const expected = [ServiceNavigation, SkipLink]
11
+
12
+ // Act
13
+ const actual = layoutComponents
14
+
15
+ // Assert
16
+ expect(actual).toEqual(expected)
17
+ })
18
+ })
@@ -0,0 +1,68 @@
1
+ import { Component } from 'govuk-frontend'
2
+
3
+ const ARIA_LABEL_ATTRIBUTE = 'aria-label'
4
+ const ORIGINAL_LABEL_ATTRIBUTE = 'data-original-aria-label'
5
+
6
+ /**
7
+ * Drops the tab stop from a scrollable region while its content fits.
8
+ */
9
+ export class ScrollableTable extends Component {
10
+ static moduleName = 'lis-scrollable-table'
11
+
12
+ /**
13
+ * @param {object} $root - the scrollable wrapper element
14
+ */
15
+ constructor($root) {
16
+ super($root)
17
+
18
+ this.update()
19
+
20
+ // Watches the table as well as the wrapper: the table can widen after
21
+ // start-up without the window resizing, for example when the font loads.
22
+ const resizeObserver = new globalThis.ResizeObserver(() => this.update())
23
+ resizeObserver.observe(this.$root)
24
+ for (const $child of this.$root.children) {
25
+ resizeObserver.observe($child)
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Applies the focusable region attributes only while the content overflows.
31
+ */
32
+ update() {
33
+ if (this.$root.scrollWidth > this.$root.clientWidth) {
34
+ this.makeFocusableRegion()
35
+ } else {
36
+ this.removeFocusableRegion()
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Lets keyboard users reach the region to scroll it.
42
+ */
43
+ makeFocusableRegion() {
44
+ const label =
45
+ this.$root.getAttribute(ORIGINAL_LABEL_ATTRIBUTE) ??
46
+ this.$root.getAttribute(ARIA_LABEL_ATTRIBUTE)
47
+
48
+ if (label !== null) {
49
+ this.$root.setAttribute(ARIA_LABEL_ATTRIBUTE, label)
50
+ }
51
+ this.$root.setAttribute('tabindex', '0')
52
+ this.$root.setAttribute('role', 'region')
53
+ }
54
+
55
+ /**
56
+ * Avoids an empty tab stop on a table that fits, keeping the label to restore later.
57
+ */
58
+ removeFocusableRegion() {
59
+ const label = this.$root.getAttribute(ARIA_LABEL_ATTRIBUTE)
60
+
61
+ if (label !== null) {
62
+ this.$root.setAttribute(ORIGINAL_LABEL_ATTRIBUTE, label)
63
+ }
64
+ this.$root.removeAttribute('tabindex')
65
+ this.$root.removeAttribute('role')
66
+ this.$root.removeAttribute(ARIA_LABEL_ATTRIBUTE)
67
+ }
68
+ }
@@ -0,0 +1,178 @@
1
+ // @vitest-environment jsdom
2
+ import { createAll } from 'govuk-frontend'
3
+ import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'
4
+
5
+ import { ScrollableTable } from './scrollable-table.js'
6
+
7
+ function setWidths(element, { scrollWidth, clientWidth }) {
8
+ Object.defineProperty(element, 'scrollWidth', {
9
+ configurable: true,
10
+ value: scrollWidth
11
+ })
12
+ Object.defineProperty(element, 'clientWidth', {
13
+ configurable: true,
14
+ value: clientWidth
15
+ })
16
+ }
17
+
18
+ const resizeObservers = []
19
+
20
+ class FakeResizeObserver {
21
+ constructor(callback) {
22
+ this.callback = callback
23
+ this.observed = []
24
+ resizeObservers.push(this)
25
+ }
26
+
27
+ observe(element) {
28
+ this.observed.push(element)
29
+ }
30
+ }
31
+
32
+ function triggerResize() {
33
+ for (const resizeObserver of resizeObservers) {
34
+ resizeObserver.callback([])
35
+ }
36
+ }
37
+
38
+ function renderWrapper(widths) {
39
+ document.body.innerHTML =
40
+ '<div data-module="lis-scrollable-table" tabindex="0" role="region" aria-label="Animals"><table></table></div>'
41
+ const wrapper = document.querySelector('[data-module]')
42
+ setWidths(wrapper, widths)
43
+ return wrapper
44
+ }
45
+
46
+ describe('ScrollableTable', () => {
47
+ beforeAll(() => {
48
+ document.body.classList.add('govuk-frontend-supported')
49
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
50
+ })
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks()
54
+ resizeObservers.length = 0
55
+ document.body.innerHTML = ''
56
+ })
57
+
58
+ test('removes the focusable region attributes when the table does not overflow', () => {
59
+ // Arrange
60
+ const wrapper = renderWrapper({ scrollWidth: 300, clientWidth: 300 })
61
+
62
+ // Act
63
+ createAll(ScrollableTable)
64
+
65
+ // Assert
66
+ expect(wrapper.hasAttribute('tabindex')).toBe(false)
67
+ expect(wrapper.hasAttribute('role')).toBe(false)
68
+ expect(wrapper.hasAttribute('aria-label')).toBe(false)
69
+ expect(wrapper.getAttribute('data-original-aria-label')).toBe('Animals')
70
+ expect(wrapper.hasAttribute('data-lis-scrollable-table-init')).toBe(true)
71
+ })
72
+
73
+ test('keeps the focusable region attributes when the table overflows', () => {
74
+ // Arrange
75
+ const wrapper = renderWrapper({ scrollWidth: 600, clientWidth: 300 })
76
+
77
+ // Act
78
+ createAll(ScrollableTable)
79
+
80
+ // Assert
81
+ expect(wrapper.getAttribute('tabindex')).toBe('0')
82
+ expect(wrapper.getAttribute('role')).toBe('region')
83
+ expect(wrapper.getAttribute('aria-label')).toBe('Animals')
84
+ })
85
+
86
+ test('restores the attributes, including the label, when the table starts to overflow when resized', () => {
87
+ // Arrange
88
+ const wrapper = renderWrapper({ scrollWidth: 300, clientWidth: 300 })
89
+ createAll(ScrollableTable)
90
+
91
+ // Act
92
+ setWidths(wrapper, { scrollWidth: 600, clientWidth: 300 })
93
+ triggerResize()
94
+
95
+ // Assert
96
+ expect(wrapper.getAttribute('tabindex')).toBe('0')
97
+ expect(wrapper.getAttribute('role')).toBe('region')
98
+ expect(wrapper.getAttribute('aria-label')).toBe('Animals')
99
+ })
100
+
101
+ test('removes the attributes again when the table stops overflowing when resized', () => {
102
+ // Arrange
103
+ const wrapper = renderWrapper({ scrollWidth: 600, clientWidth: 300 })
104
+ createAll(ScrollableTable)
105
+
106
+ // Act
107
+ setWidths(wrapper, { scrollWidth: 300, clientWidth: 300 })
108
+ triggerResize()
109
+
110
+ // Assert
111
+ expect(wrapper.hasAttribute('tabindex')).toBe(false)
112
+ expect(wrapper.hasAttribute('aria-label')).toBe(false)
113
+ })
114
+
115
+ test('leaves a wrapper without an aria-label unlabelled when restoring', () => {
116
+ // Arrange
117
+ const wrapper = renderWrapper({ scrollWidth: 300, clientWidth: 300 })
118
+ wrapper.removeAttribute('aria-label')
119
+ createAll(ScrollableTable)
120
+
121
+ // Act
122
+ setWidths(wrapper, { scrollWidth: 600, clientWidth: 300 })
123
+ triggerResize()
124
+
125
+ // Assert
126
+ expect(wrapper.getAttribute('role')).toBe('region')
127
+ expect(wrapper.hasAttribute('aria-label')).toBe(false)
128
+ })
129
+
130
+ test('watches both the wrapper and the table for size changes', () => {
131
+ // Arrange
132
+ const wrapper = renderWrapper({ scrollWidth: 300, clientWidth: 300 })
133
+
134
+ // Act
135
+ createAll(ScrollableTable)
136
+
137
+ // Assert
138
+ expect(resizeObservers[0].observed).toEqual([
139
+ wrapper,
140
+ wrapper.querySelector('table')
141
+ ])
142
+ })
143
+
144
+ test('throws when constructed twice on the same element', () => {
145
+ // Arrange
146
+ const wrapper = renderWrapper({ scrollWidth: 300, clientWidth: 300 })
147
+ const first = new ScrollableTable(wrapper)
148
+
149
+ // Act
150
+ let second, error
151
+ try {
152
+ second = new ScrollableTable(wrapper)
153
+ } catch (e) {
154
+ error = e
155
+ }
156
+
157
+ // Assert
158
+ expect(first).toBeInstanceOf(ScrollableTable)
159
+ expect(second).toBeUndefined()
160
+ expect(error).toBeDefined()
161
+ expect(error.name).toBe('InitError')
162
+ expect(resizeObservers).toHaveLength(1)
163
+ })
164
+
165
+ test('does not double-apply when createAll runs twice on the same document', () => {
166
+ // Arrange
167
+ renderWrapper({ scrollWidth: 300, clientWidth: 300 })
168
+ const log = vi.spyOn(console, 'log').mockReturnValueOnce(undefined)
169
+ createAll(ScrollableTable)
170
+
171
+ // Act
172
+ createAll(ScrollableTable)
173
+
174
+ // Assert
175
+ expect(resizeObservers).toHaveLength(1)
176
+ expect(log).toHaveBeenCalledTimes(1)
177
+ })
178
+ })
@@ -13,7 +13,7 @@
13
13
  }
14
14
 
15
15
  &:focus {
16
- outline: none;
16
+ outline: $govuk-focus-width solid transparent;
17
17
  color: govuk-functional-colour('focus-text');
18
18
  background-color: govuk-functional-colour('focus');
19
19
  box-shadow:
@@ -34,6 +34,27 @@
34
34
  position: relative;
35
35
  overflow-x: auto;
36
36
 
37
+ // Scroll shadows: an edge shadows only while more of the table is hidden
38
+ // past it. The body-coloured covers scroll with the content and hide each
39
+ // shadow once that end is reached, so a table that fits shows none.
40
+ background:
41
+ linear-gradient(
42
+ to right,
43
+ govuk-functional-colour('body-background') 30%,
44
+ transparent
45
+ )
46
+ left center / 40px 100% no-repeat local,
47
+ linear-gradient(
48
+ to left,
49
+ govuk-functional-colour('body-background') 30%,
50
+ transparent
51
+ )
52
+ right center / 40px 100% no-repeat local,
53
+ radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.2), transparent)
54
+ left center / 14px 100% no-repeat scroll,
55
+ radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.2), transparent)
56
+ right center / 14px 100% no-repeat scroll;
57
+
37
58
  // The GOV.UK non-text focus state - yellow alone has too little contrast
38
59
  // against a white page. Both rings sit outside the box, clear of the text.
39
60
  &:focus {
@@ -60,7 +60,7 @@
60
60
  0 -2px govuk-functional-colour('focus'),
61
61
  0 4px govuk-functional-colour('focus-text');
62
62
  text-decoration: none;
63
- outline: none;
63
+ outline: $govuk-focus-width solid transparent;
64
64
  }
65
65
 
66
66
  &[aria-current='page'] {
@@ -42,11 +42,43 @@ describe('searchForm()', () => {
42
42
 
43
43
  // Assert
44
44
  expect(html).toContain(
45
- '<p class="govuk-body" id="search-hint">Search by ear tag</p>'
45
+ '<div class="govuk-hint" id="search-hint">Search by ear tag</div>'
46
46
  )
47
47
  expect(html).toContain('aria-describedby="search-hint"')
48
48
  })
49
49
 
50
+ test('makes the form the search landmark, labelled by the label text', () => {
51
+ // Act
52
+ const html = render({ label: 'Search animals', hint: 'Search by ear tag' })
53
+
54
+ // Assert
55
+ expect(html).toContain(
56
+ '<form method="GET" class="lis-search-form" role="search" aria-label="Search animals">'
57
+ )
58
+ })
59
+
60
+ test('wraps the label, hint and input in the form', () => {
61
+ // Act
62
+ const html = render({ label: 'Search animals', hint: 'Search by ear tag' })
63
+
64
+ // Assert
65
+ expect(html.indexOf('<form')).toBeLessThan(html.indexOf('govuk-form-group'))
66
+ expect(html.indexOf('govuk-hint')).toBeLessThan(html.indexOf('</form>'))
67
+ expect(html.indexOf('</form>')).toBeGreaterThan(
68
+ html.indexOf('type="search"')
69
+ )
70
+ })
71
+
72
+ test('renders the inset text outside the form', () => {
73
+ // Act
74
+ const html = render({ label: 'Search', insetText: 'Up to 24 hours' })
75
+
76
+ // Assert
77
+ expect(html.indexOf('govuk-inset-text')).toBeGreaterThan(
78
+ html.indexOf('</form>')
79
+ )
80
+ })
81
+
50
82
  test('does not describe the input by a hint when none is given', () => {
51
83
  // Act
52
84
  const html = render({ label: 'Search' })
@@ -1,17 +1,17 @@
1
1
  {%- set id = params.id or params.name or "search" -%}
2
2
 
3
- <div class="govuk-form-group">
4
- <h2 class="govuk-label-wrapper">
5
- <label class="govuk-label govuk-label--m" for="{{ id }}">
6
- {{ params.label }}
7
- </label>
8
- </h2>
3
+ <form method="GET" class="lis-search-form" role="search" aria-label="{{ params.label }}">
4
+ <div class="govuk-form-group">
5
+ <h2 class="govuk-label-wrapper">
6
+ <label class="govuk-label govuk-label--m" for="{{ id }}">
7
+ {{ params.label }}
8
+ </label>
9
+ </h2>
9
10
 
10
- {%- if params.hint -%}
11
- <p class="govuk-body" id="{{ id }}-hint">{{ params.hint }}</p>
12
- {%- endif -%}
11
+ {%- if params.hint -%}
12
+ <div class="govuk-hint" id="{{ id }}-hint">{{ params.hint }}</div>
13
+ {%- endif -%}
13
14
 
14
- <form method="GET" class="lis-search-form">
15
15
  {%- for field in params.hiddenFields -%}
16
16
  <input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
17
17
  {%- endfor -%}
@@ -34,8 +34,8 @@
34
34
  </button>
35
35
  </div>
36
36
  </div>
37
- </form>
38
- </div>
37
+ </div>
38
+ </form>
39
39
 
40
40
  {%- if params.insetText -%}
41
41
  <div class="govuk-inset-text">
@@ -169,7 +169,21 @@ test('wraps the table in a focusable, labelled region so it can be scrolled by k
169
169
 
170
170
  assert.match(
171
171
  html,
172
- /<div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="Animals table">\s*<table/
172
+ /<div class="lis-sortable-table__wrapper" data-module="lis-scrollable-table" tabindex="0" role="region" aria-label="Animals table">\s*<table/
173
+ )
174
+ })
175
+
176
+ test('marks the wrapper as a lis-scrollable-table module', () => {
177
+ const html = render({
178
+ caption: 'Animals',
179
+ columns,
180
+ rows,
181
+ baseHref: '/animals'
182
+ })
183
+
184
+ assert.match(
185
+ html,
186
+ /<div class="lis-sortable-table__wrapper" data-module="lis-scrollable-table"/
173
187
  )
174
188
  })
175
189
 
@@ -46,7 +46,7 @@
46
46
  {{ params.caption }}{% if sortableColumns | length %}<span class="govuk-visually-hidden"> (column headers with links are sortable).</span>{% endif %}
47
47
  {%- endset -%}
48
48
 
49
- <div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="{{ (params.caption ~ ' table') if params.caption else 'Table' }}">
49
+ <div class="lis-sortable-table__wrapper" data-module="lis-scrollable-table" tabindex="0" role="region" aria-label="{{ (params.caption ~ ' table') if params.caption else 'Table' }}">
50
50
  {{ govukTable({
51
51
  caption: (captionHtml | safe) if params.caption else undefined,
52
52
  captionClasses: params.captionClasses or "govuk-visually-hidden",
@@ -52,8 +52,6 @@ function createPermissionCheck(request) {
52
52
  /**
53
53
  * @typedef {object} ErrorPageLinks
54
54
  * @property {string} [contactHref] - every error page's contact link; defaults to the BCMS GOV.UK page
55
- * @property {string} [homeHref] - the page not found "Home page" link; defaults to '/'
56
- * @property {string} [getStartedHref] - the page not found "Get started" link; defaults to '/'
57
55
  */
58
56
 
59
57
  /**
@@ -69,9 +67,7 @@ export function createNunjucksContextBuilder({
69
67
  errorPages = {}
70
68
  }) {
71
69
  const errorPageLinks = {
72
- contactHref: errorPages.contactHref ?? BCMS_CONTACT_HREF,
73
- homeHref: errorPages.homeHref ?? '/',
74
- getStartedHref: errorPages.getStartedHref ?? '/'
70
+ contactHref: errorPages.contactHref ?? BCMS_CONTACT_HREF
75
71
  }
76
72
  const sectionName = moduleId ? (getModuleById(moduleId)?.label ?? null) : null
77
73
  const manifestPath = path.join(
@@ -292,7 +292,7 @@ describe('createNunjucksContextBuilder() hub flags', () => {
292
292
  })
293
293
 
294
294
  describe('createNunjucksContextBuilder() errorPages', () => {
295
- test('defaults the contact link to the BCMS GOV.UK page and the other links to the root', () => {
295
+ test('defaults the contact link to the BCMS GOV.UK page', () => {
296
296
  // Arrange
297
297
  const contextBuilder = createNunjucksContextBuilder({
298
298
  config: createConfig(),
@@ -308,9 +308,7 @@ describe('createNunjucksContextBuilder() errorPages', () => {
308
308
  // Assert
309
309
  expect(context.errorPages).toEqual({
310
310
  contactHref:
311
- 'https://www.gov.uk/government/organisations/british-cattle-movement-service',
312
- homeHref: '/',
313
- getStartedHref: '/'
311
+ 'https://www.gov.uk/government/organisations/british-cattle-movement-service'
314
312
  })
315
313
  })
316
314
 
@@ -322,9 +320,7 @@ describe('createNunjucksContextBuilder() errorPages', () => {
322
320
  logger: {},
323
321
  readFileSync: () => '{}',
324
322
  errorPages: {
325
- contactHref: 'https://example.com/contact',
326
- homeHref: '/home',
327
- getStartedHref: '/start'
323
+ contactHref: 'https://example.com/contact'
328
324
  }
329
325
  })
330
326
  const request = { headers: {}, path: '/', auth: { credentials: null } }
@@ -334,20 +330,18 @@ describe('createNunjucksContextBuilder() errorPages', () => {
334
330
 
335
331
  // Assert
336
332
  expect(context.errorPages).toEqual({
337
- contactHref: 'https://example.com/contact',
338
- homeHref: '/home',
339
- getStartedHref: '/start'
333
+ contactHref: 'https://example.com/contact'
340
334
  })
341
335
  })
342
336
 
343
- test('keeps the default for any link the errorPages option leaves out', () => {
337
+ test('keeps the default for a link the errorPages option leaves undefined', () => {
344
338
  // Arrange
345
339
  const contextBuilder = createNunjucksContextBuilder({
346
340
  config: createConfig(),
347
341
  getRequestBasePath: () => '',
348
342
  logger: {},
349
343
  readFileSync: () => '{}',
350
- errorPages: { getStartedHref: '/start', homeHref: undefined }
344
+ errorPages: { contactHref: undefined }
351
345
  })
352
346
  const request = { headers: {}, path: '/', auth: { credentials: null } }
353
347
 
@@ -357,9 +351,7 @@ describe('createNunjucksContextBuilder() errorPages', () => {
357
351
  // Assert
358
352
  expect(context.errorPages).toEqual({
359
353
  contactHref:
360
- 'https://www.gov.uk/government/organisations/british-cattle-movement-service',
361
- homeHref: '/',
362
- getStartedHref: '/start'
354
+ 'https://www.gov.uk/government/organisations/british-cattle-movement-service'
363
355
  })
364
356
  })
365
357
  })
@@ -94,9 +94,7 @@ describe('createNunjucksConfig()', () => {
94
94
  get: vi.fn((key) => (key === 'root' ? projectRoot : false))
95
95
  }
96
96
  const errorPages = {
97
- contactHref: 'https://example.com/contact',
98
- homeHref: '/home',
99
- getStartedHref: '/start'
97
+ contactHref: 'https://example.com/contact'
100
98
  }
101
99
 
102
100
  // Act
@@ -38,7 +38,7 @@ function textOf(html) {
38
38
  }
39
39
 
40
40
  describe('error pages', () => {
41
- test('page not found shows the heading, the three links and the support link', () => {
41
+ test('page not found shows the heading, the guidance and the BCMS link', () => {
42
42
  // Arrange
43
43
  const context = { pageTitle: 'Page not found' }
44
44
 
@@ -51,18 +51,13 @@ describe('error pages', () => {
51
51
  )
52
52
  expect(html).toContain('<h1 class="govuk-heading-l">Page not found</h1>')
53
53
  expect(textOf(html)).toContain(
54
- "Sorry, we could not find the page you were looking for. You might find what you're looking for by visiting: Home page Get started GOV.UK If you cannot find the information you need, contact our support team."
54
+ 'If you typed the web address, check it is correct. If you pasted the web address, check you copied the entire address. If the web address is correct or you selected a link or button, contact the British Cattle Movement Service (BCMS) if you need to speak to someone about your account.'
55
55
  )
56
- expect(html).toContain('<a class="govuk-link" href="/home">Home page</a>')
57
56
  expect(html).toContain(
58
- '<a class="govuk-link" href="/start">Get started</a>'
59
- )
60
- expect(html).toContain(
61
- '<a class="govuk-link" href="https://www.gov.uk">GOV.UK</a>'
62
- )
63
- expect(html).toContain(
64
- '<a class="govuk-link" href="https://example.com/contact">contact our support team</a>'
57
+ '<a class="govuk-link" href="https://example.com/contact">British Cattle Movement Service (BCMS)</a>'
65
58
  )
59
+ expect(html).not.toContain('Home page')
60
+ expect(html).not.toContain('Get started')
66
61
  })
67
62
 
68
63
  test('not authorised offers a sign in button when signInHref is given', () => {
@@ -104,7 +99,7 @@ describe('error pages', () => {
104
99
  '<title>Sorry, you are not authorised to view this page - Livestock Information</title>'
105
100
  )
106
101
  expect(html).toContain(
107
- '<h1 class="govuk-heading-l">Sorry, you are not authorised to view this page.</h1>'
102
+ '<h1 class="govuk-heading-l">Sorry, you are not authorised to view this page</h1>'
108
103
  )
109
104
  expect(textOf(html)).toContain(
110
105
  'Check your details are correct and try again. If you think this is incorrect contact our support team.'
@@ -3,7 +3,7 @@
3
3
  {% block content %}
4
4
  <div class="govuk-grid-row">
5
5
  <div class="govuk-grid-column-two-thirds">
6
- <h1 class="govuk-heading-l">Sorry, you are not authorised to view this page.</h1>
6
+ <h1 class="govuk-heading-l">Sorry, you are not authorised to view this page</h1>
7
7
  <p class="govuk-body">Check your details are correct and try again.</p>
8
8
  <p class="govuk-body">If you think this is incorrect <a class="govuk-link" href="{{ errorPages.contactHref }}">contact our support team</a>.</p>
9
9
  {% if signInHref %}
@@ -4,14 +4,9 @@
4
4
  <div class="govuk-grid-row">
5
5
  <div class="govuk-grid-column-two-thirds">
6
6
  <h1 class="govuk-heading-l">Page not found</h1>
7
- <p class="govuk-body">Sorry, we could not find the page you were looking for.</p>
8
- <p class="govuk-body">You might find what you're looking for by visiting:</p>
9
- <ul class="govuk-list">
10
- <li><a class="govuk-link" href="{{ errorPages.homeHref }}">Home page</a></li>
11
- <li><a class="govuk-link" href="{{ errorPages.getStartedHref }}">Get started</a></li>
12
- <li><a class="govuk-link" href="https://www.gov.uk">GOV.UK</a></li>
13
- </ul>
14
- <p class="govuk-body">If you cannot find the information you need, <a class="govuk-link" href="{{ errorPages.contactHref }}">contact our support team</a>.</p>
7
+ <p class="govuk-body">If you typed the web address, check it is correct.</p>
8
+ <p class="govuk-body">If you pasted the web address, check you copied the entire address.</p>
9
+ <p class="govuk-body">If the web address is correct or you selected a link or button, contact the <a class="govuk-link" href="{{ errorPages.contactHref }}">British Cattle Movement Service (BCMS)</a> if you need to speak to someone about your account.</p>
15
10
  </div>
16
11
  </div>
17
12
  {% endblock %}
@@ -38,13 +38,16 @@
38
38
  <link href="{{ getAssetPath('src/client/stylesheets/application.scss') }}" rel="stylesheet">
39
39
  {% endblock %}
40
40
 
41
- {% block header %}
41
+ {% block govukHeader %}
42
42
  {{ govukHeader({
43
43
  homepageUrl: "https://www.gov.uk/",
44
44
  classes: "app-header",
45
45
  containerClasses: "govuk-width-container",
46
46
  useTudorCrown: true
47
47
  }) }}
48
+ {% endblock %}
49
+
50
+ {% block govukServiceNavigation %}
48
51
  {% if isSignedIn %}
49
52
  {% set signOutNavigationEnd %}
50
53
  <li class="govuk-service-navigation__item app-service-navigation__item--right">
@@ -60,7 +63,9 @@
60
63
  navigationEnd: signOutNavigationEnd
61
64
  }
62
65
  }) }}
66
+ {% endblock %}
63
67
 
68
+ {% block headerEnd %}
64
69
  {{ govukPhaseBanner({
65
70
  tag: {
66
71
  html: "<b>Steel Thread</b>",
@@ -84,7 +89,7 @@
84
89
 
85
90
  {% block content %}{% endblock %}
86
91
 
87
- {% block footer %}
92
+ {% block govukFooter %}
88
93
  {{ govukFooter({
89
94
  meta: {
90
95
  items: [
@@ -0,0 +1,76 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import nunjucks from 'nunjucks'
6
+ import { describe, expect, test } from 'vitest'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const govukRoot = path.join(
10
+ path.dirname(require.resolve('govuk-frontend/package.json')),
11
+ 'dist'
12
+ )
13
+ const layoutsRoot = path.dirname(fileURLToPath(import.meta.url))
14
+ const templatesRoot = path.dirname(layoutsRoot)
15
+ const componentsRoot = path.join(path.dirname(templatesRoot), 'components')
16
+ const environment = new nunjucks.Environment(
17
+ new nunjucks.FileSystemLoader([templatesRoot, componentsRoot, govukRoot]),
18
+ { autoescape: true, trimBlocks: true, lstripBlocks: true }
19
+ )
20
+ environment.addGlobal('getAssetPath', (asset) => `/${asset}`)
21
+
22
+ function render(context = {}) {
23
+ return environment.render('layouts/base.njk', {
24
+ isSignedIn: true,
25
+ logoutUrl: '/sign-out',
26
+ navigation: [{ href: '/one', text: 'One' }],
27
+ ...context
28
+ })
29
+ }
30
+
31
+ function countOf(html, fragment) {
32
+ return html.split(fragment).length - 1
33
+ }
34
+
35
+ describe('base layout', () => {
36
+ test('renders one header landmark wrapping the header and service navigation', () => {
37
+ // Act
38
+ const html = render()
39
+ const headerStart = html.indexOf('<header class="govuk-template__header')
40
+ const headerEnd = html.indexOf('</header>', headerStart)
41
+ const headerHtml = html.slice(headerStart, headerEnd)
42
+
43
+ // Assert
44
+ expect(countOf(html, '<header class="govuk-template__header')).toBe(1)
45
+ expect(headerHtml).toContain('govuk-header')
46
+ expect(headerHtml).toContain('govuk-service-navigation')
47
+ expect(headerHtml).toContain('govuk-phase-banner')
48
+ })
49
+
50
+ test('renders one footer landmark wrapping the footer', () => {
51
+ // Act
52
+ const html = render()
53
+ const footerStart = html.indexOf('<footer class="govuk-template__footer')
54
+ const footerEnd = html.indexOf('</footer>', footerStart)
55
+
56
+ // Assert
57
+ expect(countOf(html, '<footer class="govuk-template__footer')).toBe(1)
58
+ expect(html.slice(footerStart, footerEnd)).toContain('govuk-footer')
59
+ })
60
+
61
+ test('renders the sign out link when signed in', () => {
62
+ // Act
63
+ const html = render()
64
+
65
+ // Assert
66
+ expect(html).toContain('href="/sign-out"')
67
+ })
68
+
69
+ test('does not render the sign out link when signed out', () => {
70
+ // Act
71
+ const html = render({ isSignedIn: false })
72
+
73
+ // Assert
74
+ expect(html).not.toContain('Sign out')
75
+ })
76
+ })