@defra/lis-infra-ui-services 0.1.0-lreg-25.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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/package.json +87 -0
  3. package/src/base-path.js +141 -0
  4. package/src/base-path.test.js +120 -0
  5. package/src/components/autocomplete.css +6 -0
  6. package/src/components/autocomplete.js +142 -0
  7. package/src/components/autocomplete.test.js +177 -0
  8. package/src/components/sub-navigation.css +89 -0
  9. package/src/duration.js +48 -0
  10. package/src/errors/index.js +65 -0
  11. package/src/errors/index.test.js +97 -0
  12. package/src/index.js +430 -0
  13. package/src/logging/index.js +11 -0
  14. package/src/logging/logger-options.js +54 -0
  15. package/src/logging/logger.js +11 -0
  16. package/src/logging/project.js +177 -0
  17. package/src/logging/project.test.js +90 -0
  18. package/src/logging/request-logger.js +23 -0
  19. package/src/logging/request-logger.test.js +24 -0
  20. package/src/navigation.js +1 -0
  21. package/src/nunjucks/build-navigation.js +74 -0
  22. package/src/nunjucks/build-navigation.test.js +45 -0
  23. package/src/nunjucks/components/autocomplete/README.md +45 -0
  24. package/src/nunjucks/components/autocomplete/autocomplete.test.js +73 -0
  25. package/src/nunjucks/components/autocomplete/macro.njk +25 -0
  26. package/src/nunjucks/components/autocomplete/template.njk +3 -0
  27. package/src/nunjucks/components/heading/macro.njk +27 -0
  28. package/src/nunjucks/components/heading/template.njk +3 -0
  29. package/src/nunjucks/components/sub-navigation/macro.njk +3 -0
  30. package/src/nunjucks/components/sub-navigation/template.njk +9 -0
  31. package/src/nunjucks/context.js +52 -0
  32. package/src/nunjucks/context.test.js +99 -0
  33. package/src/nunjucks/filters.js +6 -0
  34. package/src/nunjucks/format-currency.js +14 -0
  35. package/src/nunjucks/format-date.js +12 -0
  36. package/src/nunjucks/formatters.test.js +15 -0
  37. package/src/nunjucks/globals.js +3 -0
  38. package/src/nunjucks/plugin.js +228 -0
  39. package/src/nunjucks/routes/error/index.njk +13 -0
  40. package/src/nunjucks/templates/layouts/base.njk +110 -0
  41. package/src/nunjucks-context.js +1 -0
  42. package/src/proxy/setup-proxy.js +45 -0
  43. package/src/proxy/setup-proxy.test.js +72 -0
  44. package/src/redis-client.js +72 -0
  45. package/src/redis-client.test.js +86 -0
  46. package/src/services/holding-service/service.js +70 -0
  47. package/src/services/holding-service/service.test.js +111 -0
  48. package/src/session-cache/cache-engine.js +35 -0
  49. package/src/session-cache/cache-engine.test.js +61 -0
  50. package/src/session-cache.js +38 -0
  51. package/src/session-cache.test.js +77 -0
  52. package/src/static-files.js +50 -0
  53. package/src/static-files.test.js +48 -0
  54. package/src/status-codes.js +12 -0
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # Livestock ui-services
2
+
3
+ This package contains the shared topology metadata and route helpers used across the hub, spokes, and services.
4
+
5
+ Install dependencies for this package locally with `npm install` from this directory.
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@defra/lis-infra-ui-services",
3
+ "version": "0.1.0-lreg-25.alpha.1",
4
+ "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
+ "main": "src/index.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/DEFRA/lis-infra-ui-services.git"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./base-path": "./src/base-path.js",
13
+ "./components/autocomplete": "./src/components/autocomplete.js",
14
+ "./components/autocomplete.css": "./src/components/autocomplete.css",
15
+ "./components/sub-navigation.css": "./src/components/sub-navigation.css",
16
+ "./duration": "./src/duration.js",
17
+ "./errors": "./src/errors/index.js",
18
+ "./logging": "./src/logging/index.js",
19
+ "./logging/logger": "./src/logging/logger.js",
20
+ "./logging/logger-options": "./src/logging/logger-options.js",
21
+ "./logging/project": "./src/logging/project.js",
22
+ "./logging/request-logger": "./src/logging/request-logger.js",
23
+ "./navigation": "./src/navigation.js",
24
+ "./nunjucks/build-navigation": "./src/nunjucks/build-navigation.js",
25
+ "./nunjucks/context": "./src/nunjucks/context.js",
26
+ "./nunjucks/filters": "./src/nunjucks/filters.js",
27
+ "./nunjucks/format-currency": "./src/nunjucks/format-currency.js",
28
+ "./nunjucks/format-date": "./src/nunjucks/format-date.js",
29
+ "./nunjucks/globals": "./src/nunjucks/globals.js",
30
+ "./nunjucks/plugin": "./src/nunjucks/plugin.js",
31
+ "./nunjucks-context": "./src/nunjucks-context.js",
32
+ "./proxy/setup-proxy": "./src/proxy/setup-proxy.js",
33
+ "./redis-client": "./src/redis-client.js",
34
+ "./session-cache": "./src/session-cache.js",
35
+ "./session-cache/cache-engine": "./src/session-cache/cache-engine.js",
36
+ "./static-files": "./src/static-files.js",
37
+ "./status-codes": "./src/status-codes.js"
38
+ },
39
+ "type": "module",
40
+ "engines": {
41
+ "node": ">=24"
42
+ },
43
+ "scripts": {
44
+ "format": "prettier --write \"src/**/*.js\" \"**/*.{js,cjs,md,json}\"",
45
+ "format:check": "prettier --check \"src/**/*.js\" \"**/*.{js,cjs,md,json}\"",
46
+ "lint": "eslint .",
47
+ "lint:fix": "eslint . --fix",
48
+ "test": "vitest run",
49
+ "test:coverage": "vitest run --coverage --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=lcov --coverage.include=\"src/**/*.js\"",
50
+ "prepare": "if [ \"$npm_command\" = \"install\" ] && [ -z \"$CI\" ] && command -v husky >/dev/null 2>&1; then husky; fi",
51
+ "security-audit": "audit-ci",
52
+ "git:pre-commit-hook": "npm run security-audit && npm run format:check && npm run lint"
53
+ },
54
+ "author": "Defra DDTS",
55
+ "license": "OGL-UK-3.0",
56
+ "dependencies": {
57
+ "@defra/hapi-tracing": "1.30.0",
58
+ "@elastic/ecs-pino-format": "1.5.0",
59
+ "@hapi/catbox-memory": "6.0.2",
60
+ "@hapi/catbox-redis": "7.0.2",
61
+ "@hapi/vision": "7.0.3",
62
+ "@hapi/yar": "11.0.3",
63
+ "accessible-autocomplete": "3.0.1",
64
+ "chokidar": "3.6.0",
65
+ "date-fns": "4.1.0",
66
+ "global-agent": "3.0.0",
67
+ "hapi-pino": "13.0.0",
68
+ "ioredis": "5.8.2",
69
+ "lodash": "4.18.1",
70
+ "nunjucks": "3.2.4",
71
+ "pino": "10.1.0",
72
+ "pino-pretty": "13.1.3",
73
+ "undici": "7.24.8"
74
+ },
75
+ "peerDependencies": {
76
+ "eslint": "^9.0.0"
77
+ },
78
+ "devDependencies": {
79
+ "@defra/lis-infra-eslint-config": "^0.6.0",
80
+ "@vitest/coverage-istanbul": "^4.1.10",
81
+ "audit-ci": "^7.1.0",
82
+ "eslint": "^9.0.0",
83
+ "husky": "^9.1.7",
84
+ "prettier": "^3.8.4",
85
+ "vitest": "^4.1.10"
86
+ }
87
+ }
@@ -0,0 +1,141 @@
1
+ /** @import { Request } from '@hapi/hapi' */
2
+
3
+ function normalizePath(path) {
4
+ if (!path || path === '/') {
5
+ return '/'
6
+ }
7
+
8
+ return path.startsWith('/') ? path : `/${path}`
9
+ }
10
+
11
+ function normalizeBasePath(path) {
12
+ if (!path) {
13
+ return ''
14
+ }
15
+
16
+ const normalizedPath = normalizePath(path)
17
+
18
+ return normalizedPath === '/' ? '' : normalizedPath
19
+ }
20
+
21
+ function getForwardedPrefix(request) {
22
+ const forwardedPrefix = request?.headers?.['x-forwarded-prefix']
23
+
24
+ if (typeof forwardedPrefix !== 'string') {
25
+ return ''
26
+ }
27
+
28
+ return normalizeBasePath(forwardedPrefix.trim())
29
+ }
30
+
31
+ /**
32
+ * @param {{ request: Request, basePath: string }} options
33
+ * @returns {boolean}
34
+ */
35
+ export function isPrefixedRequest({ request, basePath }) {
36
+ const normalizedBasePath = normalizeBasePath(basePath)
37
+
38
+ if (!normalizedBasePath || !request) {
39
+ return false
40
+ }
41
+
42
+ const forwardedPrefix = getForwardedPrefix(request)
43
+
44
+ if (forwardedPrefix) {
45
+ return forwardedPrefix === normalizedBasePath
46
+ }
47
+
48
+ return (
49
+ request.path === normalizedBasePath ||
50
+ request.path.startsWith(`${normalizedBasePath}/`)
51
+ )
52
+ }
53
+
54
+ /**
55
+ * @param {{ request: Request, basePath: string }} options
56
+ * @returns {string}
57
+ */
58
+ export function getRequestBasePath({ request, basePath }) {
59
+ const normalizedBasePath = normalizeBasePath(basePath)
60
+
61
+ return isPrefixedRequest({ request, basePath: normalizedBasePath })
62
+ ? normalizedBasePath
63
+ : ''
64
+ }
65
+
66
+ /**
67
+ * @param {{ request: Request, routePath?: string, basePath?: string }} options
68
+ * @returns {string}
69
+ */
70
+ export function buildAppPath({ request, routePath = '/', basePath = '' }) {
71
+ const requestBasePath = getRequestBasePath({ request, basePath })
72
+ const normalizedPath = normalizePath(routePath)
73
+
74
+ if (normalizedPath === '/') {
75
+ return requestBasePath || '/'
76
+ }
77
+
78
+ return `${requestBasePath}${normalizedPath}`
79
+ }
80
+
81
+ /**
82
+ * @param {{ routePath?: string, basePath?: string }} options
83
+ * @returns {string[]}
84
+ */
85
+ export function getRouteVariants({ routePath = '/', basePath: _basePath = '' }) {
86
+ const normalizedPath = normalizePath(routePath)
87
+ return [normalizedPath]
88
+ }
89
+
90
+ /**
91
+ * @param {{ basePath?: string, assetPath: string }} options
92
+ * @returns {string[]}
93
+ */
94
+ export function getAssetPaths({ basePath: _basePath = '', assetPath }) {
95
+ return [assetPath]
96
+ }
97
+
98
+ /**
99
+ * @param {object} config
100
+ * @returns {object}
101
+ */
102
+ export function createBasePathHelpersForConfig(config) {
103
+ function getBasePath() {
104
+ return config.get('basePath')
105
+ }
106
+
107
+ return {
108
+ getBasePath,
109
+ isPrefixedRequest(request) {
110
+ return isPrefixedRequest({
111
+ request,
112
+ basePath: getBasePath()
113
+ })
114
+ },
115
+ getRequestBasePath(request) {
116
+ return getRequestBasePath({
117
+ request,
118
+ basePath: getBasePath()
119
+ })
120
+ },
121
+ buildAppPath(request, routePath = '/') {
122
+ return buildAppPath({
123
+ request,
124
+ routePath,
125
+ basePath: getBasePath()
126
+ })
127
+ },
128
+ getRouteVariants(routePath = '/') {
129
+ return getRouteVariants({
130
+ routePath,
131
+ basePath: getBasePath()
132
+ })
133
+ },
134
+ getAssetPaths() {
135
+ return getAssetPaths({
136
+ basePath: getBasePath(),
137
+ assetPath: config.get('assetPath')
138
+ })
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,120 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'vitest'
3
+
4
+ import {
5
+ buildAppPath,
6
+ createBasePathHelpersForConfig,
7
+ getAssetPaths,
8
+ getRequestBasePath,
9
+ getRouteVariants,
10
+ isPrefixedRequest
11
+ } from './base-path.js'
12
+
13
+ test('isPrefixedRequest detects a forwarded prefix that matches the configured base path', () => {
14
+ assert.equal(
15
+ isPrefixedRequest({
16
+ request: {
17
+ path: '/about',
18
+ headers: {
19
+ 'x-forwarded-prefix': '/chicken/move'
20
+ }
21
+ },
22
+ basePath: '/chicken/move'
23
+ }),
24
+ true
25
+ )
26
+ })
27
+
28
+ test('isPrefixedRequest ignores a forwarded prefix that does not match the configured base path', () => {
29
+ assert.equal(
30
+ isPrefixedRequest({
31
+ request: {
32
+ path: '/about',
33
+ headers: {
34
+ 'x-forwarded-prefix': '/goat/move'
35
+ }
36
+ },
37
+ basePath: '/chicken/move'
38
+ }),
39
+ false
40
+ )
41
+ })
42
+
43
+ test('getRequestBasePath preserves direct requests to the configured base path', () => {
44
+ assert.equal(
45
+ getRequestBasePath({
46
+ request: {
47
+ path: '/chicken/move/about',
48
+ headers: {}
49
+ },
50
+ basePath: '/chicken/move'
51
+ }),
52
+ '/chicken/move'
53
+ )
54
+ })
55
+
56
+ test('buildAppPath uses the forwarded prefix for outbound paths when present', () => {
57
+ assert.equal(
58
+ buildAppPath({
59
+ request: {
60
+ path: '/about',
61
+ headers: {
62
+ 'x-forwarded-prefix': '/chicken/move'
63
+ }
64
+ },
65
+ routePath: '/more-info',
66
+ basePath: '/chicken/move'
67
+ }),
68
+ '/chicken/move/more-info'
69
+ )
70
+ })
71
+
72
+ test('getRouteVariants only returns the internal route path', () => {
73
+ assert.deepEqual(
74
+ getRouteVariants({
75
+ routePath: '/about',
76
+ basePath: '/chicken/move'
77
+ }),
78
+ ['/about']
79
+ )
80
+ })
81
+
82
+ test('getAssetPaths only returns the internal asset path', () => {
83
+ assert.deepEqual(
84
+ getAssetPaths({
85
+ basePath: '/chicken/move',
86
+ assetPath: '/public'
87
+ }),
88
+ ['/public']
89
+ )
90
+ })
91
+
92
+ test('createBasePathHelpersForConfig binds config for request and asset helpers', () => {
93
+ const calls = []
94
+ const config = {
95
+ get(key) {
96
+ calls.push(key)
97
+
98
+ if (key === 'basePath') {
99
+ return '/chicken/move'
100
+ }
101
+
102
+ if (key === 'assetPath') {
103
+ return '/public'
104
+ }
105
+ }
106
+ }
107
+ const helpers = createBasePathHelpersForConfig(config)
108
+
109
+ assert.equal(
110
+ helpers.getRequestBasePath({
111
+ path: '/about',
112
+ headers: {
113
+ 'x-forwarded-prefix': '/chicken/move'
114
+ }
115
+ }),
116
+ '/chicken/move'
117
+ )
118
+ assert.deepEqual(helpers.getAssetPaths(), ['/public'])
119
+ assert.deepEqual(calls, ['basePath', 'basePath', 'assetPath'])
120
+ })
@@ -0,0 +1,6 @@
1
+ @import 'accessible-autocomplete/dist/accessible-autocomplete.min.css';
2
+
3
+ .app-autocomplete .autocomplete__input.govuk-input--error,
4
+ .app-autocomplete .autocomplete__hint.govuk-input--error {
5
+ border-color: #d4351c;
6
+ }
@@ -0,0 +1,142 @@
1
+ import accessibleAutocomplete from 'accessible-autocomplete'
2
+
3
+ const MODULE_SELECTOR = '[data-module="app-autocomplete"]'
4
+ const SOURCE_SELECTOR = '.app-autocomplete__source'
5
+ const DESCRIBED_BY_ATTRIBUTE = 'aria-describedby'
6
+
7
+ function escapeHtml(value) {
8
+ return value
9
+ .replaceAll('&', '&')
10
+ .replaceAll('<', '&lt;')
11
+ .replaceAll('>', '&gt;')
12
+ .replaceAll('"', '&quot;')
13
+ .replaceAll("'", '&#039;')
14
+ }
15
+
16
+ function booleanOption(element, name, defaultValue) {
17
+ const value = element.dataset[name]
18
+
19
+ if (value === undefined) {
20
+ return defaultValue
21
+ }
22
+
23
+ return value === 'true'
24
+ }
25
+
26
+ function copyInputAttributes(source, target) {
27
+ const attributes = [
28
+ 'aria-invalid',
29
+ 'autocapitalize',
30
+ 'inputmode',
31
+ 'maxlength',
32
+ 'minlength',
33
+ 'pattern',
34
+ 'spellcheck'
35
+ ]
36
+
37
+ attributes.forEach((name) => {
38
+ if (source.hasAttribute(name)) {
39
+ target.setAttribute(name, source.getAttribute(name))
40
+ }
41
+ })
42
+
43
+ Array.from(source.attributes)
44
+ .filter(({ name }) => name.startsWith('data-'))
45
+ .forEach(({ name, value }) => target.setAttribute(name, value))
46
+ }
47
+
48
+ function preserveDescriptions(input, describedBy) {
49
+ if (!describedBy) {
50
+ return
51
+ }
52
+
53
+ const assistiveHint = input.getAttribute(DESCRIBED_BY_ATTRIBUTE)
54
+ input.setAttribute(
55
+ DESCRIBED_BY_ATTRIBUTE,
56
+ [assistiveHint, describedBy].filter(Boolean).join(' ')
57
+ )
58
+
59
+ // The library removes its one-time assistive hint after typing. Restore the
60
+ // GOV.UK hint and error references that must remain associated with the input.
61
+ input.addEventListener('input', () => {
62
+ queueMicrotask(() =>
63
+ input.setAttribute(DESCRIBED_BY_ATTRIBUTE, describedBy)
64
+ )
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Progressively enhance one GOV.UK text input with the GDS accessible
70
+ * autocomplete. Disabled and readonly inputs retain their server-rendered form.
71
+ *
72
+ * @param {object} element component root
73
+ * @returns {object|null} enhanced input, or null when not enhanced
74
+ */
75
+ export function initAutocomplete(element) {
76
+ if (element.dataset.appAutocompleteInitialised === 'true') {
77
+ return element.querySelector('input[role="combobox"]')
78
+ }
79
+
80
+ const originalInput = element.querySelector('.govuk-input')
81
+ const sourceElement = element.querySelector(SOURCE_SELECTOR)
82
+
83
+ if (
84
+ !originalInput ||
85
+ !sourceElement ||
86
+ originalInput.disabled ||
87
+ originalInput.readOnly
88
+ ) {
89
+ return null
90
+ }
91
+
92
+ const source = Array.from(sourceElement.children, ({ textContent }) =>
93
+ textContent.trim()
94
+ )
95
+ const mountElement = document.createElement('div')
96
+ const describedBy = originalInput.getAttribute(DESCRIBED_BY_ATTRIBUTE)
97
+
98
+ originalInput.before(mountElement)
99
+ originalInput.remove()
100
+
101
+ accessibleAutocomplete({
102
+ element: mountElement,
103
+ id: originalInput.id || originalInput.name,
104
+ name: originalInput.name,
105
+ source,
106
+ defaultValue: originalInput.value,
107
+ required: originalInput.required,
108
+ inputClasses: originalInput.className,
109
+ hintClasses: originalInput.className,
110
+ autoselect: booleanOption(element, 'autoselect', false),
111
+ confirmOnBlur: booleanOption(element, 'confirmOnBlur', true),
112
+ displayMenu: element.dataset.displayMenu || 'inline',
113
+ minLength: Number(element.dataset.minLength || 0),
114
+ showAllValues: booleanOption(element, 'showAllValues', false),
115
+ placeholder: originalInput.placeholder,
116
+ templates: {
117
+ // accessible-autocomplete accepts HTML in suggestion templates. Escape
118
+ // server-provided values before the library writes them to innerHTML.
119
+ suggestion: escapeHtml
120
+ }
121
+ })
122
+
123
+ const input = mountElement.querySelector('input[role="combobox"]')
124
+
125
+ copyInputAttributes(originalInput, input)
126
+ preserveDescriptions(input, describedBy)
127
+ element.dataset.appAutocompleteInitialised = 'true'
128
+
129
+ return input
130
+ }
131
+
132
+ /**
133
+ * Initialise every autocomplete below a root element.
134
+ *
135
+ * @param {object} root search root
136
+ * @returns {Array<object>} enhanced inputs
137
+ */
138
+ export function initAllAutocompletes(root = document) {
139
+ return Array.from(root.querySelectorAll(MODULE_SELECTOR))
140
+ .map(initAutocomplete)
141
+ .filter(Boolean)
142
+ }
@@ -0,0 +1,177 @@
1
+ import assert from 'node:assert/strict'
2
+ import { beforeEach, test, vi } from 'vitest'
3
+
4
+ const { accessibleAutocomplete } = vi.hoisted(() => ({
5
+ accessibleAutocomplete: vi.fn(({ element }) => {
6
+ element.input = createInput()
7
+ })
8
+ }))
9
+
10
+ vi.mock('accessible-autocomplete', () => ({ default: accessibleAutocomplete }))
11
+
12
+ import { initAllAutocompletes, initAutocomplete } from './autocomplete.js'
13
+
14
+ function createInput(overrides = {}) {
15
+ const attributeValues = new Map()
16
+ const listeners = new Map()
17
+ const input = {
18
+ id: 'species',
19
+ name: 'species',
20
+ value: 'Sheep',
21
+ required: true,
22
+ className: 'govuk-input',
23
+ placeholder: 'Choose',
24
+ disabled: false,
25
+ readOnly: false,
26
+ attributes: [],
27
+ hasAttribute: (name) => attributeValues.has(name),
28
+ getAttribute: (name) => attributeValues.get(name) ?? null,
29
+ setAttribute(name, value) {
30
+ attributeValues.set(name, value)
31
+ const existing = this.attributes.find(
32
+ (attribute) => attribute.name === name
33
+ )
34
+ if (existing) existing.value = value
35
+ else this.attributes.push({ name, value })
36
+ },
37
+ addEventListener: (name, listener) => listeners.set(name, listener),
38
+ dispatch: (name) => listeners.get(name)?.(),
39
+ before: vi.fn(),
40
+ remove: vi.fn(),
41
+ ...overrides
42
+ }
43
+ return input
44
+ }
45
+
46
+ function createElement({
47
+ input = createInput(),
48
+ source = ['Cattle', ' Sheep ']
49
+ } = {}) {
50
+ const sourceElement = {
51
+ children: source.map((textContent) => ({ textContent }))
52
+ }
53
+ return {
54
+ dataset: {},
55
+ querySelector(selector) {
56
+ if (selector === '.govuk-input') return input
57
+ if (selector === '.app-autocomplete__source') return sourceElement
58
+ if (selector === 'input[role="combobox"]')
59
+ return this.enhancedInput ?? null
60
+ return null
61
+ },
62
+ input
63
+ }
64
+ }
65
+
66
+ beforeEach(() => {
67
+ accessibleAutocomplete.mockClear()
68
+ vi.stubGlobal('document', {
69
+ createElement: () => ({
70
+ input: null,
71
+ querySelector() {
72
+ return this.input
73
+ }
74
+ })
75
+ })
76
+ })
77
+
78
+ test('enhances an input using its source and component options', () => {
79
+ const element = createElement()
80
+ Object.assign(element.dataset, {
81
+ autoselect: 'true',
82
+ confirmOnBlur: 'false',
83
+ displayMenu: 'overlay',
84
+ minLength: '2',
85
+ showAllValues: 'true'
86
+ })
87
+
88
+ const enhancedInput = initAutocomplete(element)
89
+ const options = accessibleAutocomplete.mock.calls[0][0]
90
+
91
+ assert.equal(enhancedInput, options.element.input)
92
+ assert.deepEqual(options.source, ['Cattle', 'Sheep'])
93
+ assert.equal(options.id, 'species')
94
+ assert.equal(options.defaultValue, 'Sheep')
95
+ assert.equal(options.autoselect, true)
96
+ assert.equal(options.confirmOnBlur, false)
97
+ assert.equal(options.displayMenu, 'overlay')
98
+ assert.equal(options.minLength, 2)
99
+ assert.equal(options.showAllValues, true)
100
+ assert.equal(element.dataset.appAutocompleteInitialised, 'true')
101
+ assert.equal(element.input.before.mock.calls.length, 1)
102
+ assert.equal(element.input.remove.mock.calls.length, 1)
103
+ })
104
+
105
+ test('copies input attributes and preserves descriptions after input', async () => {
106
+ const original = createInput({ id: '' })
107
+ original.setAttribute('aria-describedby', 'species-hint species-error')
108
+ original.setAttribute('aria-invalid', 'true')
109
+ original.setAttribute('maxlength', '20')
110
+ original.setAttribute('data-tracking', 'species-field')
111
+ const element = createElement({ input: original })
112
+
113
+ const enhancedInput = initAutocomplete(element)
114
+
115
+ assert.equal(accessibleAutocomplete.mock.calls[0][0].id, 'species')
116
+ assert.equal(enhancedInput.getAttribute('aria-invalid'), 'true')
117
+ assert.equal(enhancedInput.getAttribute('maxlength'), '20')
118
+ assert.equal(enhancedInput.getAttribute('data-tracking'), 'species-field')
119
+ assert.equal(
120
+ enhancedInput.getAttribute('aria-describedby'),
121
+ 'species-hint species-error'
122
+ )
123
+
124
+ enhancedInput.setAttribute('aria-describedby', 'assistive-hint')
125
+ enhancedInput.dispatch('input')
126
+ await new Promise(queueMicrotask)
127
+ assert.equal(
128
+ enhancedInput.getAttribute('aria-describedby'),
129
+ 'species-hint species-error'
130
+ )
131
+ })
132
+
133
+ test('escapes suggestion HTML', () => {
134
+ const element = createElement()
135
+ initAutocomplete(element)
136
+
137
+ assert.equal(
138
+ accessibleAutocomplete.mock.calls[0][0].templates.suggestion(
139
+ `<b class="x">Tom & 'Sue'</b>`
140
+ ),
141
+ '&lt;b class=&quot;x&quot;&gt;Tom &amp; &#039;Sue&#039;&lt;/b&gt;'
142
+ )
143
+ })
144
+
145
+ test('does not enhance missing, disabled, or readonly inputs', () => {
146
+ const missing = createElement()
147
+ missing.querySelector = () => null
148
+
149
+ assert.equal(initAutocomplete(missing), null)
150
+ assert.equal(
151
+ initAutocomplete(createElement({ input: createInput({ disabled: true }) })),
152
+ null
153
+ )
154
+ assert.equal(
155
+ initAutocomplete(createElement({ input: createInput({ readOnly: true }) })),
156
+ null
157
+ )
158
+ assert.equal(accessibleAutocomplete.mock.calls.length, 0)
159
+ })
160
+
161
+ test('returns the existing combobox when already initialised', () => {
162
+ const existing = createInput()
163
+ const element = createElement()
164
+ element.dataset.appAutocompleteInitialised = 'true'
165
+ element.enhancedInput = existing
166
+
167
+ assert.equal(initAutocomplete(element), existing)
168
+ assert.equal(accessibleAutocomplete.mock.calls.length, 0)
169
+ })
170
+
171
+ test('initialises all valid components below a root', () => {
172
+ const valid = createElement()
173
+ const disabled = createElement({ input: createInput({ disabled: true }) })
174
+ const root = { querySelectorAll: () => [valid, disabled] }
175
+
176
+ assert.equal(initAllAutocompletes(root).length, 1)
177
+ })