@fusion.dev/helix-core 0.56.1 → 0.58.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/custom-elements.json +870 -65
- package/dist/controllers/cue-controller/cue-controller.d.ts +85 -0
- package/dist/controllers/cue-controller/cue-controller.js +45 -0
- package/dist/controllers/fullscreen-controller/fullscreen-controller.d.ts +32 -0
- package/dist/controllers/fullscreen-controller/fullscreen-controller.js +28 -0
- package/dist/controllers/idle-controller/idle-controller.d.ts +35 -0
- package/dist/controllers/idle-controller/idle-controller.js +24 -0
- package/dist/controllers/media-session-controller/media-session-controller.d.ts +54 -0
- package/dist/controllers/media-session-controller/media-session-controller.js +49 -0
- package/dist/controllers/remote-playback-controller/remote-playback-controller.d.ts +34 -0
- package/dist/controllers/remote-playback-controller/remote-playback-controller.js +38 -0
- package/dist/controllers/token-color-controller/token-color-controller.d.ts +3 -3
- package/dist/controllers/token-color-controller/token-color-controller.js +9 -13
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/utils/css-color/css-color.d.ts +76 -0
- package/dist/utils/css-color/css-color.js +168 -0
- package/dist/utils/focusable-elements/focusable-elements.d.ts +9 -10
- package/dist/utils/media-copy/media-copy.d.ts +27 -0
- package/dist/utils/media-copy/media-copy.js +18 -0
- package/dist/utils/read-color/read-color.d.ts +36 -0
- package/dist/utils/read-color/read-color.js +36 -0
- package/package.json +4 -4
- package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +0 -26
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -55
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
2
|
+
const toAlpha = (token) => {
|
|
3
|
+
if (token === undefined) {
|
|
4
|
+
return 100;
|
|
5
|
+
}
|
|
6
|
+
const number = Number.parseFloat(token);
|
|
7
|
+
if (Number.isNaN(number)) {
|
|
8
|
+
return 100;
|
|
9
|
+
}
|
|
10
|
+
return clamp(token.includes('%') ? number : number * 100, 0, 100);
|
|
11
|
+
};
|
|
12
|
+
const toByte = (token) => {
|
|
13
|
+
const number = Number.parseFloat(token);
|
|
14
|
+
return clamp(token.includes('%') ? (number / 100) * 255 : number, 0, 255);
|
|
15
|
+
};
|
|
16
|
+
const rgbToHsb = (red, green, blue, alpha) => {
|
|
17
|
+
const r = red / 255;
|
|
18
|
+
const g = green / 255;
|
|
19
|
+
const b = blue / 255;
|
|
20
|
+
const high = Math.max(r, g, b);
|
|
21
|
+
const low = Math.min(r, g, b);
|
|
22
|
+
const span = high - low;
|
|
23
|
+
let hue = 0;
|
|
24
|
+
if (span !== 0) {
|
|
25
|
+
if (high === r) {
|
|
26
|
+
hue = (g - b) / span + (g < b ? 6 : 0);
|
|
27
|
+
}
|
|
28
|
+
else if (high === g) {
|
|
29
|
+
hue = (b - r) / span + 2;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
hue = (r - g) / span + 4;
|
|
33
|
+
}
|
|
34
|
+
hue *= 60;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
alpha,
|
|
38
|
+
brightness: Math.round(high * 100),
|
|
39
|
+
hue: Math.round(hue),
|
|
40
|
+
saturation: Math.round(high === 0 ? 0 : (span / high) * 100),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const toRgb = (color) => {
|
|
44
|
+
const hue = (((color.hue % 360) + 360) % 360) / 60;
|
|
45
|
+
const saturation = color.saturation / 100;
|
|
46
|
+
const brightness = color.brightness / 100;
|
|
47
|
+
const chroma = brightness * saturation;
|
|
48
|
+
const middle = chroma * (1 - Math.abs((hue % 2) - 1));
|
|
49
|
+
const base = brightness - chroma;
|
|
50
|
+
const sextants = [
|
|
51
|
+
[chroma, middle, 0],
|
|
52
|
+
[middle, chroma, 0],
|
|
53
|
+
[0, chroma, middle],
|
|
54
|
+
[0, middle, chroma],
|
|
55
|
+
[middle, 0, chroma],
|
|
56
|
+
[chroma, 0, middle],
|
|
57
|
+
];
|
|
58
|
+
const [red, green, blue] = sextants[Math.floor(hue) % 6] ?? [0, 0, 0];
|
|
59
|
+
return {
|
|
60
|
+
blue: Math.round((blue + base) * 255),
|
|
61
|
+
green: Math.round((green + base) * 255),
|
|
62
|
+
red: Math.round((red + base) * 255),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const toHsl = (color) => {
|
|
66
|
+
const saturation = color.saturation / 100;
|
|
67
|
+
const brightness = color.brightness / 100;
|
|
68
|
+
const lightness = (brightness * (2 - saturation)) / 2;
|
|
69
|
+
return {
|
|
70
|
+
hue: ((color.hue % 360) + 360) % 360,
|
|
71
|
+
lightness: Math.round(lightness * 100),
|
|
72
|
+
saturation: Math.round((lightness === 0 || lightness === 1
|
|
73
|
+
? 0
|
|
74
|
+
: (brightness - lightness) / Math.min(lightness, 1 - lightness)) * 100),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
export const splitColors = (value) => {
|
|
78
|
+
const found = [];
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let current = '';
|
|
81
|
+
for (const character of value) {
|
|
82
|
+
if (character === '(') {
|
|
83
|
+
depth += 1;
|
|
84
|
+
}
|
|
85
|
+
else if (character === ')') {
|
|
86
|
+
depth = Math.max(0, depth - 1);
|
|
87
|
+
}
|
|
88
|
+
if (character === ',' && depth === 0) {
|
|
89
|
+
found.push(current.trim());
|
|
90
|
+
current = '';
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
current += character;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
found.push(current.trim());
|
|
97
|
+
return found.filter(Boolean);
|
|
98
|
+
};
|
|
99
|
+
export const parseColor = (value) => {
|
|
100
|
+
const text = value.trim().toLowerCase();
|
|
101
|
+
if (text === '') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (text.startsWith('#')) {
|
|
105
|
+
const digits = text.slice(1);
|
|
106
|
+
const short = digits.length === 3 || digits.length === 4;
|
|
107
|
+
const long = digits.length === 6 || digits.length === 8;
|
|
108
|
+
if ((!short && !long) || !/^[\da-f]+$/.test(digits)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const width = short ? 1 : 2;
|
|
112
|
+
const channel = (index) => {
|
|
113
|
+
const piece = digits.slice(index * width, index * width + width);
|
|
114
|
+
return Number.parseInt(short ? piece + piece : piece, 16);
|
|
115
|
+
};
|
|
116
|
+
const carriesAlpha = digits.length === 4 || digits.length === 8;
|
|
117
|
+
return rgbToHsb(channel(0), channel(1), channel(2), carriesAlpha ? Math.round((channel(3) / 255) * 100) : 100);
|
|
118
|
+
}
|
|
119
|
+
const call = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
|
|
120
|
+
if (!call?.[1] || call[2] === undefined) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const [first = '', second = '', third = '', fourth] = call[2]
|
|
124
|
+
.replace(/[,/]/g, ' ')
|
|
125
|
+
.split(/\s+/)
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
if (first === '' || second === '' || third === '') {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const alpha = toAlpha(fourth);
|
|
131
|
+
if (call[1].startsWith('rgb')) {
|
|
132
|
+
return rgbToHsb(toByte(first), toByte(second), toByte(third), alpha);
|
|
133
|
+
}
|
|
134
|
+
const hue = Number.parseFloat(first);
|
|
135
|
+
const saturation = clamp(Number.parseFloat(second), 0, 100) / 100;
|
|
136
|
+
const lightness = clamp(Number.parseFloat(third), 0, 100) / 100;
|
|
137
|
+
if (Number.isNaN(hue) || Number.isNaN(saturation) || Number.isNaN(lightness)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const brightness = lightness + saturation * Math.min(lightness, 1 - lightness);
|
|
141
|
+
return {
|
|
142
|
+
alpha,
|
|
143
|
+
brightness: Math.round(brightness * 100),
|
|
144
|
+
hue: Math.round(((hue % 360) + 360) % 360),
|
|
145
|
+
saturation: Math.round((brightness === 0 ? 0 : 2 * (1 - lightness / brightness)) * 100),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
export const formatColor = (color, format, withAlpha) => {
|
|
149
|
+
const alpha = clamp(color.alpha, 0, 100) / 100;
|
|
150
|
+
const opacity = Number(alpha.toFixed(2));
|
|
151
|
+
if (format === 'hsl') {
|
|
152
|
+
const { hue, lightness, saturation } = toHsl(color);
|
|
153
|
+
const body = `${Math.round(hue)}, ${saturation}%, ${lightness}%`;
|
|
154
|
+
return withAlpha ? `hsla(${body}, ${opacity})` : `hsl(${body})`;
|
|
155
|
+
}
|
|
156
|
+
const { blue, green, red } = toRgb(color);
|
|
157
|
+
if (format === 'rgb') {
|
|
158
|
+
const body = `${red}, ${green}, ${blue}`;
|
|
159
|
+
return withAlpha ? `rgba(${body}, ${opacity})` : `rgb(${body})`;
|
|
160
|
+
}
|
|
161
|
+
const byte = (one) => one.toString(16).padStart(2, '0');
|
|
162
|
+
const hex = `#${byte(red)}${byte(green)}${byte(blue)}`;
|
|
163
|
+
return withAlpha ? hex + byte(Math.round(alpha * 255)) : hex;
|
|
164
|
+
};
|
|
165
|
+
export const toCss = (color) => {
|
|
166
|
+
const { blue, green, red } = toRgb(color);
|
|
167
|
+
return `rgb(${red} ${green} ${blue} / ${clamp(color.alpha, 0, 100)}%)`;
|
|
168
|
+
};
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Everything inside `root` the reader can reach with Tab, in document order.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Roving tabindex, arrow-key navigation and "focus the first thing" all need
|
|
5
|
+
* this list, which is why it is a utility rather than part of anything that
|
|
6
|
+
* asks for it.
|
|
7
7
|
*
|
|
8
8
|
* Visibility is tested with `getClientRects()` rather than `offsetParent`,
|
|
9
|
-
* which is null for anything inside a `position: fixed` container
|
|
10
|
-
*
|
|
11
|
-
* being trapped.
|
|
9
|
+
* which is null for anything inside a `position: fixed` container, so the
|
|
10
|
+
* `offsetParent` test excluded every control on a raised surface.
|
|
12
11
|
*/
|
|
13
12
|
export declare const focusableElements: (root: HTMLElement) => HTMLElement[];
|
|
14
13
|
/**
|
|
@@ -23,13 +22,13 @@ export declare const deepActiveElement: () => Element | null;
|
|
|
23
22
|
*
|
|
24
23
|
* `Node.contains` walks `parentNode` and a shadow root's `parentNode` is null,
|
|
25
24
|
* so it answers false for anything inside a component. Paired with
|
|
26
|
-
* `deepActiveElement`, which returns exactly such a node,
|
|
27
|
-
*
|
|
25
|
+
* `deepActiveElement`, which returns exactly such a node, the pair is a hazard of
|
|
26
|
+
* its own: the two look like they belong together and one of them crosses.
|
|
28
27
|
*
|
|
29
28
|
* `contains(deepActiveElement())` is the shape to avoid: focus on a
|
|
30
29
|
* `helix-button` reports as the `<button>` in its shadow root, which is in no
|
|
31
|
-
* light-DOM container anywhere, so a
|
|
32
|
-
* as already outside
|
|
30
|
+
* light-DOM container anywhere, so a containment test written that way reads
|
|
31
|
+
* every reader as already outside.
|
|
33
32
|
*
|
|
34
33
|
* `host` is reached without naming `ShadowRoot`, which the server has no global
|
|
35
34
|
* for. This runs from a keydown handler and would never execute there, but a
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the consumer slotted, as the plain data a media element can be given.
|
|
3
|
+
*
|
|
4
|
+
* A media element runs resource selection over its own child nodes and a slot
|
|
5
|
+
* is one node, so anything wrapping `<video>` or `<audio>` in a shadow root has
|
|
6
|
+
* to copy rather than slot. The two questions that copying asks are answered
|
|
7
|
+
* here: which attributes come across, and whether a fresh read differs from the
|
|
8
|
+
* set already held. When to read, and what to do with the answer, stay with the
|
|
9
|
+
* element.
|
|
10
|
+
*
|
|
11
|
+
* A read of light DOM and nothing else: no media element, no playback state,
|
|
12
|
+
* nothing drawn, which is what makes it a utility rather than a controller.
|
|
13
|
+
*/
|
|
14
|
+
/** A `<source>` or `<track>` as the consumer wrote it, reduced to what the copy carries. */
|
|
15
|
+
export interface MediaCopy {
|
|
16
|
+
attrs: Record<string, string>;
|
|
17
|
+
tag: 'source' | 'track';
|
|
18
|
+
}
|
|
19
|
+
/** The copies to make of what the default slot is given. */
|
|
20
|
+
export declare const copiesOf: (elements: readonly Element[]) => MediaCopy[];
|
|
21
|
+
/**
|
|
22
|
+
* Whether two reads describe the same media.
|
|
23
|
+
*
|
|
24
|
+
* The same set again is not a change. A video reloads for every new list, and
|
|
25
|
+
* the first `slotchange` reports the children the element has already read.
|
|
26
|
+
*/
|
|
27
|
+
export declare const sameCopies: (one: readonly MediaCopy[], two: readonly MediaCopy[]) => boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const CARRIED = {
|
|
2
|
+
source: ['media', 'sizes', 'src', 'srcset', 'type'],
|
|
3
|
+
track: ['default', 'kind', 'label', 'src', 'srclang'],
|
|
4
|
+
};
|
|
5
|
+
export const copiesOf = (elements) => elements
|
|
6
|
+
.filter((node) => node instanceof HTMLSourceElement || node instanceof HTMLTrackElement)
|
|
7
|
+
.map((node) => {
|
|
8
|
+
const tag = node instanceof HTMLTrackElement ? 'track' : 'source';
|
|
9
|
+
const attrs = {};
|
|
10
|
+
for (const name of CARRIED[tag]) {
|
|
11
|
+
const value = node.getAttribute(name);
|
|
12
|
+
if (value !== null) {
|
|
13
|
+
attrs[name] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { attrs, tag };
|
|
17
|
+
});
|
|
18
|
+
export const sameCopies = (one, two) => JSON.stringify(one) === JSON.stringify(two);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a computed color the engine has resolved but not converted.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `css-color`, which reads the syntax a *consumer* writes and
|
|
5
|
+
* runs on a server. This reads what the engine hands back, which can be in any
|
|
6
|
+
* space the platform supports, and needs a canvas to do it.
|
|
7
|
+
*/
|
|
8
|
+
/** A color as three sRGB bytes, which is what a canvas reports and takes. */
|
|
9
|
+
export type ColorBytes = [number, number, number];
|
|
10
|
+
/**
|
|
11
|
+
* A computed color as three sRGB bytes, or nothing where it cannot be read.
|
|
12
|
+
*
|
|
13
|
+
* **Painting it is the only way to read it.** A color-typed property is
|
|
14
|
+
* resolved before it is reported, but resolved is not the same as converted:
|
|
15
|
+
* Chrome hands back `lab(100 0 0)` for a token built through `oklab`, and
|
|
16
|
+
* scraping the first three numbers out of that and calling them red, green and
|
|
17
|
+
* blue made white arrive as `rgb(100, 0, 0)`. In light mode the near dots were
|
|
18
|
+
* `lab(0 0 0)`, which scrapes to black and looks right, so only dark mode
|
|
19
|
+
* showed it: dark red mixed toward black, on a near-black ground.
|
|
20
|
+
*
|
|
21
|
+
* **Painted twice, over two different grounds, which is what makes the refusal
|
|
22
|
+
* honest.** Assigning a value the engine will not parse leaves `fillStyle`
|
|
23
|
+
* exactly where it was, so a single paint cannot tell a color apart from the
|
|
24
|
+
* ground it failed to replace. Over black and over white, a value that parsed
|
|
25
|
+
* gives the same answer twice and one that did not gives two. A caller then
|
|
26
|
+
* chooses its own fallback rather than being handed a color nobody picked,
|
|
27
|
+
* which is the half both earlier copies of this had to guess at.
|
|
28
|
+
*
|
|
29
|
+
* A readback is not free, so the answer is held against the string it came
|
|
30
|
+
* from, refusals included. The oldest goes when the store is full rather than
|
|
31
|
+
* all of them, or a page deriving colors on a slider pays for every frame
|
|
32
|
+
* twice.
|
|
33
|
+
*
|
|
34
|
+
* @param value A resolved color, as `getComputedStyle` reports one.
|
|
35
|
+
*/
|
|
36
|
+
export declare const readColor: (value: string) => ColorBytes | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
let swatch;
|
|
2
|
+
const known = new Map();
|
|
3
|
+
const KEPT = 64;
|
|
4
|
+
const painted = (context, value, under) => {
|
|
5
|
+
context.fillStyle = under;
|
|
6
|
+
context.fillStyle = value;
|
|
7
|
+
context.fillRect(0, 0, 1, 1);
|
|
8
|
+
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
|
9
|
+
return [red ?? 0, green ?? 0, blue ?? 0];
|
|
10
|
+
};
|
|
11
|
+
export const readColor = (value) => {
|
|
12
|
+
if (known.has(value)) {
|
|
13
|
+
const seen = known.get(value);
|
|
14
|
+
return seen ? [...seen] : undefined;
|
|
15
|
+
}
|
|
16
|
+
if (typeof document === 'undefined') {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (swatch === undefined) {
|
|
20
|
+
swatch = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
21
|
+
}
|
|
22
|
+
if (!swatch) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const over = painted(swatch, value, '#000000');
|
|
26
|
+
const under = painted(swatch, value, '#ffffff');
|
|
27
|
+
const read = over.every((one, at) => one === under[at]) ? over : undefined;
|
|
28
|
+
if (known.size >= KEPT) {
|
|
29
|
+
const oldest = known.keys().next().value;
|
|
30
|
+
if (oldest !== undefined) {
|
|
31
|
+
known.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
known.set(value, read);
|
|
35
|
+
return read ? [...read] : undefined;
|
|
36
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-core",
|
|
3
3
|
"description": "Helix element primitives: base classes, mixins, decorators, utilities.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.58.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@floating-ui/dom": "^1.8.0",
|
|
37
|
-
"@fusion.dev/helix-icons": "0.
|
|
37
|
+
"@fusion.dev/helix-icons": "0.58.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@custom-elements-manifest/analyzer": "^0.11.0",
|
|
41
|
-
"@fusion.dev/helix-cem": "0.
|
|
42
|
-
"@fusion.dev/helix-testing": "0.
|
|
41
|
+
"@fusion.dev/helix-cem": "0.58.0",
|
|
42
|
+
"@fusion.dev/helix-testing": "0.58.0",
|
|
43
43
|
"lit": "^3.2.1"
|
|
44
44
|
},
|
|
45
45
|
"homepage": "https://fusion.dev",
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
-
/**
|
|
3
|
-
* Keeps Tab inside a container while it is open, and gives focus back after.
|
|
4
|
-
*
|
|
5
|
-
* A dialog that does not trap focus is one a keyboard user tabs straight out
|
|
6
|
-
* of, into a page they cannot see and cannot get back from. Restoring focus on
|
|
7
|
-
* release matters just as much: without it, closing a drawer drops the reader
|
|
8
|
-
* at the top of the document.
|
|
9
|
-
*
|
|
10
|
-
* Finding the focusable elements is `focusableElements`, which is a utility
|
|
11
|
-
* rather than part of this, because that half is what roving tabindex and
|
|
12
|
-
* arrow-key navigation need and neither of them traps anything.
|
|
13
|
-
*/
|
|
14
|
-
export declare class FocusTrapController implements ReactiveController {
|
|
15
|
-
private container;
|
|
16
|
-
/** The frame waiting to take focus, so a release can call it off. */
|
|
17
|
-
private frame;
|
|
18
|
-
private previous;
|
|
19
|
-
constructor(host: ReactiveControllerHost);
|
|
20
|
-
private readonly handleKeydown;
|
|
21
|
-
/** Starts trapping, remembering where focus came from. */
|
|
22
|
-
activate(container: HTMLElement): void;
|
|
23
|
-
/** Stops trapping and returns focus to wherever it was. */
|
|
24
|
-
deactivate(): void;
|
|
25
|
-
hostDisconnected(): void;
|
|
26
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { deepActiveElement, deepContains, focusableElements, } from '../../utils/focusable-elements/focusable-elements.js';
|
|
2
|
-
export class FocusTrapController {
|
|
3
|
-
constructor(host) {
|
|
4
|
-
this.container = null;
|
|
5
|
-
this.previous = null;
|
|
6
|
-
this.handleKeydown = (event) => {
|
|
7
|
-
if (event.key !== 'Tab' || !this.container) {
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
const items = focusableElements(this.container);
|
|
11
|
-
if (items.length === 0) {
|
|
12
|
-
event.preventDefault();
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
const first = items[0];
|
|
16
|
-
const last = items.at(-1);
|
|
17
|
-
const active = deepActiveElement();
|
|
18
|
-
const at = items.findIndex((item) => item === active || deepContains(item, active));
|
|
19
|
-
if (event.shiftKey) {
|
|
20
|
-
if (at <= 0) {
|
|
21
|
-
event.preventDefault();
|
|
22
|
-
last.focus();
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
else if (at === -1 || at === items.length - 1) {
|
|
26
|
-
event.preventDefault();
|
|
27
|
-
first.focus();
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
host.addController(this);
|
|
31
|
-
}
|
|
32
|
-
activate(container) {
|
|
33
|
-
this.container = container;
|
|
34
|
-
this.previous = deepActiveElement();
|
|
35
|
-
document.addEventListener('keydown', this.handleKeydown, true);
|
|
36
|
-
this.frame = requestAnimationFrame(() => {
|
|
37
|
-
this.frame = undefined;
|
|
38
|
-
const items = focusableElements(container);
|
|
39
|
-
(items[0] ?? container).focus();
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
deactivate() {
|
|
43
|
-
if (this.frame !== undefined) {
|
|
44
|
-
cancelAnimationFrame(this.frame);
|
|
45
|
-
this.frame = undefined;
|
|
46
|
-
}
|
|
47
|
-
document.removeEventListener('keydown', this.handleKeydown, true);
|
|
48
|
-
this.container = null;
|
|
49
|
-
this.previous?.focus();
|
|
50
|
-
this.previous = null;
|
|
51
|
-
}
|
|
52
|
-
hostDisconnected() {
|
|
53
|
-
this.deactivate();
|
|
54
|
-
}
|
|
55
|
-
}
|