@bquery/bquery 1.7.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +760 -716
- package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
- package/dist/a11y-DVBCy09c.js.map +1 -0
- package/dist/a11y.es.mjs +1 -1
- package/dist/component/library.d.ts.map +1 -1
- package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
- package/dist/component-L3-JfOFz.js.map +1 -0
- package/dist/component.es.mjs +1 -1
- package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
- package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
- package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
- package/dist/constraints-D5RHQLmP.js.map +1 -0
- package/dist/core/collection.d.ts +86 -0
- package/dist/core/collection.d.ts.map +1 -1
- package/dist/core/element.d.ts +28 -0
- package/dist/core/element.d.ts.map +1 -1
- package/dist/core/shared.d.ts +6 -0
- package/dist/core/shared.d.ts.map +1 -1
- package/dist/core-DdtZHzsS.js +168 -0
- package/dist/core-DdtZHzsS.js.map +1 -0
- package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
- package/dist/core-EMYSLzaT.js.map +1 -0
- package/dist/core.es.mjs +48 -47
- package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
- package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
- package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
- package/dist/devtools-BhB2iDPT.js.map +1 -0
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
- package/dist/dnd-NwZBYh4l.js.map +1 -0
- package/dist/dnd.es.mjs +1 -1
- package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
- package/dist/env-CTdvLaH2.js.map +1 -0
- package/dist/forms/create-form.d.ts.map +1 -1
- package/dist/forms/index.d.ts +3 -2
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/types.d.ts +46 -0
- package/dist/forms/types.d.ts.map +1 -1
- package/dist/forms/use-field.d.ts +34 -0
- package/dist/forms/use-field.d.ts.map +1 -0
- package/dist/forms/validators.d.ts +25 -0
- package/dist/forms/validators.d.ts.map +1 -1
- package/dist/forms-UcRHsYxC.js +227 -0
- package/dist/forms-UcRHsYxC.js.map +1 -0
- package/dist/forms.es.mjs +14 -12
- package/dist/full.d.ts +17 -26
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +206 -181
- package/dist/full.iife.js +33 -33
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +33 -33
- package/dist/full.umd.js.map +1 -1
- package/dist/function-Cybd57JV.js +33 -0
- package/dist/function-Cybd57JV.js.map +1 -0
- package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
- package/dist/i18n-kuF6Ekj6.js.map +1 -0
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.es.mjs +251 -228
- package/dist/media/breakpoints.d.ts.map +1 -1
- package/dist/media/types.d.ts +2 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
- package/dist/media-i-fB5WxI.js.map +1 -0
- package/dist/media.es.mjs +1 -1
- package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
- package/dist/motion-BJsAuULb.js.map +1 -0
- package/dist/motion.es.mjs +1 -1
- package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
- package/dist/mount-B4Y8bk8Z.js.map +1 -0
- package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
- package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
- package/dist/platform.es.mjs +2 -2
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
- package/dist/plugin-C2WuC8SF.js.map +1 -0
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive/async-data.d.ts +28 -3
- package/dist/reactive/async-data.d.ts.map +1 -1
- package/dist/reactive/computed.d.ts +3 -0
- package/dist/reactive/computed.d.ts.map +1 -1
- package/dist/reactive/effect.d.ts +3 -0
- package/dist/reactive/effect.d.ts.map +1 -1
- package/dist/reactive/http.d.ts +194 -0
- package/dist/reactive/http.d.ts.map +1 -0
- package/dist/reactive/index.d.ts +2 -2
- package/dist/reactive/index.d.ts.map +1 -1
- package/dist/reactive/pagination.d.ts +126 -0
- package/dist/reactive/pagination.d.ts.map +1 -0
- package/dist/reactive/polling.d.ts +55 -0
- package/dist/reactive/polling.d.ts.map +1 -0
- package/dist/reactive/readonly.d.ts +20 -1
- package/dist/reactive/readonly.d.ts.map +1 -1
- package/dist/reactive/rest.d.ts +293 -0
- package/dist/reactive/rest.d.ts.map +1 -0
- package/dist/reactive/scope.d.ts +140 -0
- package/dist/reactive/scope.d.ts.map +1 -0
- package/dist/reactive/signal.d.ts +16 -2
- package/dist/reactive/signal.d.ts.map +1 -1
- package/dist/reactive/to-value.d.ts +57 -0
- package/dist/reactive/to-value.d.ts.map +1 -0
- package/dist/reactive/websocket.d.ts +285 -0
- package/dist/reactive/websocket.d.ts.map +1 -0
- package/dist/reactive-DwkhUJfP.js +1148 -0
- package/dist/reactive-DwkhUJfP.js.map +1 -0
- package/dist/reactive.es.mjs +38 -19
- package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
- package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
- package/dist/router/constraints.d.ts.map +1 -1
- package/dist/router/index.d.ts +1 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/state.d.ts +25 -2
- package/dist/router/state.d.ts.map +1 -1
- package/dist/router-CQikC9Ed.js +492 -0
- package/dist/router-CQikC9Ed.js.map +1 -0
- package/dist/router.es.mjs +9 -8
- package/dist/ssr/hydrate.d.ts.map +1 -1
- package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
- package/dist/ssr-_dAcGdzu.js.map +1 -0
- package/dist/ssr.es.mjs +1 -1
- package/dist/store/persisted.d.ts.map +1 -1
- package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
- package/dist/store-Cb3gPRve.js.map +1 -0
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs.map +1 -1
- package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
- package/dist/testing-C5Sjfsna.js.map +1 -0
- package/dist/testing.es.mjs +1 -1
- package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
- package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
- package/dist/untrack-D0fnO5k2.js +36 -0
- package/dist/untrack-D0fnO5k2.js.map +1 -0
- package/dist/view/custom-directives.d.ts.map +1 -1
- package/dist/view.es.mjs +4 -4
- package/package.json +178 -177
- package/src/a11y/announce.ts +131 -131
- package/src/a11y/audit.ts +314 -314
- package/src/a11y/index.ts +68 -68
- package/src/a11y/media-preferences.ts +255 -255
- package/src/a11y/roving-tab-index.ts +164 -164
- package/src/a11y/skip-link.ts +255 -255
- package/src/a11y/trap-focus.ts +184 -184
- package/src/a11y/types.ts +183 -183
- package/src/component/component.ts +599 -599
- package/src/component/html.ts +153 -153
- package/src/component/index.ts +52 -52
- package/src/component/library.ts +540 -542
- package/src/component/scope.ts +212 -212
- package/src/component/types.ts +310 -310
- package/src/core/collection.ts +876 -707
- package/src/core/element.ts +1015 -981
- package/src/core/env.ts +60 -60
- package/src/core/index.ts +49 -49
- package/src/core/shared.ts +77 -62
- package/src/core/utils/index.ts +148 -148
- package/src/devtools/devtools.ts +410 -410
- package/src/devtools/index.ts +48 -48
- package/src/devtools/types.ts +104 -104
- package/src/dnd/draggable.ts +296 -296
- package/src/dnd/droppable.ts +228 -228
- package/src/dnd/index.ts +62 -62
- package/src/dnd/sortable.ts +307 -307
- package/src/dnd/types.ts +293 -293
- package/src/forms/create-form.ts +320 -278
- package/src/forms/index.ts +70 -65
- package/src/forms/types.ts +203 -154
- package/src/forms/use-field.ts +231 -0
- package/src/forms/validators.ts +294 -265
- package/src/full.ts +554 -480
- package/src/i18n/formatting.ts +67 -67
- package/src/i18n/i18n.ts +200 -200
- package/src/i18n/index.ts +67 -67
- package/src/i18n/translate.ts +182 -182
- package/src/i18n/types.ts +171 -171
- package/src/index.ts +108 -108
- package/src/media/battery.ts +116 -116
- package/src/media/breakpoints.ts +129 -131
- package/src/media/clipboard.ts +80 -80
- package/src/media/device-sensors.ts +158 -158
- package/src/media/geolocation.ts +119 -119
- package/src/media/index.ts +76 -76
- package/src/media/media-query.ts +92 -92
- package/src/media/network.ts +115 -115
- package/src/media/types.ts +177 -177
- package/src/media/viewport.ts +84 -84
- package/src/motion/index.ts +57 -57
- package/src/motion/morph.ts +151 -151
- package/src/motion/parallax.ts +120 -120
- package/src/motion/reduced-motion.ts +66 -66
- package/src/motion/types.ts +271 -271
- package/src/motion/typewriter.ts +164 -164
- package/src/plugin/index.ts +37 -37
- package/src/plugin/registry.ts +284 -269
- package/src/plugin/types.ts +137 -137
- package/src/reactive/async-data.ts +250 -29
- package/src/reactive/computed.ts +144 -130
- package/src/reactive/effect.ts +29 -6
- package/src/reactive/http.ts +790 -0
- package/src/reactive/index.ts +60 -0
- package/src/reactive/pagination.ts +317 -0
- package/src/reactive/polling.ts +179 -0
- package/src/reactive/readonly.ts +52 -8
- package/src/reactive/rest.ts +859 -0
- package/src/reactive/scope.ts +276 -0
- package/src/reactive/signal.ts +61 -1
- package/src/reactive/to-value.ts +71 -0
- package/src/reactive/websocket.ts +849 -0
- package/src/router/bq-link.ts +279 -279
- package/src/router/constraints.ts +204 -201
- package/src/router/index.ts +49 -49
- package/src/router/match.ts +312 -312
- package/src/router/path-pattern.ts +52 -52
- package/src/router/query.ts +38 -38
- package/src/router/router.ts +421 -402
- package/src/router/state.ts +51 -3
- package/src/router/types.ts +139 -139
- package/src/router/use-route.ts +68 -68
- package/src/router/utils.ts +157 -157
- package/src/security/index.ts +12 -12
- package/src/ssr/hydrate.ts +84 -82
- package/src/ssr/index.ts +70 -70
- package/src/ssr/render.ts +508 -508
- package/src/ssr/serialize.ts +296 -296
- package/src/ssr/types.ts +81 -81
- package/src/store/create-store.ts +467 -467
- package/src/store/index.ts +27 -27
- package/src/store/persisted.ts +245 -249
- package/src/store/types.ts +247 -247
- package/src/store/utils.ts +135 -135
- package/src/storybook/index.ts +480 -480
- package/src/testing/index.ts +42 -42
- package/src/testing/testing.ts +593 -593
- package/src/testing/types.ts +170 -170
- package/src/view/custom-directives.ts +28 -30
- package/src/view/evaluate.ts +292 -292
- package/src/view/process.ts +108 -108
- package/dist/a11y-C5QOVvRn.js.map +0 -1
- package/dist/component-CuuTijA6.js.map +0 -1
- package/dist/constraints-3lV9yyBw.js.map +0 -1
- package/dist/core-Cjl7GUu8.js.map +0 -1
- package/dist/core-DnlyjbF2.js +0 -112
- package/dist/core-DnlyjbF2.js.map +0 -1
- package/dist/custom-directives-7wAShnnd.js.map +0 -1
- package/dist/devtools-D2fQLhDN.js.map +0 -1
- package/dist/dnd-B8EgyzaI.js.map +0 -1
- package/dist/env-NeVmr4Gf.js.map +0 -1
- package/dist/forms-C3yovgH9.js +0 -141
- package/dist/forms-C3yovgH9.js.map +0 -1
- package/dist/i18n-BnnhTFOS.js.map +0 -1
- package/dist/media-Di2Ta22s.js.map +0 -1
- package/dist/motion-qPj_TYGv.js.map +0 -1
- package/dist/mount-SM07RUa6.js.map +0 -1
- package/dist/plugin-cPoOHFLY.js.map +0 -1
- package/dist/reactive-Cfv0RK6x.js +0 -233
- package/dist/reactive-Cfv0RK6x.js.map +0 -1
- package/dist/router-BrthaP_z.js +0 -473
- package/dist/router-BrthaP_z.js.map +0 -1
- package/dist/ssr-B2qd_WBB.js.map +0 -1
- package/dist/store-DWpyH6p5.js.map +0 -1
- package/dist/testing-CsqjNUyy.js.map +0 -1
- package/dist/untrack-DJVQQ2WM.js +0 -33
- package/dist/untrack-DJVQQ2WM.js.map +0 -1
package/src/a11y/audit.ts
CHANGED
|
@@ -1,314 +1,314 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Development-time accessibility audit utility.
|
|
3
|
-
*
|
|
4
|
-
* Scans DOM elements for common accessibility issues such as missing
|
|
5
|
-
* alt text on images, missing labels on form inputs, empty links/buttons,
|
|
6
|
-
* and incorrect ARIA usage.
|
|
7
|
-
*
|
|
8
|
-
* @module bquery/a11y
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { AuditFinding, AuditResult, AuditSeverity } from './types';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Creates a finding object.
|
|
15
|
-
* @internal
|
|
16
|
-
*/
|
|
17
|
-
const finding = (
|
|
18
|
-
severity: AuditSeverity,
|
|
19
|
-
message: string,
|
|
20
|
-
element: Element,
|
|
21
|
-
rule: string
|
|
22
|
-
): AuditFinding => ({
|
|
23
|
-
severity,
|
|
24
|
-
message,
|
|
25
|
-
element,
|
|
26
|
-
rule,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Checks images for missing alt attributes.
|
|
31
|
-
* @internal
|
|
32
|
-
*/
|
|
33
|
-
const auditImages = (container: Element): AuditFinding[] => {
|
|
34
|
-
const findings: AuditFinding[] = [];
|
|
35
|
-
const images = container.querySelectorAll('img');
|
|
36
|
-
|
|
37
|
-
for (const img of images) {
|
|
38
|
-
if (!img.hasAttribute('alt')) {
|
|
39
|
-
findings.push(
|
|
40
|
-
finding(
|
|
41
|
-
'error',
|
|
42
|
-
'Image is missing an alt attribute. Add alt="" for decorative images or a descriptive alt text.',
|
|
43
|
-
img,
|
|
44
|
-
'img-alt'
|
|
45
|
-
)
|
|
46
|
-
);
|
|
47
|
-
} else if (img.getAttribute('alt') === '' && !img.hasAttribute('role')) {
|
|
48
|
-
findings.push(
|
|
49
|
-
finding(
|
|
50
|
-
'info',
|
|
51
|
-
'Image has empty alt text. Consider adding role="presentation" if decorative.',
|
|
52
|
-
img,
|
|
53
|
-
'img-decorative'
|
|
54
|
-
)
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return findings;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Checks form inputs for missing labels.
|
|
64
|
-
* @internal
|
|
65
|
-
*/
|
|
66
|
-
const auditFormInputs = (container: Element): AuditFinding[] => {
|
|
67
|
-
const findings: AuditFinding[] = [];
|
|
68
|
-
const inputs = container.querySelectorAll('input, select, textarea');
|
|
69
|
-
|
|
70
|
-
for (const input of inputs) {
|
|
71
|
-
const type = input.getAttribute('type');
|
|
72
|
-
|
|
73
|
-
// Hidden, submit, and button inputs don't need labels
|
|
74
|
-
if (type === 'hidden' || type === 'submit' || type === 'button' || type === 'reset') {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const id = input.getAttribute('id');
|
|
79
|
-
const hasLabel = id ? !!container.querySelector(`label[for="${id}"]`) : false;
|
|
80
|
-
const hasAriaLabel = input.hasAttribute('aria-label') || input.hasAttribute('aria-labelledby');
|
|
81
|
-
const hasTitle = input.hasAttribute('title');
|
|
82
|
-
const isWrappedInLabel = input.closest('label') !== null;
|
|
83
|
-
|
|
84
|
-
if (!hasLabel && !hasAriaLabel && !hasTitle && !isWrappedInLabel) {
|
|
85
|
-
findings.push(
|
|
86
|
-
finding(
|
|
87
|
-
'error',
|
|
88
|
-
`Form input is missing a label. Add a <label for="id">, aria-label, or aria-labelledby attribute.`,
|
|
89
|
-
input,
|
|
90
|
-
'input-label'
|
|
91
|
-
)
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return findings;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Checks for empty interactive elements (buttons, links).
|
|
101
|
-
* @internal
|
|
102
|
-
*/
|
|
103
|
-
const auditInteractiveElements = (container: Element): AuditFinding[] => {
|
|
104
|
-
const findings: AuditFinding[] = [];
|
|
105
|
-
|
|
106
|
-
// Check buttons
|
|
107
|
-
const buttons = container.querySelectorAll('button');
|
|
108
|
-
for (const btn of buttons) {
|
|
109
|
-
const hasText = (btn.textContent ?? '').trim().length > 0;
|
|
110
|
-
const hasAriaLabel = btn.hasAttribute('aria-label') || btn.hasAttribute('aria-labelledby');
|
|
111
|
-
const hasTitle = btn.hasAttribute('title');
|
|
112
|
-
|
|
113
|
-
if (!hasText && !hasAriaLabel && !hasTitle) {
|
|
114
|
-
findings.push(
|
|
115
|
-
finding(
|
|
116
|
-
'error',
|
|
117
|
-
'Button has no accessible name. Add text content, aria-label, or title.',
|
|
118
|
-
btn,
|
|
119
|
-
'button-name'
|
|
120
|
-
)
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Check links
|
|
126
|
-
const links = container.querySelectorAll('a[href]');
|
|
127
|
-
for (const link of links) {
|
|
128
|
-
const hasText = (link.textContent ?? '').trim().length > 0;
|
|
129
|
-
const hasAriaLabel = link.hasAttribute('aria-label') || link.hasAttribute('aria-labelledby');
|
|
130
|
-
const hasTitle = link.hasAttribute('title');
|
|
131
|
-
const hasImage = link.querySelector('img[alt]') !== null;
|
|
132
|
-
|
|
133
|
-
if (!hasText && !hasAriaLabel && !hasTitle && !hasImage) {
|
|
134
|
-
findings.push(
|
|
135
|
-
finding(
|
|
136
|
-
'error',
|
|
137
|
-
'Link has no accessible name. Add text content, aria-label, or title.',
|
|
138
|
-
link,
|
|
139
|
-
'link-name'
|
|
140
|
-
)
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return findings;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Checks heading hierarchy for skipped levels.
|
|
150
|
-
* @internal
|
|
151
|
-
*/
|
|
152
|
-
const auditHeadings = (container: Element): AuditFinding[] => {
|
|
153
|
-
const findings: AuditFinding[] = [];
|
|
154
|
-
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
155
|
-
|
|
156
|
-
let previousLevel = 0;
|
|
157
|
-
|
|
158
|
-
for (const heading of headings) {
|
|
159
|
-
const level = parseInt(heading.tagName.charAt(1), 10);
|
|
160
|
-
|
|
161
|
-
if (previousLevel > 0 && level > previousLevel + 1) {
|
|
162
|
-
findings.push(
|
|
163
|
-
finding(
|
|
164
|
-
'warning',
|
|
165
|
-
`Heading level skipped: <${heading.tagName.toLowerCase()}> follows <h${previousLevel}>. Don't skip heading levels.`,
|
|
166
|
-
heading,
|
|
167
|
-
'heading-order'
|
|
168
|
-
)
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if ((heading.textContent ?? '').trim().length === 0) {
|
|
173
|
-
findings.push(finding('warning', 'Heading element is empty.', heading, 'heading-empty'));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
previousLevel = level;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return findings;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Checks for valid ARIA attribute usage.
|
|
184
|
-
* @internal
|
|
185
|
-
*/
|
|
186
|
-
const auditAria = (container: Element): AuditFinding[] => {
|
|
187
|
-
const findings: AuditFinding[] = [];
|
|
188
|
-
|
|
189
|
-
// Check aria-labelledby references exist
|
|
190
|
-
const labelled = container.querySelectorAll('[aria-labelledby]');
|
|
191
|
-
for (const el of labelled) {
|
|
192
|
-
const ids = (el.getAttribute('aria-labelledby') ?? '').split(/\s+/);
|
|
193
|
-
for (const id of ids) {
|
|
194
|
-
if (id && !document.getElementById(id)) {
|
|
195
|
-
findings.push(
|
|
196
|
-
finding(
|
|
197
|
-
'error',
|
|
198
|
-
`aria-labelledby references "${id}" which does not exist in the document.`,
|
|
199
|
-
el,
|
|
200
|
-
'aria-labelledby-ref'
|
|
201
|
-
)
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Check aria-describedby references exist
|
|
208
|
-
const described = container.querySelectorAll('[aria-describedby]');
|
|
209
|
-
for (const el of described) {
|
|
210
|
-
const ids = (el.getAttribute('aria-describedby') ?? '').split(/\s+/);
|
|
211
|
-
for (const id of ids) {
|
|
212
|
-
if (id && !document.getElementById(id)) {
|
|
213
|
-
findings.push(
|
|
214
|
-
finding(
|
|
215
|
-
'error',
|
|
216
|
-
`aria-describedby references "${id}" which does not exist in the document.`,
|
|
217
|
-
el,
|
|
218
|
-
'aria-describedby-ref'
|
|
219
|
-
)
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return findings;
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Checks for sufficient document landmarks.
|
|
230
|
-
* @internal
|
|
231
|
-
*/
|
|
232
|
-
const auditLandmarks = (container: Element): AuditFinding[] => {
|
|
233
|
-
const findings: AuditFinding[] = [];
|
|
234
|
-
|
|
235
|
-
// Only audit the document body or top-level container
|
|
236
|
-
if (container === document.body || container === document.documentElement) {
|
|
237
|
-
const hasMain = !!container.querySelector('main') || !!container.querySelector('[role="main"]');
|
|
238
|
-
|
|
239
|
-
if (!hasMain) {
|
|
240
|
-
findings.push(
|
|
241
|
-
finding(
|
|
242
|
-
'warning',
|
|
243
|
-
'Page is missing a <main> landmark. Add <main> or role="main" to the primary content area.',
|
|
244
|
-
container,
|
|
245
|
-
'landmark-main'
|
|
246
|
-
)
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return findings;
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Runs a development-time accessibility audit on a container element.
|
|
256
|
-
*
|
|
257
|
-
* Checks for common accessibility issues including:
|
|
258
|
-
* - Missing alt text on images
|
|
259
|
-
* - Missing labels on form inputs
|
|
260
|
-
* - Empty buttons and links
|
|
261
|
-
* - Heading hierarchy issues
|
|
262
|
-
* - Invalid ARIA references
|
|
263
|
-
* - Missing document landmarks
|
|
264
|
-
*
|
|
265
|
-
* This is intended as a development tool — not a replacement for
|
|
266
|
-
* manual testing or professional accessibility audits.
|
|
267
|
-
*
|
|
268
|
-
* @param container - The element to audit (defaults to `document.body`)
|
|
269
|
-
* @returns An audit result with findings, counts, and pass/fail status
|
|
270
|
-
*
|
|
271
|
-
* @example
|
|
272
|
-
* ```ts
|
|
273
|
-
* import { auditA11y } from '@bquery/bquery/a11y';
|
|
274
|
-
*
|
|
275
|
-
* const result = auditA11y();
|
|
276
|
-
* if (!result.passed) {
|
|
277
|
-
* console.warn(`Found ${result.errors} accessibility errors:`);
|
|
278
|
-
* for (const f of result.findings) {
|
|
279
|
-
* console.warn(`[${f.severity}] ${f.message}`, f.element);
|
|
280
|
-
* }
|
|
281
|
-
* }
|
|
282
|
-
* ```
|
|
283
|
-
*/
|
|
284
|
-
export const auditA11y = (container?: Element): AuditResult => {
|
|
285
|
-
if (typeof document === 'undefined' || !document.body) {
|
|
286
|
-
return {
|
|
287
|
-
findings: [],
|
|
288
|
-
errors: 0,
|
|
289
|
-
warnings: 0,
|
|
290
|
-
passed: true,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const target = container ?? document.body;
|
|
295
|
-
|
|
296
|
-
const allFindings: AuditFinding[] = [
|
|
297
|
-
...auditImages(target),
|
|
298
|
-
...auditFormInputs(target),
|
|
299
|
-
...auditInteractiveElements(target),
|
|
300
|
-
...auditHeadings(target),
|
|
301
|
-
...auditAria(target),
|
|
302
|
-
...auditLandmarks(target),
|
|
303
|
-
];
|
|
304
|
-
|
|
305
|
-
const errors = allFindings.filter((f) => f.severity === 'error').length;
|
|
306
|
-
const warnings = allFindings.filter((f) => f.severity === 'warning').length;
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
findings: allFindings,
|
|
310
|
-
errors,
|
|
311
|
-
warnings,
|
|
312
|
-
passed: errors === 0,
|
|
313
|
-
};
|
|
314
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Development-time accessibility audit utility.
|
|
3
|
+
*
|
|
4
|
+
* Scans DOM elements for common accessibility issues such as missing
|
|
5
|
+
* alt text on images, missing labels on form inputs, empty links/buttons,
|
|
6
|
+
* and incorrect ARIA usage.
|
|
7
|
+
*
|
|
8
|
+
* @module bquery/a11y
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AuditFinding, AuditResult, AuditSeverity } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a finding object.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
const finding = (
|
|
18
|
+
severity: AuditSeverity,
|
|
19
|
+
message: string,
|
|
20
|
+
element: Element,
|
|
21
|
+
rule: string
|
|
22
|
+
): AuditFinding => ({
|
|
23
|
+
severity,
|
|
24
|
+
message,
|
|
25
|
+
element,
|
|
26
|
+
rule,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks images for missing alt attributes.
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
const auditImages = (container: Element): AuditFinding[] => {
|
|
34
|
+
const findings: AuditFinding[] = [];
|
|
35
|
+
const images = container.querySelectorAll('img');
|
|
36
|
+
|
|
37
|
+
for (const img of images) {
|
|
38
|
+
if (!img.hasAttribute('alt')) {
|
|
39
|
+
findings.push(
|
|
40
|
+
finding(
|
|
41
|
+
'error',
|
|
42
|
+
'Image is missing an alt attribute. Add alt="" for decorative images or a descriptive alt text.',
|
|
43
|
+
img,
|
|
44
|
+
'img-alt'
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
} else if (img.getAttribute('alt') === '' && !img.hasAttribute('role')) {
|
|
48
|
+
findings.push(
|
|
49
|
+
finding(
|
|
50
|
+
'info',
|
|
51
|
+
'Image has empty alt text. Consider adding role="presentation" if decorative.',
|
|
52
|
+
img,
|
|
53
|
+
'img-decorative'
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return findings;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Checks form inputs for missing labels.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
const auditFormInputs = (container: Element): AuditFinding[] => {
|
|
67
|
+
const findings: AuditFinding[] = [];
|
|
68
|
+
const inputs = container.querySelectorAll('input, select, textarea');
|
|
69
|
+
|
|
70
|
+
for (const input of inputs) {
|
|
71
|
+
const type = input.getAttribute('type');
|
|
72
|
+
|
|
73
|
+
// Hidden, submit, and button inputs don't need labels
|
|
74
|
+
if (type === 'hidden' || type === 'submit' || type === 'button' || type === 'reset') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = input.getAttribute('id');
|
|
79
|
+
const hasLabel = id ? !!container.querySelector(`label[for="${id}"]`) : false;
|
|
80
|
+
const hasAriaLabel = input.hasAttribute('aria-label') || input.hasAttribute('aria-labelledby');
|
|
81
|
+
const hasTitle = input.hasAttribute('title');
|
|
82
|
+
const isWrappedInLabel = input.closest('label') !== null;
|
|
83
|
+
|
|
84
|
+
if (!hasLabel && !hasAriaLabel && !hasTitle && !isWrappedInLabel) {
|
|
85
|
+
findings.push(
|
|
86
|
+
finding(
|
|
87
|
+
'error',
|
|
88
|
+
`Form input is missing a label. Add a <label for="id">, aria-label, or aria-labelledby attribute.`,
|
|
89
|
+
input,
|
|
90
|
+
'input-label'
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return findings;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks for empty interactive elements (buttons, links).
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
const auditInteractiveElements = (container: Element): AuditFinding[] => {
|
|
104
|
+
const findings: AuditFinding[] = [];
|
|
105
|
+
|
|
106
|
+
// Check buttons
|
|
107
|
+
const buttons = container.querySelectorAll('button');
|
|
108
|
+
for (const btn of buttons) {
|
|
109
|
+
const hasText = (btn.textContent ?? '').trim().length > 0;
|
|
110
|
+
const hasAriaLabel = btn.hasAttribute('aria-label') || btn.hasAttribute('aria-labelledby');
|
|
111
|
+
const hasTitle = btn.hasAttribute('title');
|
|
112
|
+
|
|
113
|
+
if (!hasText && !hasAriaLabel && !hasTitle) {
|
|
114
|
+
findings.push(
|
|
115
|
+
finding(
|
|
116
|
+
'error',
|
|
117
|
+
'Button has no accessible name. Add text content, aria-label, or title.',
|
|
118
|
+
btn,
|
|
119
|
+
'button-name'
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check links
|
|
126
|
+
const links = container.querySelectorAll('a[href]');
|
|
127
|
+
for (const link of links) {
|
|
128
|
+
const hasText = (link.textContent ?? '').trim().length > 0;
|
|
129
|
+
const hasAriaLabel = link.hasAttribute('aria-label') || link.hasAttribute('aria-labelledby');
|
|
130
|
+
const hasTitle = link.hasAttribute('title');
|
|
131
|
+
const hasImage = link.querySelector('img[alt]') !== null;
|
|
132
|
+
|
|
133
|
+
if (!hasText && !hasAriaLabel && !hasTitle && !hasImage) {
|
|
134
|
+
findings.push(
|
|
135
|
+
finding(
|
|
136
|
+
'error',
|
|
137
|
+
'Link has no accessible name. Add text content, aria-label, or title.',
|
|
138
|
+
link,
|
|
139
|
+
'link-name'
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return findings;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Checks heading hierarchy for skipped levels.
|
|
150
|
+
* @internal
|
|
151
|
+
*/
|
|
152
|
+
const auditHeadings = (container: Element): AuditFinding[] => {
|
|
153
|
+
const findings: AuditFinding[] = [];
|
|
154
|
+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
155
|
+
|
|
156
|
+
let previousLevel = 0;
|
|
157
|
+
|
|
158
|
+
for (const heading of headings) {
|
|
159
|
+
const level = parseInt(heading.tagName.charAt(1), 10);
|
|
160
|
+
|
|
161
|
+
if (previousLevel > 0 && level > previousLevel + 1) {
|
|
162
|
+
findings.push(
|
|
163
|
+
finding(
|
|
164
|
+
'warning',
|
|
165
|
+
`Heading level skipped: <${heading.tagName.toLowerCase()}> follows <h${previousLevel}>. Don't skip heading levels.`,
|
|
166
|
+
heading,
|
|
167
|
+
'heading-order'
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if ((heading.textContent ?? '').trim().length === 0) {
|
|
173
|
+
findings.push(finding('warning', 'Heading element is empty.', heading, 'heading-empty'));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
previousLevel = level;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return findings;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Checks for valid ARIA attribute usage.
|
|
184
|
+
* @internal
|
|
185
|
+
*/
|
|
186
|
+
const auditAria = (container: Element): AuditFinding[] => {
|
|
187
|
+
const findings: AuditFinding[] = [];
|
|
188
|
+
|
|
189
|
+
// Check aria-labelledby references exist
|
|
190
|
+
const labelled = container.querySelectorAll('[aria-labelledby]');
|
|
191
|
+
for (const el of labelled) {
|
|
192
|
+
const ids = (el.getAttribute('aria-labelledby') ?? '').split(/\s+/);
|
|
193
|
+
for (const id of ids) {
|
|
194
|
+
if (id && !document.getElementById(id)) {
|
|
195
|
+
findings.push(
|
|
196
|
+
finding(
|
|
197
|
+
'error',
|
|
198
|
+
`aria-labelledby references "${id}" which does not exist in the document.`,
|
|
199
|
+
el,
|
|
200
|
+
'aria-labelledby-ref'
|
|
201
|
+
)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check aria-describedby references exist
|
|
208
|
+
const described = container.querySelectorAll('[aria-describedby]');
|
|
209
|
+
for (const el of described) {
|
|
210
|
+
const ids = (el.getAttribute('aria-describedby') ?? '').split(/\s+/);
|
|
211
|
+
for (const id of ids) {
|
|
212
|
+
if (id && !document.getElementById(id)) {
|
|
213
|
+
findings.push(
|
|
214
|
+
finding(
|
|
215
|
+
'error',
|
|
216
|
+
`aria-describedby references "${id}" which does not exist in the document.`,
|
|
217
|
+
el,
|
|
218
|
+
'aria-describedby-ref'
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return findings;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Checks for sufficient document landmarks.
|
|
230
|
+
* @internal
|
|
231
|
+
*/
|
|
232
|
+
const auditLandmarks = (container: Element): AuditFinding[] => {
|
|
233
|
+
const findings: AuditFinding[] = [];
|
|
234
|
+
|
|
235
|
+
// Only audit the document body or top-level container
|
|
236
|
+
if (container === document.body || container === document.documentElement) {
|
|
237
|
+
const hasMain = !!container.querySelector('main') || !!container.querySelector('[role="main"]');
|
|
238
|
+
|
|
239
|
+
if (!hasMain) {
|
|
240
|
+
findings.push(
|
|
241
|
+
finding(
|
|
242
|
+
'warning',
|
|
243
|
+
'Page is missing a <main> landmark. Add <main> or role="main" to the primary content area.',
|
|
244
|
+
container,
|
|
245
|
+
'landmark-main'
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return findings;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Runs a development-time accessibility audit on a container element.
|
|
256
|
+
*
|
|
257
|
+
* Checks for common accessibility issues including:
|
|
258
|
+
* - Missing alt text on images
|
|
259
|
+
* - Missing labels on form inputs
|
|
260
|
+
* - Empty buttons and links
|
|
261
|
+
* - Heading hierarchy issues
|
|
262
|
+
* - Invalid ARIA references
|
|
263
|
+
* - Missing document landmarks
|
|
264
|
+
*
|
|
265
|
+
* This is intended as a development tool — not a replacement for
|
|
266
|
+
* manual testing or professional accessibility audits.
|
|
267
|
+
*
|
|
268
|
+
* @param container - The element to audit (defaults to `document.body`)
|
|
269
|
+
* @returns An audit result with findings, counts, and pass/fail status
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* import { auditA11y } from '@bquery/bquery/a11y';
|
|
274
|
+
*
|
|
275
|
+
* const result = auditA11y();
|
|
276
|
+
* if (!result.passed) {
|
|
277
|
+
* console.warn(`Found ${result.errors} accessibility errors:`);
|
|
278
|
+
* for (const f of result.findings) {
|
|
279
|
+
* console.warn(`[${f.severity}] ${f.message}`, f.element);
|
|
280
|
+
* }
|
|
281
|
+
* }
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export const auditA11y = (container?: Element): AuditResult => {
|
|
285
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
286
|
+
return {
|
|
287
|
+
findings: [],
|
|
288
|
+
errors: 0,
|
|
289
|
+
warnings: 0,
|
|
290
|
+
passed: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const target = container ?? document.body;
|
|
295
|
+
|
|
296
|
+
const allFindings: AuditFinding[] = [
|
|
297
|
+
...auditImages(target),
|
|
298
|
+
...auditFormInputs(target),
|
|
299
|
+
...auditInteractiveElements(target),
|
|
300
|
+
...auditHeadings(target),
|
|
301
|
+
...auditAria(target),
|
|
302
|
+
...auditLandmarks(target),
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
const errors = allFindings.filter((f) => f.severity === 'error').length;
|
|
306
|
+
const warnings = allFindings.filter((f) => f.severity === 'warning').length;
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
findings: allFindings,
|
|
310
|
+
errors,
|
|
311
|
+
warnings,
|
|
312
|
+
passed: errors === 0,
|
|
313
|
+
};
|
|
314
|
+
};
|