@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.
- package/README.md +5 -0
- package/package.json +87 -0
- package/src/base-path.js +141 -0
- package/src/base-path.test.js +120 -0
- package/src/components/autocomplete.css +6 -0
- package/src/components/autocomplete.js +142 -0
- package/src/components/autocomplete.test.js +177 -0
- package/src/components/sub-navigation.css +89 -0
- package/src/duration.js +48 -0
- package/src/errors/index.js +65 -0
- package/src/errors/index.test.js +97 -0
- package/src/index.js +430 -0
- package/src/logging/index.js +11 -0
- package/src/logging/logger-options.js +54 -0
- package/src/logging/logger.js +11 -0
- package/src/logging/project.js +177 -0
- package/src/logging/project.test.js +90 -0
- package/src/logging/request-logger.js +23 -0
- package/src/logging/request-logger.test.js +24 -0
- package/src/navigation.js +1 -0
- package/src/nunjucks/build-navigation.js +74 -0
- package/src/nunjucks/build-navigation.test.js +45 -0
- package/src/nunjucks/components/autocomplete/README.md +45 -0
- package/src/nunjucks/components/autocomplete/autocomplete.test.js +73 -0
- package/src/nunjucks/components/autocomplete/macro.njk +25 -0
- package/src/nunjucks/components/autocomplete/template.njk +3 -0
- package/src/nunjucks/components/heading/macro.njk +27 -0
- package/src/nunjucks/components/heading/template.njk +3 -0
- package/src/nunjucks/components/sub-navigation/macro.njk +3 -0
- package/src/nunjucks/components/sub-navigation/template.njk +9 -0
- package/src/nunjucks/context.js +52 -0
- package/src/nunjucks/context.test.js +99 -0
- package/src/nunjucks/filters.js +6 -0
- package/src/nunjucks/format-currency.js +14 -0
- package/src/nunjucks/format-date.js +12 -0
- package/src/nunjucks/formatters.test.js +15 -0
- package/src/nunjucks/globals.js +3 -0
- package/src/nunjucks/plugin.js +228 -0
- package/src/nunjucks/routes/error/index.njk +13 -0
- package/src/nunjucks/templates/layouts/base.njk +110 -0
- package/src/nunjucks-context.js +1 -0
- package/src/proxy/setup-proxy.js +45 -0
- package/src/proxy/setup-proxy.test.js +72 -0
- package/src/redis-client.js +72 -0
- package/src/redis-client.test.js +86 -0
- package/src/services/holding-service/service.js +70 -0
- package/src/services/holding-service/service.test.js +111 -0
- package/src/session-cache/cache-engine.js +35 -0
- package/src/session-cache/cache-engine.test.js +61 -0
- package/src/session-cache.js +38 -0
- package/src/session-cache.test.js +77 -0
- package/src/static-files.js +50 -0
- package/src/static-files.test.js +48 -0
- package/src/status-codes.js +12 -0
package/README.md
ADDED
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
|
+
}
|
package/src/base-path.js
ADDED
|
@@ -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,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('<', '<')
|
|
11
|
+
.replaceAll('>', '>')
|
|
12
|
+
.replaceAll('"', '"')
|
|
13
|
+
.replaceAll("'", ''')
|
|
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
|
+
'<b class="x">Tom & 'Sue'</b>'
|
|
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
|
+
})
|