@fusion.dev/helix-core 0.215.3 → 0.217.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 (37) hide show
  1. package/custom-elements.json +1 -1
  2. package/dist/controllers/listed-controller/listed-controller.d.ts +20 -0
  3. package/dist/controllers/listed-controller/listed-controller.js +19 -0
  4. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +9 -2
  5. package/dist/controllers/virtualize-controller/virtualize-controller.js +11 -3
  6. package/dist/controllers/virtualize-controller/waterfall.d.ts +5 -0
  7. package/dist/controllers/virtualize-controller/waterfall.js +3 -0
  8. package/dist/events/before-listen.d.ts +5 -0
  9. package/dist/events/before-listen.js +10 -0
  10. package/dist/events/details.d.ts +5 -0
  11. package/dist/events/events.d.ts +6 -2
  12. package/dist/events/events.js +5 -1
  13. package/dist/events/listen.d.ts +11 -0
  14. package/dist/events/listen.js +10 -0
  15. package/dist/events/names.js +2 -2
  16. package/dist/helix-element/helix-element.js +2 -0
  17. package/dist/helix-element/mark-rules.d.ts +25 -0
  18. package/dist/helix-element/mark-rules.js +7 -0
  19. package/dist/index.d.ts +8 -2
  20. package/dist/index.js +8 -2
  21. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +22 -0
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +143 -0
  23. package/dist/mixins/mixin-voice/mixin-voice.d.ts +40 -0
  24. package/dist/mixins/mixin-voice/mixin-voice.js +198 -0
  25. package/dist/utils/controls/controls.d.ts +64 -0
  26. package/dist/utils/controls/controls.js +136 -0
  27. package/dist/utils/report-runtime/report-runtime.d.ts +2 -0
  28. package/dist/utils/sonify/sonify.d.ts +62 -0
  29. package/dist/utils/sonify/sonify.js +130 -0
  30. package/dist/utils/speech/speech.d.ts +51 -0
  31. package/dist/utils/speech/speech.js +118 -0
  32. package/dist/utils/state/state.d.ts +79 -0
  33. package/dist/utils/state/state.js +165 -0
  34. package/dist/utils/ticker/ticker.d.ts +1 -6
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +4 -4
@@ -0,0 +1,62 @@
1
+ /** One series to play: its name and its readings, a missing one as `null`. */
2
+ export interface SoundSeries {
3
+ name: string;
4
+ values: ReadonlyArray<number | null>;
5
+ }
6
+ /** One sound of a plan. */
7
+ export interface Sound {
8
+ /** Hertz, for a tone. */
9
+ frequency: number;
10
+ kind: 'tick' | 'tone';
11
+ /** Seconds. */
12
+ length: number;
13
+ /** From -1, the left ear, to 1, the right. */
14
+ pan: number;
15
+ /** Which series, which picks its timbre. */
16
+ series: number;
17
+ /** Seconds from the start. */
18
+ time: number;
19
+ }
20
+ /** The lowest and highest pitch, two octaves apart, in hertz. */
21
+ export declare const LOW = 220;
22
+ export declare const HIGH = 880;
23
+ /** The timbres, one a series, in turn. */
24
+ export declare const TIMBRES: readonly OscillatorType[];
25
+ /**
26
+ * The pitch a value is played at: two octaves across the value axis, on a log
27
+ * scale, so equal steps in value sound like equal steps in pitch.
28
+ */
29
+ export declare const pitchOf: (value: number, min: number, max: number) => number;
30
+ /** Where a reading at this place of `count` sits between the ears. */
31
+ export declare const panOf: (place: number, count: number) => number;
32
+ /** What a sweep plays, each series after the one before, over `duration` seconds apiece. */
33
+ export declare const sweepPlan: (series: readonly SoundSeries[], options: {
34
+ duration: number;
35
+ max: number;
36
+ min: number;
37
+ }) => Sound[];
38
+ /**
39
+ * Plays plans on Web Audio, one at a time.
40
+ *
41
+ * **The audio context is made on the first play**, which a page reaches only
42
+ * from a reader's press: a browser refuses to start sound that no gesture
43
+ * asked for.
44
+ */
45
+ export declare class ChartSound {
46
+ private context;
47
+ private playing;
48
+ private ending;
49
+ private finish;
50
+ /** Whether this browser can play sound at all. */
51
+ static get available(): boolean;
52
+ /** Plays a plan, stopping whatever was playing, and resolves when it ends or is stopped. */
53
+ play(plan: readonly Sound[], volume?: number): Promise<void>;
54
+ /** Stops whatever is playing. */
55
+ stop(): void;
56
+ /**
57
+ * Stops, and lets the audio context go, as a chart leaves the page: a
58
+ * browser allows a page a few contexts, and each holds the audio device.
59
+ * The next play makes another.
60
+ */
61
+ close(): void;
62
+ }
@@ -0,0 +1,130 @@
1
+ export const LOW = 220;
2
+ export const HIGH = 880;
3
+ export const TIMBRES = ['sine', 'triangle', 'square', 'sawtooth'];
4
+ export const pitchOf = (value, min, max) => {
5
+ const share = max === min ? 0.5 : Math.min(1, Math.max(0, (value - min) / (max - min)));
6
+ return LOW * (HIGH / LOW) ** share;
7
+ };
8
+ export const panOf = (place, count) => count <= 1 ? 0 : -1 + (2 * place) / (count - 1);
9
+ export const sweepPlan = (series, options) => {
10
+ const sounds = [];
11
+ const gap = 0.4;
12
+ series.forEach((one, depth) => {
13
+ const start = depth * (options.duration + gap);
14
+ const count = one.values.length;
15
+ const step = count > 1 ? options.duration / (count - 1) : 0;
16
+ const length = Math.min(0.2, Math.max(0.05, step * 0.8));
17
+ const ticks = count <= 12 ? count : 11;
18
+ for (let tick = 0; tick < ticks; tick += 1) {
19
+ const place = ticks > 1 ? tick / (ticks - 1) : 0;
20
+ sounds.push({
21
+ frequency: 0,
22
+ kind: 'tick',
23
+ length: 0.02,
24
+ pan: -1 + 2 * place,
25
+ series: depth,
26
+ time: start + place * options.duration,
27
+ });
28
+ }
29
+ one.values.forEach((value, place) => {
30
+ if (value === null || !Number.isFinite(value)) {
31
+ return;
32
+ }
33
+ sounds.push({
34
+ frequency: pitchOf(value, options.min, options.max),
35
+ kind: 'tone',
36
+ length,
37
+ pan: panOf(place, count),
38
+ series: depth,
39
+ time: start + place * step,
40
+ });
41
+ });
42
+ });
43
+ return sounds.sort((a, b) => a.time - b.time);
44
+ };
45
+ export class ChartSound {
46
+ constructor() {
47
+ this.playing = [];
48
+ }
49
+ static get available() {
50
+ return typeof globalThis.AudioContext === 'function';
51
+ }
52
+ play(plan, volume = 0.25) {
53
+ this.stop();
54
+ if (!ChartSound.available || plan.length === 0) {
55
+ return Promise.resolve();
56
+ }
57
+ this.context ??= new AudioContext();
58
+ const context = this.context;
59
+ void context.resume();
60
+ const begin = context.currentTime + 0.05;
61
+ let last = 0;
62
+ for (const sound of plan) {
63
+ const at = begin + sound.time;
64
+ last = Math.max(last, sound.time + sound.length);
65
+ const gain = context.createGain();
66
+ const pan = context.createStereoPanner();
67
+ pan.pan.value = sound.pan;
68
+ gain.connect(pan).connect(context.destination);
69
+ let source;
70
+ if (sound.kind === 'tick') {
71
+ const buffer = context.createBuffer(1, Math.ceil(context.sampleRate * sound.length), context.sampleRate);
72
+ const samples = buffer.getChannelData(0);
73
+ for (let index = 0; index < samples.length; index += 1) {
74
+ samples[index] = (Math.random() * 2 - 1) * (1 - index / samples.length);
75
+ }
76
+ const noise = context.createBufferSource();
77
+ noise.buffer = buffer;
78
+ gain.gain.value = volume * 0.25;
79
+ source = noise;
80
+ }
81
+ else {
82
+ const oscillator = context.createOscillator();
83
+ oscillator.type = TIMBRES[sound.series % TIMBRES.length] ?? 'sine';
84
+ oscillator.frequency.value = sound.frequency;
85
+ gain.gain.setValueAtTime(0, at);
86
+ gain.gain.linearRampToValueAtTime(volume, at + 0.01);
87
+ gain.gain.linearRampToValueAtTime(0, at + sound.length);
88
+ if (oscillator.type === 'square' || oscillator.type === 'sawtooth') {
89
+ const filter = context.createBiquadFilter();
90
+ filter.type = 'lowpass';
91
+ filter.frequency.value = 1800;
92
+ oscillator.connect(filter).connect(gain);
93
+ }
94
+ else {
95
+ oscillator.connect(gain);
96
+ }
97
+ source = oscillator;
98
+ }
99
+ if (sound.kind === 'tick') {
100
+ source.connect(gain);
101
+ }
102
+ source.start(at);
103
+ source.stop(at + sound.length);
104
+ this.playing.push(source);
105
+ }
106
+ return new Promise((resolve) => {
107
+ this.finish = resolve;
108
+ this.ending = setTimeout(() => this.stop(), (last + 0.1) * 1000);
109
+ });
110
+ }
111
+ stop() {
112
+ clearTimeout(this.ending);
113
+ const finish = this.finish;
114
+ this.finish = undefined;
115
+ for (const source of this.playing) {
116
+ try {
117
+ source.stop();
118
+ }
119
+ catch {
120
+ }
121
+ }
122
+ this.playing = [];
123
+ finish?.();
124
+ }
125
+ close() {
126
+ this.stop();
127
+ void this.context?.close().catch(() => { });
128
+ this.context = undefined;
129
+ }
130
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What a field listens through when a reader dictates into it.
3
+ *
4
+ * **An engine is an interface, so the browser's recognizer is one engine of
5
+ * several.** Helix ships `browserSpeech`, on the Web Speech API, and an app
6
+ * with recognizers of its own, a desktop shell with a local model or a
7
+ * service with a streaming API, registers one with `registerSpeechEngine` for
8
+ * every field on the page, or gives one field its own through `speechEngine`.
9
+ */
10
+ /** What an engine tells a field as it listens. */
11
+ export interface SpeechListener {
12
+ /** Listening stopped, because it was asked to, because the engine ended, or because of an error. */
13
+ end(reason: 'ended' | 'error' | 'stopped', message?: string): void;
14
+ /** A phrase the engine has settled on. The guess before it is replaced by it. */
15
+ final(text: string): void;
16
+ /** How loud the microphone is, from 0 to 1, with the spectrum's bands where the engine has them. */
17
+ level?(level: number, bands?: readonly number[]): void;
18
+ /** The engine's guess at the phrase being spoken, which the next guess or `final` replaces. */
19
+ partial(text: string): void;
20
+ }
21
+ /** A listening an engine started, which the field stops. */
22
+ export interface SpeechSession {
23
+ stop(): void;
24
+ }
25
+ /** A speech recognizer a field can listen through. */
26
+ export interface SpeechEngine {
27
+ /** Whether the engine can listen on this page now. A field draws no dictation button where it cannot. */
28
+ available(): boolean;
29
+ /** Starts listening in a language, a BCP 47 tag such as `en-US`, telling `listener` what it hears. */
30
+ listen(options: {
31
+ lang: string;
32
+ listener: SpeechListener;
33
+ }): SpeechSession;
34
+ }
35
+ /**
36
+ * The browser's own recognizer, `SpeechRecognition`, or `webkitSpeechRecognition`
37
+ * in Safari. Not available in Firefox.
38
+ *
39
+ * **Chrome sends the audio to Google's servers to recognize it**, and Safari
40
+ * to Apple's unless the device recognizes on the device. A page that cannot
41
+ * send what is said registers an engine of its own.
42
+ */
43
+ export declare const browserSpeech: SpeechEngine;
44
+ /**
45
+ * Gives every field on the page an engine to listen through, in place of the
46
+ * browser's own, or takes it away again with `undefined`. A field's own
47
+ * `speechEngine` wins over it.
48
+ */
49
+ export declare const registerSpeechEngine: (engine: SpeechEngine | undefined) => void;
50
+ /** The engine a field listens through where it was given none of its own. */
51
+ export declare const pageSpeechEngine: () => SpeechEngine;
@@ -0,0 +1,118 @@
1
+ const recognizer = () => {
2
+ const scope = globalThis;
3
+ return scope.SpeechRecognition ?? scope.webkitSpeechRecognition;
4
+ };
5
+ const meter = (report) => {
6
+ let stopped = false;
7
+ let release = () => { };
8
+ const devices = globalThis.navigator?.mediaDevices;
9
+ const Context = globalThis.AudioContext;
10
+ if (!devices?.getUserMedia || !Context) {
11
+ return () => { };
12
+ }
13
+ void devices
14
+ .getUserMedia({ audio: true })
15
+ .then((stream) => {
16
+ if (stopped) {
17
+ for (const track of stream.getTracks()) {
18
+ track.stop();
19
+ }
20
+ return;
21
+ }
22
+ const context = new Context();
23
+ const tap = context.createAnalyser();
24
+ tap.fftSize = 256;
25
+ context.createMediaStreamSource(stream).connect(tap);
26
+ const samples = new Uint8Array(tap.fftSize);
27
+ const spectrum = new Uint8Array(tap.frequencyBinCount);
28
+ const timer = setInterval(() => {
29
+ tap.getByteTimeDomainData(samples);
30
+ tap.getByteFrequencyData(spectrum);
31
+ let sum = 0;
32
+ for (const sample of samples) {
33
+ const centered = (sample - 128) / 128;
34
+ sum += centered * centered;
35
+ }
36
+ const level = Math.min(1, Math.sqrt(sum / samples.length) * 4);
37
+ const bands = Array.from({ length: 8 }, (_, band) => {
38
+ const from = Math.floor((band / 8) * spectrum.length);
39
+ const to = Math.floor(((band + 1) / 8) * spectrum.length);
40
+ let peak = 0;
41
+ for (let at = from; at < to; at += 1) {
42
+ peak = Math.max(peak, spectrum[at] ?? 0);
43
+ }
44
+ return peak / 255;
45
+ });
46
+ report(level, bands);
47
+ }, 50);
48
+ release = () => {
49
+ clearInterval(timer);
50
+ for (const track of stream.getTracks()) {
51
+ track.stop();
52
+ }
53
+ void context.close();
54
+ };
55
+ })
56
+ .catch(() => { });
57
+ return () => {
58
+ stopped = true;
59
+ release();
60
+ };
61
+ };
62
+ export const browserSpeech = {
63
+ available: () => recognizer() !== undefined,
64
+ listen: ({ lang, listener }) => {
65
+ const Constructor = recognizer();
66
+ if (!Constructor) {
67
+ listener.end('error', 'This browser has no speech recognition.');
68
+ return { stop: () => { } };
69
+ }
70
+ const recognition = new Constructor();
71
+ recognition.continuous = true;
72
+ recognition.interimResults = true;
73
+ recognition.lang = lang;
74
+ let asked = false;
75
+ let failed;
76
+ const quiet = meter((level, bands) => listener.level?.(level, bands));
77
+ recognition.onresult = (event) => {
78
+ let guess = '';
79
+ for (let at = event.resultIndex; at < event.results.length; at += 1) {
80
+ const result = event.results[at];
81
+ const text = result[0]?.transcript ?? '';
82
+ if (result.isFinal) {
83
+ listener.final(text.trim());
84
+ }
85
+ else {
86
+ guess += text;
87
+ }
88
+ }
89
+ if (guess !== '') {
90
+ listener.partial(guess.trim());
91
+ }
92
+ };
93
+ recognition.onerror = (event) => {
94
+ failed = event.error;
95
+ };
96
+ recognition.onend = () => {
97
+ quiet();
98
+ listener.end(failed && failed !== 'aborted' ? 'error' : asked ? 'stopped' : 'ended', failed);
99
+ };
100
+ recognition.start();
101
+ return {
102
+ stop: () => {
103
+ asked = true;
104
+ recognition.stop();
105
+ },
106
+ };
107
+ },
108
+ };
109
+ const KEY = Symbol.for('fusion.helix.speech');
110
+ const globals = globalThis;
111
+ if (globals[KEY] === undefined) {
112
+ globals[KEY] = { engine: undefined };
113
+ }
114
+ const holder = globals[KEY];
115
+ export const registerSpeechEngine = (engine) => {
116
+ holder.engine = engine;
117
+ };
118
+ export const pageSpeechEngine = () => holder.engine ?? browserSpeech;
@@ -0,0 +1,79 @@
1
+ /** What one element holds, as a snapshot keeps it. */
2
+ export interface ElementState {
3
+ /** The caret or selection in its field, start and end. */
4
+ caret?: [number, number];
5
+ checked?: boolean;
6
+ open?: boolean;
7
+ /** Where each box it scrolls is scrolled, in the order the boxes are drawn, as `[left, top]`. */
8
+ scroll?: Array<[number, number]>;
9
+ value?: string;
10
+ }
11
+ /** A screen, as `captureState` writes it. */
12
+ export interface PageState {
13
+ /** One entry per Helix element with something to keep, in document order. */
14
+ entries: Array<{
15
+ path: string;
16
+ state: ElementState;
17
+ tag: string;
18
+ }>;
19
+ /** The element focus was on, by its path. */
20
+ focus?: string;
21
+ /** The document's own scroll, `[left, top]`. */
22
+ scroll: [number, number];
23
+ version: 1;
24
+ /** The viewport the screen was captured at, `[width, height]`. */
25
+ viewport: [number, number];
26
+ }
27
+ /** What `restoreState` did. */
28
+ export interface RestoreReport {
29
+ /** How many entries were put back. */
30
+ applied: number;
31
+ /** Paths that found no element, such as one the page no longer draws. */
32
+ missed: string[];
33
+ /** Paths whose element refused a request, which the page decided. */
34
+ refused: string[];
35
+ }
36
+ /** What one Helix element holds that a snapshot keeps, or nothing where it holds none. */
37
+ export declare const snapshotOf: (element: HTMLElement) => ElementState | undefined;
38
+ /**
39
+ * Puts one element's state back, through the requests a reader's action
40
+ * makes, and returns whether every request was granted.
41
+ */
42
+ export declare const restoreOn: (element: HTMLElement, state: ElementState) => Promise<boolean>;
43
+ /**
44
+ * A path that finds an element again on the same page: its `id` where it has
45
+ * one, and otherwise its tag and its place among the elements of that tag in
46
+ * its tree, with `>>` crossing into a shadow root.
47
+ */
48
+ export declare const pathOf: (element: Element) => string;
49
+ /** The element a path names, or nothing where the page no longer draws it. */
50
+ export declare const elementAt: (path: string, root?: Document) => Element | null;
51
+ /** The screen as it is: every Helix element's state, the focus, the scroll and the viewport. */
52
+ export declare const captureState: (root?: Document) => PageState;
53
+ /**
54
+ * Puts a screen back: each entry in document order, each element waiting for
55
+ * its definition and its update so an opened overlay has drawn its rows
56
+ * before a row inside it is restored, then the focus and the scroll.
57
+ */
58
+ export declare const restoreState: (snapshot: PageState, root?: Document) => Promise<RestoreReport>;
59
+ /**
60
+ * Where a page's capture and restore are found without importing anything,
61
+ * such as by `helix visual`'s driver, which reaches a page only through its
62
+ * globals: `globalThis[Symbol.for('fusion.dev/helix/state')]`.
63
+ *
64
+ * **Empty unless asked for.** A page that exposed it by default would hand
65
+ * every script on it the text in every field and a way to drive each element.
66
+ * The page asks by calling `exposeState()`. A tool that drives a page it did
67
+ * not write, such as `helix visual` or an agent's browser, asks before the
68
+ * page loads by setting the slot to `true`, and the first copy of Helix to
69
+ * load fills it.
70
+ *
71
+ * **On `globalThis` and not on the element classes**, for the reason
72
+ * `noteVersion` gives: a static would be listed on every reference page. The
73
+ * first copy of Helix on a page keeps the slot.
74
+ */
75
+ export declare const STATE_SLOT: unique symbol;
76
+ /** Puts `captureState` and `restoreState` where a page's tools find them, once. */
77
+ export declare const exposeState: () => void;
78
+ /** Exposes the state tools where a tool set the slot to `true` before the page loaded. */
79
+ export declare const exposeStateIfAsked: () => void;
@@ -0,0 +1,165 @@
1
+ import { deepActiveElement } from '../focusable-elements/focusable-elements.js';
2
+ const isHelix = (element) => typeof element.constructor.tier === 'string';
3
+ const parentOf = (element) => element.parentElement ??
4
+ element.getRootNode().host ??
5
+ null;
6
+ const excluded = (element) => {
7
+ for (let at = element; at; at = parentOf(at)) {
8
+ if (at.getAttribute('snapshot') === 'off') {
9
+ return true;
10
+ }
11
+ }
12
+ return false;
13
+ };
14
+ const fieldOf = (element) => element.shadowRoot?.querySelector('input:not([type="hidden"]), textarea') ?? null;
15
+ const SECRET_TOKEN = /^(?:cc-.+|current-password|new-password|one-time-code)$/;
16
+ const secret = (element) => {
17
+ const field = fieldOf(element);
18
+ const type = element.type ?? field?.type;
19
+ const autocomplete = element.getAttribute('autocomplete') ?? field?.getAttribute('autocomplete') ?? '';
20
+ return (type === 'password' ||
21
+ autocomplete
22
+ .toLowerCase()
23
+ .split(/\s+/)
24
+ .some((token) => SECRET_TOKEN.test(token)));
25
+ };
26
+ export const snapshotOf = (element) => {
27
+ const stateful = element;
28
+ const state = {};
29
+ if (typeof stateful.requestOpen === 'function' && typeof stateful.open === 'boolean') {
30
+ state.open = stateful.open;
31
+ }
32
+ if (typeof stateful.requestChecked === 'function' && typeof stateful.checked === 'boolean') {
33
+ state.checked = stateful.checked;
34
+ }
35
+ if (typeof stateful.requestValue === 'function' &&
36
+ typeof stateful.value === 'string' &&
37
+ !secret(element)) {
38
+ state.value = stateful.value;
39
+ }
40
+ const boxes = [...(element.shadowRoot?.querySelectorAll('[data-scroller]') ?? [])];
41
+ if (boxes.some((box) => box.scrollTop !== 0 || box.scrollLeft !== 0)) {
42
+ state.scroll = boxes.map((box) => [box.scrollLeft, box.scrollTop]);
43
+ }
44
+ const field = fieldOf(element);
45
+ if (field && deepActiveElement() === field && field.selectionStart !== null && !secret(element)) {
46
+ state.caret = [field.selectionStart, field.selectionEnd ?? field.selectionStart];
47
+ }
48
+ return Object.keys(state).length > 0 ? state : undefined;
49
+ };
50
+ export const restoreOn = async (element, state) => {
51
+ const stateful = element;
52
+ let granted = true;
53
+ if (state.value !== undefined && stateful.value !== state.value) {
54
+ granted = (stateful.requestValue?.(state.value) ?? false) && granted;
55
+ }
56
+ if (state.checked !== undefined && stateful.checked !== state.checked) {
57
+ granted = (stateful.requestChecked?.(state.checked) ?? false) && granted;
58
+ }
59
+ if (state.open !== undefined && stateful.open !== state.open) {
60
+ granted = (stateful.requestOpen?.(state.open) ?? false) && granted;
61
+ }
62
+ await stateful.updateComplete;
63
+ const boxes = [...(element.shadowRoot?.querySelectorAll('[data-scroller]') ?? [])];
64
+ state.scroll?.forEach(([left, top], index) => {
65
+ boxes[index]?.scrollTo({ behavior: 'instant', left, top });
66
+ });
67
+ if (state.caret) {
68
+ const field = fieldOf(element);
69
+ field?.focus();
70
+ field?.setSelectionRange(state.caret[0], state.caret[1]);
71
+ }
72
+ return granted;
73
+ };
74
+ export const pathOf = (element) => {
75
+ const steps = [];
76
+ for (let at = element; at;) {
77
+ const root = at.getRootNode();
78
+ const tag = at.localName;
79
+ const id = at.id;
80
+ const unique = id !== '' && root.querySelectorAll(`#${CSS.escape(id)}`).length === 1;
81
+ steps.unshift(unique ? `#${id}` : `${tag}:${[...root.querySelectorAll(tag)].indexOf(at)}`);
82
+ at = root instanceof ShadowRoot ? root.host : null;
83
+ }
84
+ return steps.join(' >> ');
85
+ };
86
+ export const elementAt = (path, root = document) => {
87
+ let scope = root;
88
+ let found = null;
89
+ for (const step of path.split(' >> ')) {
90
+ if (!scope) {
91
+ return null;
92
+ }
93
+ if (step.startsWith('#')) {
94
+ found = scope.querySelector(`#${CSS.escape(step.slice(1))}`);
95
+ }
96
+ else {
97
+ const [tag = '', index = '0'] = step.split(':');
98
+ found = scope.querySelectorAll(tag)[Number(index)] ?? null;
99
+ }
100
+ scope = found?.shadowRoot ?? null;
101
+ }
102
+ return found;
103
+ };
104
+ const everyElement = (root) => [...root.querySelectorAll('*')].flatMap((element) => [
105
+ element,
106
+ ...(element.shadowRoot ? everyElement(element.shadowRoot) : []),
107
+ ]);
108
+ export const captureState = (root = document) => {
109
+ const entries = everyElement(root)
110
+ .filter(isHelix)
111
+ .filter((element) => !excluded(element))
112
+ .flatMap((element) => {
113
+ const state = snapshotOf(element);
114
+ return state ? [{ path: pathOf(element), state, tag: element.localName }] : [];
115
+ });
116
+ const focus = deepActiveElement();
117
+ const view = root.defaultView;
118
+ return {
119
+ entries,
120
+ ...(focus && focus !== root.body ? { focus: pathOf(focus) } : {}),
121
+ scroll: [view?.scrollX ?? 0, view?.scrollY ?? 0],
122
+ version: 1,
123
+ viewport: [view?.innerWidth ?? 0, view?.innerHeight ?? 0],
124
+ };
125
+ };
126
+ export const restoreState = async (snapshot, root = document) => {
127
+ const report = { applied: 0, missed: [], refused: [] };
128
+ for (const entry of snapshot.entries) {
129
+ await customElements.whenDefined(entry.tag);
130
+ const element = elementAt(entry.path, root);
131
+ if (!(element instanceof HTMLElement)) {
132
+ report.missed.push(entry.path);
133
+ continue;
134
+ }
135
+ await element.updateComplete;
136
+ if (await restoreOn(element, entry.state)) {
137
+ report.applied += 1;
138
+ }
139
+ else {
140
+ report.refused.push(entry.path);
141
+ }
142
+ }
143
+ if (snapshot.focus) {
144
+ const target = elementAt(snapshot.focus, root);
145
+ target?.focus?.();
146
+ }
147
+ root.defaultView?.scrollTo({
148
+ behavior: 'instant',
149
+ left: snapshot.scroll[0],
150
+ top: snapshot.scroll[1],
151
+ });
152
+ return report;
153
+ };
154
+ export const STATE_SLOT = Symbol.for('fusion.dev/helix/state');
155
+ export const exposeState = () => {
156
+ const store = globalThis;
157
+ if (typeof store[STATE_SLOT] !== 'object') {
158
+ store[STATE_SLOT] = { captureState, restoreState };
159
+ }
160
+ };
161
+ export const exposeStateIfAsked = () => {
162
+ if (globalThis[STATE_SLOT] === true) {
163
+ exposeState();
164
+ }
165
+ };
@@ -5,12 +5,7 @@
5
5
  * on the machine: a spec that subscribes at 1000ms and waits 1400 is measuring
6
6
  * how loaded the runner is, and a loaded runner eats 400ms. One did, on a green
7
7
  * branch, and blocked a merge that had nothing wrong with it. A bigger margin is
8
- * the same bug at a lower rate.
9
- *
10
- * Bun 1.1.43 is what this workspace pins, and it has `jest.useFakeTimers()`
11
- * without `jest.advanceTimersByTime`, so there is nothing to advance a faked
12
- * timer with. Measured rather than assumed: a spec asserting that function
13
- * exists throws.
8
+ * the same bug at a lower rate. A spec passes its own `now` and `setTimer`.
14
9
  */
15
10
  export interface Clock {
16
11
  clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.215.3";
9
+ export declare const VERSION = "0.217.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.215.3';
1
+ export const VERSION = '0.217.0';
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.215.3",
4
+ "version": "0.217.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -81,12 +81,12 @@
81
81
  "bugs": "https://github.com/project-fusion/fusion/issues",
82
82
  "dependencies": {
83
83
  "@floating-ui/dom": "^1.8.0",
84
- "@fusion.dev/helix-icons": "0.215.3",
84
+ "@fusion.dev/helix-icons": "0.217.0",
85
85
  "tslib": "^2.8.1"
86
86
  },
87
87
  "devDependencies": {
88
- "@fusion.dev/helix-cem": "0.215.3",
89
- "@fusion.dev/helix-testing": "0.215.3",
88
+ "@fusion.dev/helix-cem": "0.217.0",
89
+ "@fusion.dev/helix-testing": "0.217.0",
90
90
  "@lit-labs/ssr": "^4.1.0",
91
91
  "lit": "^3.2.1"
92
92
  },