@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.
Files changed (25) hide show
  1. package/custom-elements.json +870 -65
  2. package/dist/controllers/cue-controller/cue-controller.d.ts +85 -0
  3. package/dist/controllers/cue-controller/cue-controller.js +45 -0
  4. package/dist/controllers/fullscreen-controller/fullscreen-controller.d.ts +32 -0
  5. package/dist/controllers/fullscreen-controller/fullscreen-controller.js +28 -0
  6. package/dist/controllers/idle-controller/idle-controller.d.ts +35 -0
  7. package/dist/controllers/idle-controller/idle-controller.js +24 -0
  8. package/dist/controllers/media-session-controller/media-session-controller.d.ts +54 -0
  9. package/dist/controllers/media-session-controller/media-session-controller.js +49 -0
  10. package/dist/controllers/remote-playback-controller/remote-playback-controller.d.ts +34 -0
  11. package/dist/controllers/remote-playback-controller/remote-playback-controller.js +38 -0
  12. package/dist/controllers/token-color-controller/token-color-controller.d.ts +3 -3
  13. package/dist/controllers/token-color-controller/token-color-controller.js +9 -13
  14. package/dist/index.d.ts +8 -1
  15. package/dist/index.js +8 -1
  16. package/dist/utils/css-color/css-color.d.ts +76 -0
  17. package/dist/utils/css-color/css-color.js +168 -0
  18. package/dist/utils/focusable-elements/focusable-elements.d.ts +9 -10
  19. package/dist/utils/media-copy/media-copy.d.ts +27 -0
  20. package/dist/utils/media-copy/media-copy.js +18 -0
  21. package/dist/utils/read-color/read-color.d.ts +36 -0
  22. package/dist/utils/read-color/read-color.js +36 -0
  23. package/package.json +4 -4
  24. package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +0 -26
  25. 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
- * Separate from the focus trap because finding is the half most callers want:
5
- * roving tabindex, arrow-key navigation and "focus the first thing" all need
6
- * this list and none of them traps anything.
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. A drawer is
10
- * exactly that, so the `offsetParent` test excluded every control in the thing
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, that pairing is a trap
27
- * of its own: the two look like they belong together and one of them crosses.
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 trap written that way reads every reader
32
- * as already outside it.
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.56.1",
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.56.1"
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.56.1",
42
- "@fusion.dev/helix-testing": "0.56.1",
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
- }