@a11y-oracle/focus-analyzer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/lib/color-parser.d.ts +29 -0
- package/dist/lib/color-parser.d.ts.map +1 -0
- package/dist/lib/color-parser.js +110 -0
- package/dist/lib/contrast.d.ts +54 -0
- package/dist/lib/contrast.d.ts.map +1 -0
- package/dist/lib/contrast.js +68 -0
- package/dist/lib/focus-analyzer.d.ts +77 -0
- package/dist/lib/focus-analyzer.d.ts.map +1 -0
- package/dist/lib/focus-analyzer.js +299 -0
- package/dist/lib/types.d.ts +97 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +7 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @a11y-oracle/focus-analyzer
|
|
2
|
+
|
|
3
|
+
Focus state analysis for accessibility testing. Provides visual focus indicator inspection, WCAG contrast ratio calculation, DOM tab order extraction, and keyboard trap detection.
|
|
4
|
+
|
|
5
|
+
This is a low-level building block used internally by `@a11y-oracle/core-engine`. Most users should use the Playwright or Cypress plugin instead.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @a11y-oracle/focus-analyzer
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { FocusAnalyzer } from '@a11y-oracle/focus-analyzer';
|
|
17
|
+
|
|
18
|
+
const analyzer = new FocusAnalyzer(cdpSession);
|
|
19
|
+
|
|
20
|
+
// Analyze the focus indicator on the currently focused element
|
|
21
|
+
const indicator = await analyzer.getFocusIndicator();
|
|
22
|
+
console.log(indicator.isVisible); // true
|
|
23
|
+
console.log(indicator.contrastRatio); // 12.63
|
|
24
|
+
console.log(indicator.meetsWCAG_AA); // true (visible + contrast >= 3.0)
|
|
25
|
+
console.log(indicator.outlineColor); // "rgb(0, 95, 204)"
|
|
26
|
+
console.log(indicator.outlineWidth); // "3px"
|
|
27
|
+
|
|
28
|
+
// Extract all tabbable elements in DOM tab order
|
|
29
|
+
const entries = await analyzer.getTabOrder();
|
|
30
|
+
entries.forEach(e => {
|
|
31
|
+
console.log(`${e.index}: ${e.tag}#${e.id} (tabIndex=${e.tabIndex})`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Detect keyboard traps (WCAG 2.1.2)
|
|
35
|
+
const result = await analyzer.detectKeyboardTrap('#modal-container', 20);
|
|
36
|
+
if (result.isTrapped) {
|
|
37
|
+
console.log('Focus is trapped!');
|
|
38
|
+
} else {
|
|
39
|
+
console.log(`Focus escaped to: ${result.escapeElement?.tag}`);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API Reference
|
|
44
|
+
|
|
45
|
+
### `FocusAnalyzer`
|
|
46
|
+
|
|
47
|
+
#### `constructor(cdp: CDPSessionLike)`
|
|
48
|
+
|
|
49
|
+
Create a new focus analyzer. Internally creates a `KeyboardEngine` for trap detection.
|
|
50
|
+
|
|
51
|
+
- `cdp` — Any CDP-compatible session (uses the `CDPSessionLike` interface from `@a11y-oracle/cdp-types`).
|
|
52
|
+
|
|
53
|
+
#### `getFocusIndicator(): Promise<FocusIndicator>`
|
|
54
|
+
|
|
55
|
+
Analyze the visual focus indicator of the currently focused element.
|
|
56
|
+
|
|
57
|
+
Extracts computed CSS properties (`outline`, `box-shadow`, `border-color`, `background-color`) via `Runtime.evaluate`, then calculates the contrast ratio of the focus indicator against the background.
|
|
58
|
+
|
|
59
|
+
Returns a default "not visible" indicator if no element has focus.
|
|
60
|
+
|
|
61
|
+
**Visibility detection:**
|
|
62
|
+
- An element has a visible **outline** if `outline-width` is not `0px` and `outline-color` is not `transparent`
|
|
63
|
+
- An element has a visible **box-shadow** if `box-shadow` is not `none`
|
|
64
|
+
|
|
65
|
+
**Contrast calculation:**
|
|
66
|
+
- For outlines: contrast ratio between `outline-color` and `background-color`
|
|
67
|
+
- For box-shadows: extracts the first color from the `box-shadow` value and computes contrast against `background-color`
|
|
68
|
+
- Returns `contrastRatio: null` if colors cannot be reliably parsed (e.g., gradients, complex color functions)
|
|
69
|
+
|
|
70
|
+
**WCAG 2.4.12 AA compliance:** `meetsWCAG_AA` is `true` when the indicator is visible AND the contrast ratio >= 3.0.
|
|
71
|
+
|
|
72
|
+
#### `getTabOrder(): Promise<TabOrderEntry[]>`
|
|
73
|
+
|
|
74
|
+
Extract all tabbable elements from the DOM in tab order.
|
|
75
|
+
|
|
76
|
+
Queries for focusable elements (`a[href]`, `button:not([disabled])`, `input:not([disabled])`, `select:not([disabled])`, `textarea:not([disabled])`, `[tabindex]`), then filters out:
|
|
77
|
+
- Elements with `tabIndex < 0`
|
|
78
|
+
- Hidden elements (`display: none`, `visibility: hidden`)
|
|
79
|
+
- Elements with no layout (`offsetParent === null`)
|
|
80
|
+
- Elements inside `[inert]` containers
|
|
81
|
+
|
|
82
|
+
Results are sorted by `tabIndex` value: positive `tabIndex` values first (ascending), then `tabIndex=0` elements in DOM order.
|
|
83
|
+
|
|
84
|
+
#### `detectKeyboardTrap(selector: string, maxTabs?: number): Promise<TraversalResult>`
|
|
85
|
+
|
|
86
|
+
Detect whether focus is trapped inside a container (WCAG 2.1.2).
|
|
87
|
+
|
|
88
|
+
1. Focuses the first tabbable element inside the container matching `selector`
|
|
89
|
+
2. Presses Tab repeatedly (up to `maxTabs`, default 50)
|
|
90
|
+
3. After each Tab, checks whether focus has left the container
|
|
91
|
+
4. Returns immediately if focus escapes, or declares a trap if `maxTabs` is exhausted
|
|
92
|
+
|
|
93
|
+
Returns `{ isTrapped: false, tabCount: 0 }` if the container doesn't exist or has no focusable elements.
|
|
94
|
+
|
|
95
|
+
### Pure Utility Functions
|
|
96
|
+
|
|
97
|
+
The package also exports pure functions for color parsing and WCAG contrast calculation:
|
|
98
|
+
|
|
99
|
+
#### `parseColor(css: string): RGBColor | null`
|
|
100
|
+
|
|
101
|
+
Parse a CSS color string to an `RGBColor` object. Supports:
|
|
102
|
+
- `rgb(r, g, b)` and `rgba(r, g, b, a)`
|
|
103
|
+
- Hex colors: `#RGB`, `#RRGGBB`, `#RRGGBBAA`
|
|
104
|
+
- `transparent` (returns `{ r: 0, g: 0, b: 0, a: 0 }`)
|
|
105
|
+
|
|
106
|
+
Returns `null` for unsupported formats (named colors, `hsl()`, `color()`, etc.).
|
|
107
|
+
|
|
108
|
+
#### `srgbToLinear(channel: number): number`
|
|
109
|
+
|
|
110
|
+
Convert an sRGB channel value (0-255) to linear light.
|
|
111
|
+
|
|
112
|
+
#### `relativeLuminance(color: RGBColor): number`
|
|
113
|
+
|
|
114
|
+
Calculate WCAG relative luminance: `L = 0.2126*R + 0.7152*G + 0.0722*B`
|
|
115
|
+
|
|
116
|
+
#### `contrastRatio(color1: RGBColor, color2: RGBColor): number`
|
|
117
|
+
|
|
118
|
+
Calculate the WCAG contrast ratio between two colors. Returns a value between 1 (identical) and 21 (black on white).
|
|
119
|
+
|
|
120
|
+
#### `meetsAA(ratio: number): boolean`
|
|
121
|
+
|
|
122
|
+
Check if a contrast ratio meets WCAG AA for focus indicators (>= 3.0).
|
|
123
|
+
|
|
124
|
+
### Types
|
|
125
|
+
|
|
126
|
+
#### `RGBColor`
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
interface RGBColor {
|
|
130
|
+
r: number; // 0-255
|
|
131
|
+
g: number; // 0-255
|
|
132
|
+
b: number; // 0-255
|
|
133
|
+
a: number; // 0-1
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### `FocusIndicator`
|
|
138
|
+
|
|
139
|
+
Full focus indicator analysis:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
interface FocusIndicator {
|
|
143
|
+
isVisible: boolean;
|
|
144
|
+
outline: string; // Raw outline shorthand
|
|
145
|
+
outlineColor: string; // e.g. "rgb(0, 95, 204)"
|
|
146
|
+
outlineWidth: string; // e.g. "3px"
|
|
147
|
+
outlineOffset: string; // e.g. "0px"
|
|
148
|
+
boxShadow: string; // e.g. "0px 0px 0px 3px rgb(52, 152, 219)"
|
|
149
|
+
borderColor: string;
|
|
150
|
+
backgroundColor: string;
|
|
151
|
+
contrastRatio: number | null;
|
|
152
|
+
meetsWCAG_AA: boolean;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### `TabOrderEntry`
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
interface TabOrderEntry {
|
|
160
|
+
index: number; // Position in tab order (0-based)
|
|
161
|
+
tag: string; // "BUTTON"
|
|
162
|
+
id: string;
|
|
163
|
+
textContent: string;
|
|
164
|
+
tabIndex: number;
|
|
165
|
+
role: string;
|
|
166
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### `TabOrderReport`
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
interface TabOrderReport {
|
|
174
|
+
entries: TabOrderEntry[];
|
|
175
|
+
totalCount: number;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
#### `TraversalResult`
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
interface TraversalResult {
|
|
183
|
+
isTrapped: boolean; // true if focus never escaped
|
|
184
|
+
tabCount: number; // Total Tab presses attempted
|
|
185
|
+
visitedElements: TabOrderEntry[]; // Elements that received focus
|
|
186
|
+
escapeElement: TabOrderEntry | null; // First element outside container
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Exports
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
export { FocusAnalyzer } from '@a11y-oracle/focus-analyzer';
|
|
194
|
+
export { parseColor } from '@a11y-oracle/focus-analyzer';
|
|
195
|
+
export { srgbToLinear, relativeLuminance, contrastRatio, meetsAA } from '@a11y-oracle/focus-analyzer';
|
|
196
|
+
export type { RGBColor, FocusIndicator, TabOrderEntry, TraversalResult, TabOrderReport } from '@a11y-oracle/focus-analyzer';
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Limitations
|
|
200
|
+
|
|
201
|
+
- **Complex focus indicators** — Gradients, CSS `color-mix()`, and other advanced color functions return `contrastRatio: null` because they cannot be reliably parsed into a single color value.
|
|
202
|
+
- **Box-shadow parsing** — Only the first color found in a `box-shadow` value is used for contrast calculation. Multi-layer box-shadows may not be fully analyzed.
|
|
203
|
+
- **Keyboard trap detection** — Tests with Tab key only. Intentional focus traps (e.g., modal dialogs) should be tested separately with Escape key navigation.
|
|
204
|
+
- **`tabIndex` ordering** — The tab order extraction follows the standard algorithm (positive `tabIndex` first, then `tabIndex=0` in DOM order), but doesn't account for Shadow DOM boundaries.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @a11y-oracle/focus-analyzer
|
|
3
|
+
*
|
|
4
|
+
* Focus state analysis for accessibility testing. Provides visual
|
|
5
|
+
* focus indicator inspection, WCAG contrast ratio calculation,
|
|
6
|
+
* DOM tab order extraction, and keyboard trap detection.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
export { FocusAnalyzer } from './lib/focus-analyzer.js';
|
|
11
|
+
export { parseColor } from './lib/color-parser.js';
|
|
12
|
+
export { srgbToLinear, relativeLuminance, contrastRatio, meetsAA } from './lib/contrast.js';
|
|
13
|
+
export type { RGBColor, FocusIndicator, TabOrderEntry, TraversalResult, TabOrderReport, } from './lib/types.js';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5F,YAAY,EACV,QAAQ,EACR,cAAc,EACd,aAAa,EACb,eAAe,EACf,cAAc,GACf,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @a11y-oracle/focus-analyzer
|
|
3
|
+
*
|
|
4
|
+
* Focus state analysis for accessibility testing. Provides visual
|
|
5
|
+
* focus indicator inspection, WCAG contrast ratio calculation,
|
|
6
|
+
* DOM tab order extraction, and keyboard trap detection.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
export { FocusAnalyzer } from './lib/focus-analyzer.js';
|
|
11
|
+
export { parseColor } from './lib/color-parser.js';
|
|
12
|
+
export { srgbToLinear, relativeLuminance, contrastRatio, meetsAA } from './lib/contrast.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module color-parser
|
|
3
|
+
*
|
|
4
|
+
* Parses CSS color values into normalized RGBA tuples.
|
|
5
|
+
* Handles `rgb()`, `rgba()`, `#hex` (3, 4, 6, 8 digit),
|
|
6
|
+
* and `transparent`.
|
|
7
|
+
*/
|
|
8
|
+
import type { RGBColor } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Parse a CSS color string into an {@link RGBColor} tuple.
|
|
11
|
+
*
|
|
12
|
+
* Supports:
|
|
13
|
+
* - `rgb(r, g, b)` and `rgba(r, g, b, a)`
|
|
14
|
+
* - `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
|
|
15
|
+
* - `transparent` (returns black with alpha 0)
|
|
16
|
+
*
|
|
17
|
+
* @param css - A CSS color value string.
|
|
18
|
+
* @returns The parsed color, or `null` if parsing fails.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* parseColor('rgb(255, 0, 0)'); // { r: 255, g: 0, b: 0, a: 1 }
|
|
23
|
+
* parseColor('#3498db'); // { r: 52, g: 152, b: 219, a: 1 }
|
|
24
|
+
* parseColor('rgba(0, 0, 0, 0.5)'); // { r: 0, g: 0, b: 0, a: 0.5 }
|
|
25
|
+
* parseColor('transparent'); // { r: 0, g: 0, b: 0, a: 0 }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseColor(css: string): RGBColor | null;
|
|
29
|
+
//# sourceMappingURL=color-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"color-parser.d.ts","sourceRoot":"","sources":["../../src/lib/color-parser.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CA0FvD"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module color-parser
|
|
3
|
+
*
|
|
4
|
+
* Parses CSS color values into normalized RGBA tuples.
|
|
5
|
+
* Handles `rgb()`, `rgba()`, `#hex` (3, 4, 6, 8 digit),
|
|
6
|
+
* and `transparent`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Parse a CSS color string into an {@link RGBColor} tuple.
|
|
10
|
+
*
|
|
11
|
+
* Supports:
|
|
12
|
+
* - `rgb(r, g, b)` and `rgba(r, g, b, a)`
|
|
13
|
+
* - `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
|
|
14
|
+
* - `transparent` (returns black with alpha 0)
|
|
15
|
+
*
|
|
16
|
+
* @param css - A CSS color value string.
|
|
17
|
+
* @returns The parsed color, or `null` if parsing fails.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* parseColor('rgb(255, 0, 0)'); // { r: 255, g: 0, b: 0, a: 1 }
|
|
22
|
+
* parseColor('#3498db'); // { r: 52, g: 152, b: 219, a: 1 }
|
|
23
|
+
* parseColor('rgba(0, 0, 0, 0.5)'); // { r: 0, g: 0, b: 0, a: 0.5 }
|
|
24
|
+
* parseColor('transparent'); // { r: 0, g: 0, b: 0, a: 0 }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function parseColor(css) {
|
|
28
|
+
if (!css || typeof css !== 'string')
|
|
29
|
+
return null;
|
|
30
|
+
const trimmed = css.trim().toLowerCase();
|
|
31
|
+
// transparent
|
|
32
|
+
if (trimmed === 'transparent') {
|
|
33
|
+
return { r: 0, g: 0, b: 0, a: 0 };
|
|
34
|
+
}
|
|
35
|
+
// rgb() and rgba()
|
|
36
|
+
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+)\s*)?\)$/);
|
|
37
|
+
if (rgbMatch) {
|
|
38
|
+
return {
|
|
39
|
+
r: clamp(parseInt(rgbMatch[1], 10), 0, 255),
|
|
40
|
+
g: clamp(parseInt(rgbMatch[2], 10), 0, 255),
|
|
41
|
+
b: clamp(parseInt(rgbMatch[3], 10), 0, 255),
|
|
42
|
+
a: rgbMatch[4] !== undefined ? clamp(parseFloat(rgbMatch[4]), 0, 1) : 1,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Modern space-separated rgb() / rgba() syntax: rgb(255 0 0 / 0.5)
|
|
46
|
+
const rgbSpaceMatch = trimmed.match(/^rgba?\(\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s*(?:\/\s*([\d.]+%?)\s*)?\)$/);
|
|
47
|
+
if (rgbSpaceMatch) {
|
|
48
|
+
let alpha = 1;
|
|
49
|
+
if (rgbSpaceMatch[4] !== undefined) {
|
|
50
|
+
const alphaStr = rgbSpaceMatch[4];
|
|
51
|
+
alpha = alphaStr.endsWith('%')
|
|
52
|
+
? clamp(parseFloat(alphaStr) / 100, 0, 1)
|
|
53
|
+
: clamp(parseFloat(alphaStr), 0, 1);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
r: clamp(parseInt(rgbSpaceMatch[1], 10), 0, 255),
|
|
57
|
+
g: clamp(parseInt(rgbSpaceMatch[2], 10), 0, 255),
|
|
58
|
+
b: clamp(parseInt(rgbSpaceMatch[3], 10), 0, 255),
|
|
59
|
+
a: alpha,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Hex colors
|
|
63
|
+
const hexMatch = trimmed.match(/^#([0-9a-f]+)$/);
|
|
64
|
+
if (hexMatch) {
|
|
65
|
+
const hex = hexMatch[1];
|
|
66
|
+
if (hex.length === 3) {
|
|
67
|
+
// #RGB → #RRGGBB
|
|
68
|
+
return {
|
|
69
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
70
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
71
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
72
|
+
a: 1,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (hex.length === 4) {
|
|
76
|
+
// #RGBA → #RRGGBBAA
|
|
77
|
+
return {
|
|
78
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
79
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
80
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
81
|
+
a: parseInt(hex[3] + hex[3], 16) / 255,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (hex.length === 6) {
|
|
85
|
+
// #RRGGBB
|
|
86
|
+
return {
|
|
87
|
+
r: parseInt(hex.substring(0, 2), 16),
|
|
88
|
+
g: parseInt(hex.substring(2, 4), 16),
|
|
89
|
+
b: parseInt(hex.substring(4, 6), 16),
|
|
90
|
+
a: 1,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (hex.length === 8) {
|
|
94
|
+
// #RRGGBBAA
|
|
95
|
+
return {
|
|
96
|
+
r: parseInt(hex.substring(0, 2), 16),
|
|
97
|
+
g: parseInt(hex.substring(2, 4), 16),
|
|
98
|
+
b: parseInt(hex.substring(4, 6), 16),
|
|
99
|
+
a: parseInt(hex.substring(6, 8), 16) / 255,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Clamp a value between min and max.
|
|
107
|
+
*/
|
|
108
|
+
function clamp(value, min, max) {
|
|
109
|
+
return Math.min(Math.max(value, min), max);
|
|
110
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module contrast
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.x relative luminance and contrast ratio calculations.
|
|
5
|
+
*
|
|
6
|
+
* Implements the formulas from:
|
|
7
|
+
* - {@link https://www.w3.org/TR/WCAG21/#dfn-relative-luminance | WCAG 2.1 Relative Luminance}
|
|
8
|
+
* - {@link https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio | WCAG 2.1 Contrast Ratio}
|
|
9
|
+
*/
|
|
10
|
+
import type { RGBColor } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Convert an sRGB channel value (0–255) to linear RGB.
|
|
13
|
+
*
|
|
14
|
+
* Applies the sRGB inverse companding function:
|
|
15
|
+
* - If sRGB <= 0.04045: linear = sRGB / 12.92
|
|
16
|
+
* - Otherwise: linear = ((sRGB + 0.055) / 1.055) ^ 2.4
|
|
17
|
+
*
|
|
18
|
+
* @param channel - sRGB channel value in range [0, 255].
|
|
19
|
+
* @returns Linear RGB value in range [0, 1].
|
|
20
|
+
*/
|
|
21
|
+
export declare function srgbToLinear(channel: number): number;
|
|
22
|
+
/**
|
|
23
|
+
* Calculate the WCAG relative luminance of a color.
|
|
24
|
+
*
|
|
25
|
+
* Formula: L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
26
|
+
* where R, G, B are linearized sRGB channel values.
|
|
27
|
+
*
|
|
28
|
+
* @param color - The RGB color.
|
|
29
|
+
* @returns Relative luminance in range [0, 1].
|
|
30
|
+
* 0 = darkest black, 1 = lightest white.
|
|
31
|
+
*/
|
|
32
|
+
export declare function relativeLuminance(color: RGBColor): number;
|
|
33
|
+
/**
|
|
34
|
+
* Calculate the WCAG contrast ratio between two colors.
|
|
35
|
+
*
|
|
36
|
+
* Formula: (L1 + 0.05) / (L2 + 0.05)
|
|
37
|
+
* where L1 is the lighter luminance and L2 is the darker.
|
|
38
|
+
*
|
|
39
|
+
* @param foreground - The foreground (indicator) color.
|
|
40
|
+
* @param background - The background color.
|
|
41
|
+
* @returns Contrast ratio in range [1, 21].
|
|
42
|
+
* 1:1 = no contrast, 21:1 = maximum contrast.
|
|
43
|
+
*/
|
|
44
|
+
export declare function contrastRatio(foreground: RGBColor, background: RGBColor): number;
|
|
45
|
+
/**
|
|
46
|
+
* Check if a contrast ratio meets WCAG 2.4.12 AA for focus indicators.
|
|
47
|
+
*
|
|
48
|
+
* WCAG 2.4.12 requires a minimum 3:1 contrast ratio for focus indicators.
|
|
49
|
+
*
|
|
50
|
+
* @param ratio - The contrast ratio.
|
|
51
|
+
* @returns `true` if the ratio meets or exceeds 3.0.
|
|
52
|
+
*/
|
|
53
|
+
export declare function meetsAA(ratio: number): boolean;
|
|
54
|
+
//# sourceMappingURL=contrast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../../src/lib/contrast.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGpD;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,CAKzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,GAAG,MAAM,CAMhF;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE9C"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module contrast
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.x relative luminance and contrast ratio calculations.
|
|
5
|
+
*
|
|
6
|
+
* Implements the formulas from:
|
|
7
|
+
* - {@link https://www.w3.org/TR/WCAG21/#dfn-relative-luminance | WCAG 2.1 Relative Luminance}
|
|
8
|
+
* - {@link https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio | WCAG 2.1 Contrast Ratio}
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Convert an sRGB channel value (0–255) to linear RGB.
|
|
12
|
+
*
|
|
13
|
+
* Applies the sRGB inverse companding function:
|
|
14
|
+
* - If sRGB <= 0.04045: linear = sRGB / 12.92
|
|
15
|
+
* - Otherwise: linear = ((sRGB + 0.055) / 1.055) ^ 2.4
|
|
16
|
+
*
|
|
17
|
+
* @param channel - sRGB channel value in range [0, 255].
|
|
18
|
+
* @returns Linear RGB value in range [0, 1].
|
|
19
|
+
*/
|
|
20
|
+
export function srgbToLinear(channel) {
|
|
21
|
+
const s = channel / 255;
|
|
22
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Calculate the WCAG relative luminance of a color.
|
|
26
|
+
*
|
|
27
|
+
* Formula: L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
28
|
+
* where R, G, B are linearized sRGB channel values.
|
|
29
|
+
*
|
|
30
|
+
* @param color - The RGB color.
|
|
31
|
+
* @returns Relative luminance in range [0, 1].
|
|
32
|
+
* 0 = darkest black, 1 = lightest white.
|
|
33
|
+
*/
|
|
34
|
+
export function relativeLuminance(color) {
|
|
35
|
+
const r = srgbToLinear(color.r);
|
|
36
|
+
const g = srgbToLinear(color.g);
|
|
37
|
+
const b = srgbToLinear(color.b);
|
|
38
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Calculate the WCAG contrast ratio between two colors.
|
|
42
|
+
*
|
|
43
|
+
* Formula: (L1 + 0.05) / (L2 + 0.05)
|
|
44
|
+
* where L1 is the lighter luminance and L2 is the darker.
|
|
45
|
+
*
|
|
46
|
+
* @param foreground - The foreground (indicator) color.
|
|
47
|
+
* @param background - The background color.
|
|
48
|
+
* @returns Contrast ratio in range [1, 21].
|
|
49
|
+
* 1:1 = no contrast, 21:1 = maximum contrast.
|
|
50
|
+
*/
|
|
51
|
+
export function contrastRatio(foreground, background) {
|
|
52
|
+
const l1 = relativeLuminance(foreground);
|
|
53
|
+
const l2 = relativeLuminance(background);
|
|
54
|
+
const lighter = Math.max(l1, l2);
|
|
55
|
+
const darker = Math.min(l1, l2);
|
|
56
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a contrast ratio meets WCAG 2.4.12 AA for focus indicators.
|
|
60
|
+
*
|
|
61
|
+
* WCAG 2.4.12 requires a minimum 3:1 contrast ratio for focus indicators.
|
|
62
|
+
*
|
|
63
|
+
* @param ratio - The contrast ratio.
|
|
64
|
+
* @returns `true` if the ratio meets or exceeds 3.0.
|
|
65
|
+
*/
|
|
66
|
+
export function meetsAA(ratio) {
|
|
67
|
+
return ratio >= 3.0;
|
|
68
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module focus-analyzer
|
|
3
|
+
*
|
|
4
|
+
* Analyzes visual focus indicators, extracts DOM tab order, and
|
|
5
|
+
* detects keyboard traps via CDP `Runtime.evaluate`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { FocusAnalyzer } from '@a11y-oracle/focus-analyzer';
|
|
10
|
+
*
|
|
11
|
+
* const analyzer = new FocusAnalyzer(cdpSession);
|
|
12
|
+
* const indicator = await analyzer.getFocusIndicator();
|
|
13
|
+
* console.log(indicator.isVisible, indicator.contrastRatio);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
|
|
17
|
+
import type { FocusIndicator, TabOrderEntry, TraversalResult } from './types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Analyzes focus indicators, tab order, and keyboard traps.
|
|
20
|
+
*
|
|
21
|
+
* Uses CDP `Runtime.evaluate` to inspect DOM state and
|
|
22
|
+
* {@link KeyboardEngine} for dispatching Tab keys during
|
|
23
|
+
* trap detection.
|
|
24
|
+
*/
|
|
25
|
+
export declare class FocusAnalyzer {
|
|
26
|
+
private cdp;
|
|
27
|
+
private keyboard;
|
|
28
|
+
/**
|
|
29
|
+
* @param cdp - CDP session for sending protocol commands.
|
|
30
|
+
*/
|
|
31
|
+
constructor(cdp: CDPSessionLike);
|
|
32
|
+
/**
|
|
33
|
+
* Analyze the visual focus indicator of the currently focused element.
|
|
34
|
+
*
|
|
35
|
+
* Extracts computed CSS properties (`outline`, `box-shadow`, `border`,
|
|
36
|
+
* `background-color`) and calculates the contrast ratio of the focus
|
|
37
|
+
* indicator against the background.
|
|
38
|
+
*
|
|
39
|
+
* @returns Focus indicator analysis, or a default "not visible"
|
|
40
|
+
* indicator if no element has focus.
|
|
41
|
+
*/
|
|
42
|
+
getFocusIndicator(): Promise<FocusIndicator>;
|
|
43
|
+
/**
|
|
44
|
+
* Extract all tabbable elements from the DOM in tab order.
|
|
45
|
+
*
|
|
46
|
+
* Queries for focusable elements (`a[href]`, `button`, `input`,
|
|
47
|
+
* `select`, `textarea`, `[tabindex]`), filters out hidden and
|
|
48
|
+
* disabled elements, and sorts by `tabIndex` value.
|
|
49
|
+
*
|
|
50
|
+
* @returns Tab order entries sorted by actual tab key order.
|
|
51
|
+
*/
|
|
52
|
+
getTabOrder(): Promise<TabOrderEntry[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Detect whether focus is trapped inside a container.
|
|
55
|
+
*
|
|
56
|
+
* Focuses the first tabbable element inside the container, then
|
|
57
|
+
* presses Tab repeatedly. If focus never leaves the container
|
|
58
|
+
* after `maxTabs` presses, the container is a keyboard trap.
|
|
59
|
+
*
|
|
60
|
+
* @param selector - CSS selector for the container to test.
|
|
61
|
+
* @param maxTabs - Maximum Tab presses before declaring a trap. Default 50.
|
|
62
|
+
* @returns Traversal result indicating trap status.
|
|
63
|
+
*/
|
|
64
|
+
detectKeyboardTrap(selector: string, maxTabs?: number): Promise<TraversalResult>;
|
|
65
|
+
/**
|
|
66
|
+
* Extract the dominant color from a CSS box-shadow value.
|
|
67
|
+
*
|
|
68
|
+
* Takes the first color found in a `box-shadow` string.
|
|
69
|
+
* Example: `"0px 0px 0px 3px rgb(52, 152, 219)"` → rgb(52, 152, 219)
|
|
70
|
+
*/
|
|
71
|
+
private extractBoxShadowColor;
|
|
72
|
+
/**
|
|
73
|
+
* Create a default "not visible" focus indicator.
|
|
74
|
+
*/
|
|
75
|
+
private createEmptyIndicator;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=focus-analyzer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus-analyzer.d.ts","sourceRoot":"","sources":["../../src/lib/focus-analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,eAAe,EAChB,MAAM,YAAY,CAAC;AA4EpB;;;;;;GAMG;AACH,qBAAa,aAAa;IAMZ,OAAO,CAAC,GAAG;IALvB,OAAO,CAAC,QAAQ,CAAiB;IAEjC;;OAEG;gBACiB,GAAG,EAAE,cAAc;IAIvC;;;;;;;;;OASG;IACG,iBAAiB,IAAI,OAAO,CAAC,cAAc,CAAC;IAiDlD;;;;;;;;OAQG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;IAS7C;;;;;;;;;;OAUG;IACG,kBAAkB,CACtB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,MAAW,GACnB,OAAO,CAAC,eAAe,CAAC;IAgG3B;;;;;OAKG;IACH,OAAO,CAAC,qBAAqB;IAgB7B;;OAEG;IACH,OAAO,CAAC,oBAAoB;CAc7B"}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module focus-analyzer
|
|
3
|
+
*
|
|
4
|
+
* Analyzes visual focus indicators, extracts DOM tab order, and
|
|
5
|
+
* detects keyboard traps via CDP `Runtime.evaluate`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { FocusAnalyzer } from '@a11y-oracle/focus-analyzer';
|
|
10
|
+
*
|
|
11
|
+
* const analyzer = new FocusAnalyzer(cdpSession);
|
|
12
|
+
* const indicator = await analyzer.getFocusIndicator();
|
|
13
|
+
* console.log(indicator.isVisible, indicator.contrastRatio);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { KeyboardEngine } from '@a11y-oracle/keyboard-engine';
|
|
17
|
+
import { parseColor } from './color-parser.js';
|
|
18
|
+
import { contrastRatio, meetsAA } from './contrast.js';
|
|
19
|
+
/**
|
|
20
|
+
* JavaScript expression to extract computed focus styles from
|
|
21
|
+
* `document.activeElement`.
|
|
22
|
+
*/
|
|
23
|
+
const GET_FOCUS_STYLES_JS = `
|
|
24
|
+
(() => {
|
|
25
|
+
const el = document.activeElement;
|
|
26
|
+
if (!el || el === document.body || el === document.documentElement) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const cs = window.getComputedStyle(el);
|
|
30
|
+
return {
|
|
31
|
+
outline: cs.outline || '',
|
|
32
|
+
outlineColor: cs.outlineColor || '',
|
|
33
|
+
outlineWidth: cs.outlineWidth || '',
|
|
34
|
+
outlineOffset: cs.outlineOffset || '',
|
|
35
|
+
boxShadow: cs.boxShadow || '',
|
|
36
|
+
borderColor: cs.borderColor || '',
|
|
37
|
+
backgroundColor: cs.backgroundColor || '',
|
|
38
|
+
};
|
|
39
|
+
})()
|
|
40
|
+
`;
|
|
41
|
+
/**
|
|
42
|
+
* JavaScript expression to extract all tabbable elements in DOM order.
|
|
43
|
+
*/
|
|
44
|
+
const GET_TAB_ORDER_JS = `
|
|
45
|
+
(() => {
|
|
46
|
+
const selector = [
|
|
47
|
+
'a[href]',
|
|
48
|
+
'button:not([disabled])',
|
|
49
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
50
|
+
'select:not([disabled])',
|
|
51
|
+
'textarea:not([disabled])',
|
|
52
|
+
'[tabindex]',
|
|
53
|
+
].join(', ');
|
|
54
|
+
const elements = Array.from(document.querySelectorAll(selector));
|
|
55
|
+
return elements
|
|
56
|
+
.filter(el => {
|
|
57
|
+
if (el.tabIndex < 0) return false;
|
|
58
|
+
if (el.offsetParent === null && el.tagName !== 'BODY') return false;
|
|
59
|
+
const cs = window.getComputedStyle(el);
|
|
60
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
|
61
|
+
if (el.closest('[inert]')) return false;
|
|
62
|
+
return true;
|
|
63
|
+
})
|
|
64
|
+
.sort((a, b) => {
|
|
65
|
+
if (a.tabIndex === 0 && b.tabIndex === 0) return 0;
|
|
66
|
+
if (a.tabIndex === 0) return 1;
|
|
67
|
+
if (b.tabIndex === 0) return -1;
|
|
68
|
+
return a.tabIndex - b.tabIndex;
|
|
69
|
+
})
|
|
70
|
+
.map((el, i) => {
|
|
71
|
+
const rect = el.getBoundingClientRect();
|
|
72
|
+
return {
|
|
73
|
+
index: i,
|
|
74
|
+
tag: el.tagName,
|
|
75
|
+
id: el.id || '',
|
|
76
|
+
textContent: (el.textContent || '').trim().substring(0, 200),
|
|
77
|
+
tabIndex: el.tabIndex,
|
|
78
|
+
role: el.getAttribute('role') || '',
|
|
79
|
+
rect: {
|
|
80
|
+
x: Math.round(rect.x),
|
|
81
|
+
y: Math.round(rect.y),
|
|
82
|
+
width: Math.round(rect.width),
|
|
83
|
+
height: Math.round(rect.height),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
})()
|
|
88
|
+
`;
|
|
89
|
+
/**
|
|
90
|
+
* Analyzes focus indicators, tab order, and keyboard traps.
|
|
91
|
+
*
|
|
92
|
+
* Uses CDP `Runtime.evaluate` to inspect DOM state and
|
|
93
|
+
* {@link KeyboardEngine} for dispatching Tab keys during
|
|
94
|
+
* trap detection.
|
|
95
|
+
*/
|
|
96
|
+
export class FocusAnalyzer {
|
|
97
|
+
cdp;
|
|
98
|
+
keyboard;
|
|
99
|
+
/**
|
|
100
|
+
* @param cdp - CDP session for sending protocol commands.
|
|
101
|
+
*/
|
|
102
|
+
constructor(cdp) {
|
|
103
|
+
this.cdp = cdp;
|
|
104
|
+
this.keyboard = new KeyboardEngine(cdp);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Analyze the visual focus indicator of the currently focused element.
|
|
108
|
+
*
|
|
109
|
+
* Extracts computed CSS properties (`outline`, `box-shadow`, `border`,
|
|
110
|
+
* `background-color`) and calculates the contrast ratio of the focus
|
|
111
|
+
* indicator against the background.
|
|
112
|
+
*
|
|
113
|
+
* @returns Focus indicator analysis, or a default "not visible"
|
|
114
|
+
* indicator if no element has focus.
|
|
115
|
+
*/
|
|
116
|
+
async getFocusIndicator() {
|
|
117
|
+
const result = (await this.cdp.send('Runtime.evaluate', {
|
|
118
|
+
expression: GET_FOCUS_STYLES_JS,
|
|
119
|
+
returnByValue: true,
|
|
120
|
+
}));
|
|
121
|
+
const styles = result.result.value;
|
|
122
|
+
if (!styles) {
|
|
123
|
+
return this.createEmptyIndicator();
|
|
124
|
+
}
|
|
125
|
+
const outlineWidth = styles.outlineWidth || '0px';
|
|
126
|
+
const hasOutline = outlineWidth !== '0px' &&
|
|
127
|
+
outlineWidth !== '0' &&
|
|
128
|
+
styles.outlineColor !== 'transparent';
|
|
129
|
+
const hasBoxShadow = styles.boxShadow !== 'none' && styles.boxShadow !== '';
|
|
130
|
+
const isVisible = hasOutline || hasBoxShadow;
|
|
131
|
+
// Calculate contrast ratio between focus indicator and background
|
|
132
|
+
let ratio = null;
|
|
133
|
+
if (isVisible) {
|
|
134
|
+
const indicatorColor = hasOutline
|
|
135
|
+
? parseColor(styles.outlineColor)
|
|
136
|
+
: this.extractBoxShadowColor(styles.boxShadow);
|
|
137
|
+
const bgColor = parseColor(styles.backgroundColor);
|
|
138
|
+
if (indicatorColor && bgColor) {
|
|
139
|
+
ratio = contrastRatio(indicatorColor, bgColor);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
isVisible,
|
|
144
|
+
outline: styles.outline || '',
|
|
145
|
+
outlineColor: styles.outlineColor || '',
|
|
146
|
+
outlineWidth: styles.outlineWidth || '',
|
|
147
|
+
outlineOffset: styles.outlineOffset || '',
|
|
148
|
+
boxShadow: styles.boxShadow || '',
|
|
149
|
+
borderColor: styles.borderColor || '',
|
|
150
|
+
backgroundColor: styles.backgroundColor || '',
|
|
151
|
+
contrastRatio: ratio,
|
|
152
|
+
meetsWCAG_AA: isVisible && ratio !== null && meetsAA(ratio),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Extract all tabbable elements from the DOM in tab order.
|
|
157
|
+
*
|
|
158
|
+
* Queries for focusable elements (`a[href]`, `button`, `input`,
|
|
159
|
+
* `select`, `textarea`, `[tabindex]`), filters out hidden and
|
|
160
|
+
* disabled elements, and sorts by `tabIndex` value.
|
|
161
|
+
*
|
|
162
|
+
* @returns Tab order entries sorted by actual tab key order.
|
|
163
|
+
*/
|
|
164
|
+
async getTabOrder() {
|
|
165
|
+
const result = (await this.cdp.send('Runtime.evaluate', {
|
|
166
|
+
expression: GET_TAB_ORDER_JS,
|
|
167
|
+
returnByValue: true,
|
|
168
|
+
}));
|
|
169
|
+
return result.result.value ?? [];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Detect whether focus is trapped inside a container.
|
|
173
|
+
*
|
|
174
|
+
* Focuses the first tabbable element inside the container, then
|
|
175
|
+
* presses Tab repeatedly. If focus never leaves the container
|
|
176
|
+
* after `maxTabs` presses, the container is a keyboard trap.
|
|
177
|
+
*
|
|
178
|
+
* @param selector - CSS selector for the container to test.
|
|
179
|
+
* @param maxTabs - Maximum Tab presses before declaring a trap. Default 50.
|
|
180
|
+
* @returns Traversal result indicating trap status.
|
|
181
|
+
*/
|
|
182
|
+
async detectKeyboardTrap(selector, maxTabs = 50) {
|
|
183
|
+
// Focus the first tabbable element in the container
|
|
184
|
+
const focusResult = (await this.cdp.send('Runtime.evaluate', {
|
|
185
|
+
expression: `
|
|
186
|
+
(() => {
|
|
187
|
+
const container = document.querySelector(${JSON.stringify(selector)});
|
|
188
|
+
if (!container) return { error: 'Container not found' };
|
|
189
|
+
const focusable = container.querySelector(
|
|
190
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
191
|
+
);
|
|
192
|
+
if (!focusable) return { error: 'No focusable elements in container' };
|
|
193
|
+
focusable.focus();
|
|
194
|
+
return { success: true };
|
|
195
|
+
})()
|
|
196
|
+
`,
|
|
197
|
+
returnByValue: true,
|
|
198
|
+
}));
|
|
199
|
+
if (focusResult.result.value?.error) {
|
|
200
|
+
return {
|
|
201
|
+
isTrapped: false,
|
|
202
|
+
tabCount: 0,
|
|
203
|
+
visitedElements: [],
|
|
204
|
+
escapeElement: null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const visitedElements = [];
|
|
208
|
+
let escapeElement = null;
|
|
209
|
+
for (let i = 0; i < maxTabs; i++) {
|
|
210
|
+
await this.keyboard.press('Tab');
|
|
211
|
+
// Short delay for focus to settle
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
213
|
+
// Check where focus is now
|
|
214
|
+
const checkResult = (await this.cdp.send('Runtime.evaluate', {
|
|
215
|
+
expression: `
|
|
216
|
+
(() => {
|
|
217
|
+
const container = document.querySelector(${JSON.stringify(selector)});
|
|
218
|
+
const el = document.activeElement;
|
|
219
|
+
if (!el || el === document.body) return { outside: true, element: null };
|
|
220
|
+
const rect = el.getBoundingClientRect();
|
|
221
|
+
const entry = {
|
|
222
|
+
index: ${i},
|
|
223
|
+
tag: el.tagName,
|
|
224
|
+
id: el.id || '',
|
|
225
|
+
textContent: (el.textContent || '').trim().substring(0, 200),
|
|
226
|
+
tabIndex: el.tabIndex,
|
|
227
|
+
role: el.getAttribute('role') || '',
|
|
228
|
+
rect: {
|
|
229
|
+
x: Math.round(rect.x),
|
|
230
|
+
y: Math.round(rect.y),
|
|
231
|
+
width: Math.round(rect.width),
|
|
232
|
+
height: Math.round(rect.height),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const isInside = container && container.contains(el);
|
|
236
|
+
return { outside: !isInside, element: entry };
|
|
237
|
+
})()
|
|
238
|
+
`,
|
|
239
|
+
returnByValue: true,
|
|
240
|
+
}));
|
|
241
|
+
const check = checkResult.result.value;
|
|
242
|
+
if (check.element) {
|
|
243
|
+
if (check.outside) {
|
|
244
|
+
escapeElement = check.element;
|
|
245
|
+
return {
|
|
246
|
+
isTrapped: false,
|
|
247
|
+
tabCount: i + 1,
|
|
248
|
+
visitedElements,
|
|
249
|
+
escapeElement,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
visitedElements.push(check.element);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// If we exhausted all tabs and focus never left, it's a trap
|
|
256
|
+
return {
|
|
257
|
+
isTrapped: true,
|
|
258
|
+
tabCount: maxTabs,
|
|
259
|
+
visitedElements,
|
|
260
|
+
escapeElement: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Extract the dominant color from a CSS box-shadow value.
|
|
265
|
+
*
|
|
266
|
+
* Takes the first color found in a `box-shadow` string.
|
|
267
|
+
* Example: `"0px 0px 0px 3px rgb(52, 152, 219)"` → rgb(52, 152, 219)
|
|
268
|
+
*/
|
|
269
|
+
extractBoxShadowColor(boxShadow) {
|
|
270
|
+
// Try to find an rgb/rgba color in the box-shadow
|
|
271
|
+
const rgbMatch = boxShadow.match(/rgba?\([^)]+\)/);
|
|
272
|
+
if (rgbMatch) {
|
|
273
|
+
return parseColor(rgbMatch[0]);
|
|
274
|
+
}
|
|
275
|
+
// Try to find a hex color
|
|
276
|
+
const hexMatch = boxShadow.match(/#[0-9a-fA-F]{3,8}/);
|
|
277
|
+
if (hexMatch) {
|
|
278
|
+
return parseColor(hexMatch[0]);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Create a default "not visible" focus indicator.
|
|
284
|
+
*/
|
|
285
|
+
createEmptyIndicator() {
|
|
286
|
+
return {
|
|
287
|
+
isVisible: false,
|
|
288
|
+
outline: '',
|
|
289
|
+
outlineColor: '',
|
|
290
|
+
outlineWidth: '',
|
|
291
|
+
outlineOffset: '',
|
|
292
|
+
boxShadow: '',
|
|
293
|
+
borderColor: '',
|
|
294
|
+
backgroundColor: '',
|
|
295
|
+
contrastRatio: null,
|
|
296
|
+
meetsWCAG_AA: false,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for focus analysis, tab order traversal,
|
|
5
|
+
* and keyboard trap detection.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* An RGBA color tuple with channels in the range [0, 255]
|
|
9
|
+
* and alpha in the range [0, 1].
|
|
10
|
+
*/
|
|
11
|
+
export interface RGBColor {
|
|
12
|
+
r: number;
|
|
13
|
+
g: number;
|
|
14
|
+
b: number;
|
|
15
|
+
a: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Analysis of the visual focus indicator on the currently focused element.
|
|
19
|
+
*
|
|
20
|
+
* Includes raw CSS values, parsed contrast ratio, and WCAG 2.4.12 AA pass/fail.
|
|
21
|
+
*/
|
|
22
|
+
export interface FocusIndicator {
|
|
23
|
+
/** Whether any visual focus indicator is detected. */
|
|
24
|
+
isVisible: boolean;
|
|
25
|
+
/** Raw `outline` shorthand value. */
|
|
26
|
+
outline: string;
|
|
27
|
+
/** Computed `outline-color`. */
|
|
28
|
+
outlineColor: string;
|
|
29
|
+
/** Computed `outline-width`. */
|
|
30
|
+
outlineWidth: string;
|
|
31
|
+
/** Computed `outline-offset`. */
|
|
32
|
+
outlineOffset: string;
|
|
33
|
+
/** Computed `box-shadow`. */
|
|
34
|
+
boxShadow: string;
|
|
35
|
+
/** Computed `border-color`. */
|
|
36
|
+
borderColor: string;
|
|
37
|
+
/** Computed `background-color`. */
|
|
38
|
+
backgroundColor: string;
|
|
39
|
+
/**
|
|
40
|
+
* Contrast ratio of the focus indicator against the background.
|
|
41
|
+
* `null` if the colors could not be reliably parsed.
|
|
42
|
+
*/
|
|
43
|
+
contrastRatio: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the focus indicator meets WCAG 2.4.12 AA
|
|
46
|
+
* (contrast ratio >= 3.0 and indicator is visible).
|
|
47
|
+
*/
|
|
48
|
+
meetsWCAG_AA: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A single entry in the tab order — an element that can receive
|
|
52
|
+
* focus via the Tab key.
|
|
53
|
+
*/
|
|
54
|
+
export interface TabOrderEntry {
|
|
55
|
+
/** Position in the actual tab order (0-based). */
|
|
56
|
+
index: number;
|
|
57
|
+
/** Tag name (e.g. `'BUTTON'`). */
|
|
58
|
+
tag: string;
|
|
59
|
+
/** Element `id` attribute, or empty string. */
|
|
60
|
+
id: string;
|
|
61
|
+
/** Trimmed text content of the element. */
|
|
62
|
+
textContent: string;
|
|
63
|
+
/** The element's `tabIndex` property. */
|
|
64
|
+
tabIndex: number;
|
|
65
|
+
/** The element's `role` attribute, or empty string. */
|
|
66
|
+
role: string;
|
|
67
|
+
/** Bounding rectangle. */
|
|
68
|
+
rect: {
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Result of a keyboard trap detection traversal.
|
|
77
|
+
*/
|
|
78
|
+
export interface TraversalResult {
|
|
79
|
+
/** Whether focus was trapped (could not escape the container). */
|
|
80
|
+
isTrapped: boolean;
|
|
81
|
+
/** Total number of Tab presses attempted. */
|
|
82
|
+
tabCount: number;
|
|
83
|
+
/** Elements that received focus during the traversal. */
|
|
84
|
+
visitedElements: TabOrderEntry[];
|
|
85
|
+
/** The first element outside the container that received focus, or `null` if trapped. */
|
|
86
|
+
escapeElement: TabOrderEntry | null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Full report of a tab order traversal across the page.
|
|
90
|
+
*/
|
|
91
|
+
export interface TabOrderReport {
|
|
92
|
+
/** Tabbable elements in actual tab-key order. */
|
|
93
|
+
entries: TabOrderEntry[];
|
|
94
|
+
/** Total number of tabbable elements found. */
|
|
95
|
+
totalCount: number;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,sDAAsD;IACtD,SAAS,EAAE,OAAO,CAAC;IACnB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,mCAAmC;IACnC,eAAe,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,YAAY,EAAE,OAAO,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,+CAA+C;IAC/C,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,0BAA0B;IAC1B,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/D;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,SAAS,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,eAAe,EAAE,aAAa,EAAE,CAAC;IACjC,yFAAyF;IACzF,aAAa,EAAE,aAAa,GAAG,IAAI,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,iDAAiD;IACjD,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;CACpB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@a11y-oracle/focus-analyzer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Focus indicator CSS analysis (WCAG 2.4.12), tab order extraction, and keyboard trap detection",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "a11y-oracle",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/a11y-oracle/a11y-oracle.git",
|
|
10
|
+
"directory": "libs/focus-analyzer"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/a11y-oracle/a11y-oracle/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/a11y-oracle/a11y-oracle/tree/main/libs/focus-analyzer",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"accessibility",
|
|
18
|
+
"a11y",
|
|
19
|
+
"focus",
|
|
20
|
+
"wcag",
|
|
21
|
+
"keyboard-trap",
|
|
22
|
+
"contrast",
|
|
23
|
+
"testing"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"module": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
"./package.json": "./package.json",
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"!**/*.tsbuildinfo"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@a11y-oracle/cdp-types": "1.0.0",
|
|
46
|
+
"@a11y-oracle/keyboard-engine": "1.0.0",
|
|
47
|
+
"tslib": "^2.3.0"
|
|
48
|
+
}
|
|
49
|
+
}
|