@fusion.dev/helix-core 0.215.2 → 0.216.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 +1 -1
- package/dist/controllers/listed-controller/listed-controller.d.ts +20 -0
- package/dist/controllers/listed-controller/listed-controller.js +19 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +9 -2
- package/dist/controllers/virtualize-controller/virtualize-controller.js +11 -3
- package/dist/controllers/virtualize-controller/waterfall.d.ts +5 -0
- package/dist/controllers/virtualize-controller/waterfall.js +3 -0
- package/dist/events/before-listen.d.ts +5 -0
- package/dist/events/before-listen.js +10 -0
- package/dist/events/details.d.ts +5 -0
- package/dist/events/events.d.ts +6 -2
- package/dist/events/events.js +5 -1
- package/dist/events/listen.d.ts +11 -0
- package/dist/events/listen.js +10 -0
- package/dist/events/names.js +2 -2
- package/dist/helix-element/helix-element.js +2 -0
- package/dist/helix-element/mark-rules.d.ts +25 -0
- package/dist/helix-element/mark-rules.js +7 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +8 -2
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +22 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +143 -0
- package/dist/mixins/mixin-voice/mixin-voice.d.ts +40 -0
- package/dist/mixins/mixin-voice/mixin-voice.js +198 -0
- package/dist/utils/controls/controls.d.ts +64 -0
- package/dist/utils/controls/controls.js +136 -0
- package/dist/utils/report-runtime/report-runtime.d.ts +2 -0
- package/dist/utils/sonify/sonify.d.ts +62 -0
- package/dist/utils/sonify/sonify.js +130 -0
- package/dist/utils/speech/speech.d.ts +51 -0
- package/dist/utils/speech/speech.js +118 -0
- package/dist/utils/state/state.d.ts +79 -0
- package/dist/utils/state/state.js +165 -0
- package/dist/utils/ticker/ticker.d.ts +1 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { __decorate } from "tslib";
|
|
2
|
+
import { speakerHigh } from '@fusion.dev/helix-icons';
|
|
2
3
|
import { html, nothing } from 'lit';
|
|
3
4
|
import { property, state } from 'lit/decorators.js';
|
|
4
5
|
import { ChartPointerController } from '../../controllers/chart-pointer-controller/chart-pointer-controller.js';
|
|
5
6
|
import { SourceController } from '../../controllers/source-controller/source-controller.js';
|
|
7
|
+
import { drawIcon } from '../../utils/draw-icon/draw-icon.js';
|
|
8
|
+
import { keyClaimed } from '../../utils/key-claimed/key-claimed.js';
|
|
9
|
+
import { timeConverter } from '../../utils/number-converter/number-converter.js';
|
|
6
10
|
import { seriesKey } from '../../utils/read-series/read-series.js';
|
|
11
|
+
import { ChartSound, panOf, pitchOf, sweepPlan, } from '../../utils/sonify/sonify.js';
|
|
7
12
|
import { writtenList } from '../../utils/written-list/written-list.js';
|
|
8
13
|
import { formatWords, words } from '../../words/words.js';
|
|
9
14
|
export const PALETTE = 24;
|
|
@@ -19,6 +24,32 @@ export const MixinChartSource = (Base) => {
|
|
|
19
24
|
super(...arguments);
|
|
20
25
|
this.source = new SourceController(this);
|
|
21
26
|
this.pointer = new ChartPointerController(this);
|
|
27
|
+
this.playingReadings = false;
|
|
28
|
+
this.sound = new ChartSound();
|
|
29
|
+
this.toned = { at: -1, series: -1 };
|
|
30
|
+
this.handleSonifyKey = (event) => {
|
|
31
|
+
if (event.key === 'Escape' && this.playingReadings) {
|
|
32
|
+
this.stopReadings();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!this.sonify ||
|
|
36
|
+
event.defaultPrevented ||
|
|
37
|
+
keyClaimed(event) ||
|
|
38
|
+
event.altKey ||
|
|
39
|
+
event.ctrlKey ||
|
|
40
|
+
event.metaKey) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (event.key === 'l' || event.key === 'L') {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
if (this.playingReadings) {
|
|
46
|
+
this.stopReadings();
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
void this.playReadings();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
22
53
|
this.data = undefined;
|
|
23
54
|
this.for = '';
|
|
24
55
|
this.label = '';
|
|
@@ -27,9 +58,101 @@ export const MixinChartSource = (Base) => {
|
|
|
27
58
|
this.positionLabel = '{position} of {count}';
|
|
28
59
|
this.previousReadingsLabel = 'Previous readings';
|
|
29
60
|
this.readingsLabel = 'Readings {first} to {last} of {count}';
|
|
61
|
+
this.sonify = false;
|
|
62
|
+
this.sonifyDuration = 3000;
|
|
63
|
+
this.sonifyLabel = 'Listen';
|
|
64
|
+
this.sonifyStopLabel = 'Stop';
|
|
30
65
|
this.tablePage = 0;
|
|
31
66
|
}
|
|
32
67
|
static { this.words = ''; }
|
|
68
|
+
get sounding() {
|
|
69
|
+
return this.playingReadings;
|
|
70
|
+
}
|
|
71
|
+
soundSeries() {
|
|
72
|
+
const count = this.readings;
|
|
73
|
+
return this.series
|
|
74
|
+
.filter((one) => this.showsSeries(one.name))
|
|
75
|
+
.map((one) => {
|
|
76
|
+
const values = Array.from({ length: count }, () => null);
|
|
77
|
+
for (const point of one.points) {
|
|
78
|
+
values[point.at] = point.value;
|
|
79
|
+
}
|
|
80
|
+
return { name: one.name, values };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
soundRange(series) {
|
|
84
|
+
const values = series.flatMap((one) => one.values.filter((value) => value !== null));
|
|
85
|
+
return { max: Math.max(...values, 0), min: Math.min(...values, 0) };
|
|
86
|
+
}
|
|
87
|
+
async playReadings() {
|
|
88
|
+
const series = this.soundSeries();
|
|
89
|
+
if (series.length === 0) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.playingReadings = true;
|
|
93
|
+
await this.sound.play(sweepPlan(series, { duration: this.sonifyDuration / 1000, ...this.soundRange(series) }));
|
|
94
|
+
this.playingReadings = false;
|
|
95
|
+
}
|
|
96
|
+
stopReadings() {
|
|
97
|
+
this.sound.stop();
|
|
98
|
+
this.playingReadings = false;
|
|
99
|
+
}
|
|
100
|
+
connectedCallback() {
|
|
101
|
+
super.connectedCallback();
|
|
102
|
+
this.addEventListener('keydown', this.handleSonifyKey);
|
|
103
|
+
}
|
|
104
|
+
disconnectedCallback() {
|
|
105
|
+
super.disconnectedCallback();
|
|
106
|
+
this.removeEventListener('keydown', this.handleSonifyKey);
|
|
107
|
+
this.stopReadings();
|
|
108
|
+
this.sound.close();
|
|
109
|
+
}
|
|
110
|
+
updated(changed) {
|
|
111
|
+
super.updated(changed);
|
|
112
|
+
const { at, keyed, series } = this.pointer;
|
|
113
|
+
if (!this.sonify ||
|
|
114
|
+
!keyed ||
|
|
115
|
+
at < 0 ||
|
|
116
|
+
(at === this.toned.at && series === this.toned.series)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.toned = { at, series };
|
|
120
|
+
const all = this.soundSeries();
|
|
121
|
+
const one = all[Math.max(0, series)];
|
|
122
|
+
const value = one?.values[at];
|
|
123
|
+
if (one === undefined || value === null || value === undefined) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const range = this.soundRange(all);
|
|
127
|
+
void this.sound.play([
|
|
128
|
+
{
|
|
129
|
+
frequency: pitchOf(value, range.min, range.max),
|
|
130
|
+
kind: 'tone',
|
|
131
|
+
length: 0.18,
|
|
132
|
+
pan: panOf(at, this.readings),
|
|
133
|
+
series: Math.max(0, series),
|
|
134
|
+
time: 0,
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
renderSonify() {
|
|
139
|
+
if (!this.sonify || !this.readingsKnown || !ChartSound.available) {
|
|
140
|
+
return nothing;
|
|
141
|
+
}
|
|
142
|
+
return html `<button
|
|
143
|
+
part="sonify"
|
|
144
|
+
type="button"
|
|
145
|
+
aria-pressed=${this.playingReadings ? 'true' : 'false'}
|
|
146
|
+
@click=${() => {
|
|
147
|
+
if (this.playingReadings) {
|
|
148
|
+
this.stopReadings();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
void this.playReadings();
|
|
152
|
+
}
|
|
153
|
+
}}
|
|
154
|
+
>${drawIcon(speakerHigh)}<span>${this.playingReadings ? this.sonifyStopLabel : this.sonifyLabel}</span></button>`;
|
|
155
|
+
}
|
|
33
156
|
static get observedAttributes() {
|
|
34
157
|
const inherited = Reflect.get(Base, 'observedAttributes', this);
|
|
35
158
|
return [...(inherited ?? []), 'aria-label'];
|
|
@@ -146,6 +269,9 @@ export const MixinChartSource = (Base) => {
|
|
|
146
269
|
</div>`;
|
|
147
270
|
}
|
|
148
271
|
}
|
|
272
|
+
__decorate([
|
|
273
|
+
state()
|
|
274
|
+
], ChartSourceElement.prototype, "playingReadings", void 0);
|
|
149
275
|
__decorate([
|
|
150
276
|
property({ attribute: false })
|
|
151
277
|
], ChartSourceElement.prototype, "data", void 0);
|
|
@@ -174,6 +300,23 @@ export const MixinChartSource = (Base) => {
|
|
|
174
300
|
words,
|
|
175
301
|
property({ attribute: 'readings-label' })
|
|
176
302
|
], ChartSourceElement.prototype, "readingsLabel", void 0);
|
|
303
|
+
__decorate([
|
|
304
|
+
property({ reflect: true, type: Boolean })
|
|
305
|
+
], ChartSourceElement.prototype, "sonify", void 0);
|
|
306
|
+
__decorate([
|
|
307
|
+
property({ attribute: 'sonify-duration', converter: timeConverter('sonify-duration', 3000) })
|
|
308
|
+
], ChartSourceElement.prototype, "sonifyDuration", void 0);
|
|
309
|
+
__decorate([
|
|
310
|
+
words,
|
|
311
|
+
property({ attribute: 'sonify-label' })
|
|
312
|
+
], ChartSourceElement.prototype, "sonifyLabel", void 0);
|
|
313
|
+
__decorate([
|
|
314
|
+
words,
|
|
315
|
+
property({ attribute: 'sonify-stop-label' })
|
|
316
|
+
], ChartSourceElement.prototype, "sonifyStopLabel", void 0);
|
|
317
|
+
__decorate([
|
|
318
|
+
property({ reflect: true })
|
|
319
|
+
], ChartSourceElement.prototype, "summary", void 0);
|
|
177
320
|
__decorate([
|
|
178
321
|
state()
|
|
179
322
|
], ChartSourceElement.prototype, "tablePage", void 0);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { nothing, type TemplateResult } from 'lit';
|
|
2
|
+
import type { HelixElement } from '../../helix-element/helix-element.js';
|
|
3
|
+
import { type SpeechEngine } from '../../utils/speech/speech.js';
|
|
4
|
+
type Constructor<T> = new (...args: any[]) => T;
|
|
5
|
+
export declare class VoiceElement {
|
|
6
|
+
/** Whether the field is listening now. */
|
|
7
|
+
readonly listening: boolean;
|
|
8
|
+
listeningLabel: string;
|
|
9
|
+
speechEngine: SpeechEngine | undefined;
|
|
10
|
+
stoppedListeningLabel: string;
|
|
11
|
+
voice: boolean;
|
|
12
|
+
voiceLabel: string;
|
|
13
|
+
voiceStopLabel: string;
|
|
14
|
+
/** Starts listening, and returns whether it did. */
|
|
15
|
+
startListening(): boolean;
|
|
16
|
+
/** Stops listening, keeping what was heard. */
|
|
17
|
+
stopListening(): void;
|
|
18
|
+
/** The dictation button and its status, behind a seam a tier can move. */
|
|
19
|
+
protected renderVoice(): TemplateResult | typeof nothing;
|
|
20
|
+
/** The native field dictation types into. */
|
|
21
|
+
protected voiceField(): HTMLInputElement | HTMLTextAreaElement | null;
|
|
22
|
+
/** The type of the field dictation types into. Dictation listens only in `text`, `search`, `tel` and `url`. */
|
|
23
|
+
protected get voiceType(): string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Adds dictation to a field: `voice` draws a button that listens through the
|
|
27
|
+
* page's speech engine and types what it hears at the caret.
|
|
28
|
+
*
|
|
29
|
+
* **What is heard goes in through the field's own `input` event.** A guess is
|
|
30
|
+
* written where the caret was and replaced by each better guess, and the
|
|
31
|
+
* settled phrase replaces the last guess, each an `input` like a key press, so
|
|
32
|
+
* a field's value, its validation and a framework's state follow it with no
|
|
33
|
+
* code of their own. A space is put before a phrase that does not start with
|
|
34
|
+
* punctuation when the text before it does not end in one.
|
|
35
|
+
*
|
|
36
|
+
* The button is drawn only once the page has been read, since a server cannot
|
|
37
|
+
* ask whether the browser has an engine, and only where it has one.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MixinVoice: <T extends Constructor<HelixElement>>(Base: T) => Constructor<VoiceElement> & T;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { __decorate } from "tslib";
|
|
2
|
+
import { microphone } from '@fusion.dev/helix-icons';
|
|
3
|
+
import { html, nothing } from 'lit';
|
|
4
|
+
import { property, state } from 'lit/decorators.js';
|
|
5
|
+
import { HelixBeforeListenEvent, HelixListenEvent } from '../../events/events.js';
|
|
6
|
+
import { drawIcon } from '../../utils/draw-icon/draw-icon.js';
|
|
7
|
+
import { pageSpeechEngine, } from '../../utils/speech/speech.js';
|
|
8
|
+
import { words } from '../../words/words.js';
|
|
9
|
+
import { wordsLocale } from '../../words/words-locale.js';
|
|
10
|
+
const JOINS = /^[\s.,!?;:)\]}]/;
|
|
11
|
+
const DICTATED = new Set(['search', 'tel', 'text', 'url']);
|
|
12
|
+
const HELD = new Set(['Alt', 'CapsLock', 'Control', 'Meta', 'Shift']);
|
|
13
|
+
export const MixinVoice = (Base) => {
|
|
14
|
+
class Voice extends Base {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(...arguments);
|
|
17
|
+
this.listeningLabel = 'Listening';
|
|
18
|
+
this.speechEngine = undefined;
|
|
19
|
+
this.stoppedListeningLabel = 'Stopped listening';
|
|
20
|
+
this.voice = false;
|
|
21
|
+
this.voiceLabel = 'Dictate';
|
|
22
|
+
this.voiceStopLabel = 'Stop dictation';
|
|
23
|
+
this.heard = false;
|
|
24
|
+
this.said = '';
|
|
25
|
+
this.guess = [0, 0];
|
|
26
|
+
this.handleVoiceKey = (event) => {
|
|
27
|
+
const { key } = event;
|
|
28
|
+
if (!this.heard || HELD.has(key)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key === 'Escape') {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
event.stopPropagation();
|
|
34
|
+
}
|
|
35
|
+
this.stopListening();
|
|
36
|
+
};
|
|
37
|
+
this.handleVoicePointer = () => {
|
|
38
|
+
this.stopListening();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
static { this.words = ''; }
|
|
42
|
+
get listening() {
|
|
43
|
+
return this.heard;
|
|
44
|
+
}
|
|
45
|
+
voiceField() {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
get engine() {
|
|
49
|
+
return this.speechEngine ?? pageSpeechEngine();
|
|
50
|
+
}
|
|
51
|
+
get voiceType() {
|
|
52
|
+
return 'text';
|
|
53
|
+
}
|
|
54
|
+
get voiceReady() {
|
|
55
|
+
return (DICTATED.has(this.voiceType) &&
|
|
56
|
+
this.childrenKnown &&
|
|
57
|
+
this.engine.available());
|
|
58
|
+
}
|
|
59
|
+
firstUpdated(changed) {
|
|
60
|
+
super.firstUpdated(changed);
|
|
61
|
+
if (this.voice) {
|
|
62
|
+
this.finishHydration();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
updated(changed) {
|
|
66
|
+
super.updated(changed);
|
|
67
|
+
if ((changed.has('voice') && !this.voice) || (this.heard && !this.voiceReady)) {
|
|
68
|
+
this.stopListening();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
disconnectedCallback() {
|
|
72
|
+
super.disconnectedCallback();
|
|
73
|
+
this.stopListening();
|
|
74
|
+
}
|
|
75
|
+
startListening() {
|
|
76
|
+
if (this.heard) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
const field = this.voiceField();
|
|
80
|
+
if (!field || !this.voiceReady || !this.dispatchEvent(new HelixBeforeListenEvent())) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
this.guess = [
|
|
84
|
+
field.selectionStart ?? field.value.length,
|
|
85
|
+
field.selectionEnd ?? field.value.length,
|
|
86
|
+
];
|
|
87
|
+
this.heard = true;
|
|
88
|
+
this.said = this.listeningLabel;
|
|
89
|
+
this.toggleState('listening', true);
|
|
90
|
+
field.addEventListener('keydown', this.handleVoiceKey);
|
|
91
|
+
field.addEventListener('pointerdown', this.handleVoicePointer);
|
|
92
|
+
this.session = this.engine.listen({
|
|
93
|
+
lang: wordsLocale(this),
|
|
94
|
+
listener: {
|
|
95
|
+
end: () => this.ended(),
|
|
96
|
+
final: (text) => this.place(text, true),
|
|
97
|
+
level: (level) => {
|
|
98
|
+
this.style.setProperty('--dictation-level', level.toFixed(3));
|
|
99
|
+
},
|
|
100
|
+
partial: (text) => this.place(text, false),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
stopListening() {
|
|
106
|
+
this.session?.stop();
|
|
107
|
+
this.ended();
|
|
108
|
+
}
|
|
109
|
+
ended() {
|
|
110
|
+
if (!this.heard) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.session = undefined;
|
|
114
|
+
this.heard = false;
|
|
115
|
+
this.said = this.stoppedListeningLabel;
|
|
116
|
+
this.style.removeProperty('--dictation-level');
|
|
117
|
+
this.toggleState('listening', false);
|
|
118
|
+
this.voiceField()?.removeEventListener('keydown', this.handleVoiceKey);
|
|
119
|
+
this.voiceField()?.removeEventListener('pointerdown', this.handleVoicePointer);
|
|
120
|
+
}
|
|
121
|
+
place(text, settled) {
|
|
122
|
+
const field = this.voiceField();
|
|
123
|
+
if (!field || !this.heard) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const [start, end] = this.guess;
|
|
127
|
+
const before = field.value.slice(0, start);
|
|
128
|
+
const spaced = text !== '' && before !== '' && !/\s$/.test(before) && !JOINS.test(text) ? ` ${text}` : text;
|
|
129
|
+
field.setRangeText(spaced, start, end, 'end');
|
|
130
|
+
this.guess = settled
|
|
131
|
+
? [start + spaced.length, start + spaced.length]
|
|
132
|
+
: [start, start + spaced.length];
|
|
133
|
+
field.dispatchEvent(new InputEvent('input', {
|
|
134
|
+
bubbles: true,
|
|
135
|
+
composed: true,
|
|
136
|
+
data: spaced,
|
|
137
|
+
inputType: 'insertText',
|
|
138
|
+
}));
|
|
139
|
+
if (settled && text !== '') {
|
|
140
|
+
this.dispatchEvent(new HelixListenEvent({ text }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
renderVoice() {
|
|
144
|
+
if (!this.voice || !this.voiceReady) {
|
|
145
|
+
return nothing;
|
|
146
|
+
}
|
|
147
|
+
const off = this.isDisabled === true;
|
|
148
|
+
const readOnly = this.readOnly === true;
|
|
149
|
+
return html `<button
|
|
150
|
+
part="voice${this.heard ? ' voice-listening' : ''}"
|
|
151
|
+
type="button"
|
|
152
|
+
aria-label=${this.heard ? this.voiceStopLabel : this.voiceLabel}
|
|
153
|
+
aria-pressed=${this.heard ? 'true' : 'false'}
|
|
154
|
+
?disabled=${off || readOnly}
|
|
155
|
+
@click=${() => {
|
|
156
|
+
if (this.heard) {
|
|
157
|
+
this.stopListening();
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
this.startListening();
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
${drawIcon(microphone)}
|
|
165
|
+
</button
|
|
166
|
+
><span part="voice-status" data-spoken role="status">${this.said}</span>`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
__decorate([
|
|
170
|
+
words,
|
|
171
|
+
property({ attribute: 'listening-label' })
|
|
172
|
+
], Voice.prototype, "listeningLabel", void 0);
|
|
173
|
+
__decorate([
|
|
174
|
+
property({ attribute: false })
|
|
175
|
+
], Voice.prototype, "speechEngine", void 0);
|
|
176
|
+
__decorate([
|
|
177
|
+
words,
|
|
178
|
+
property({ attribute: 'stopped-listening-label' })
|
|
179
|
+
], Voice.prototype, "stoppedListeningLabel", void 0);
|
|
180
|
+
__decorate([
|
|
181
|
+
property({ type: Boolean })
|
|
182
|
+
], Voice.prototype, "voice", void 0);
|
|
183
|
+
__decorate([
|
|
184
|
+
words,
|
|
185
|
+
property({ attribute: 'voice-label' })
|
|
186
|
+
], Voice.prototype, "voiceLabel", void 0);
|
|
187
|
+
__decorate([
|
|
188
|
+
words,
|
|
189
|
+
property({ attribute: 'voice-stop-label' })
|
|
190
|
+
], Voice.prototype, "voiceStopLabel", void 0);
|
|
191
|
+
__decorate([
|
|
192
|
+
state()
|
|
193
|
+
], Voice.prototype, "heard", void 0);
|
|
194
|
+
__decorate([
|
|
195
|
+
state()
|
|
196
|
+
], Voice.prototype, "said", void 0);
|
|
197
|
+
return Voice;
|
|
198
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** What running a listed control does: press it, or move focus into it. */
|
|
2
|
+
export type ControlKind = 'focus' | 'press';
|
|
3
|
+
/** A control on the page, as it registers itself. */
|
|
4
|
+
export interface ControlEntry {
|
|
5
|
+
element: HTMLElement;
|
|
6
|
+
kind: ControlKind;
|
|
7
|
+
/**
|
|
8
|
+
* The shortcuts the control answers, as `aria-keyshortcuts` spells them,
|
|
9
|
+
* where they are not on its `aria-keyshortcuts` attribute, such as a value
|
|
10
|
+
* written through `ElementInternals`.
|
|
11
|
+
*/
|
|
12
|
+
keys?: (() => string | null | undefined) | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** A shortcut that belongs to the page or a region rather than to one control, such as ⌘K for a palette. */
|
|
15
|
+
export interface ShortcutEntry {
|
|
16
|
+
/** The chord, as Kbd writes it: `mod+k`, `F8`. Read when the list is asked for. */
|
|
17
|
+
keys: () => string;
|
|
18
|
+
/** What the shortcut does, such as "Open the command palette". */
|
|
19
|
+
label: () => string;
|
|
20
|
+
/** The element it belongs to, which has to be connected for the shortcut to be listed. */
|
|
21
|
+
owner: Element;
|
|
22
|
+
/** The control the shortcut acts on, where it acts on one, so a list can name it once. */
|
|
23
|
+
target?: Element | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** A control as a list of what is on screen gives it. */
|
|
26
|
+
export interface ListedControl {
|
|
27
|
+
element: HTMLElement;
|
|
28
|
+
/** The name of the nearest labeled region around it, or empty. */
|
|
29
|
+
group: string;
|
|
30
|
+
/** Its shortcuts as chords, `mod+k` style, each one written once. */
|
|
31
|
+
keys: string[];
|
|
32
|
+
kind: ControlKind;
|
|
33
|
+
name: string;
|
|
34
|
+
}
|
|
35
|
+
/** Adds a control to the page's list. */
|
|
36
|
+
export declare const registerControl: (entry: ControlEntry) => void;
|
|
37
|
+
/** Takes a control off the page's list. */
|
|
38
|
+
export declare const unregisterControl: (element: HTMLElement) => void;
|
|
39
|
+
/** Adds a shortcut that belongs to no one control, and returns what takes it away again. */
|
|
40
|
+
export declare const registerShortcut: (entry: ShortcutEntry) => (() => void);
|
|
41
|
+
/** One `aria-keyshortcuts` chord, `Meta+Shift+K`, as Kbd's grammar writes it: `meta+shift+k`. */
|
|
42
|
+
export declare const chordOfAria: (aria: string) => string;
|
|
43
|
+
/**
|
|
44
|
+
* The name a reader is given for a control: its `label`, then `aria-label`,
|
|
45
|
+
* then the text `aria-labelledby` points at, then its own content.
|
|
46
|
+
*/
|
|
47
|
+
export declare const controlName: (element: HTMLElement) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a control is one a reader can reach now: drawn, not disabled, and
|
|
50
|
+
* not inside anything inert or `unlisted`. `unlisted` on the control or on any
|
|
51
|
+
* element around it, across shadow roots, leaves it off every list.
|
|
52
|
+
*/
|
|
53
|
+
export declare const controlReachable: (element: HTMLElement) => boolean;
|
|
54
|
+
/** Every control a reader can reach now, in document order, with its name, keys and group. */
|
|
55
|
+
export declare const listControls: () => ListedControl[];
|
|
56
|
+
/** Every shortcut that belongs to no one control, whose owner is on the page. */
|
|
57
|
+
export declare const listShortcuts: () => Array<{
|
|
58
|
+
keys: string;
|
|
59
|
+
label: string;
|
|
60
|
+
owner: Element;
|
|
61
|
+
target?: Element | undefined;
|
|
62
|
+
}>;
|
|
63
|
+
/** Runs a listed control as a reader would: brings it into view, then presses it or focuses it. */
|
|
64
|
+
export declare const runControl: (control: Pick<ListedControl, 'element' | 'kind'>) => void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { nameText } from '../name-text/name-text.js';
|
|
2
|
+
const KEY = Symbol.for('fusion.helix.controls');
|
|
3
|
+
const globals = globalThis;
|
|
4
|
+
if (globals[KEY] === undefined) {
|
|
5
|
+
globals[KEY] = { controls: new Map(), shortcuts: new Set() };
|
|
6
|
+
}
|
|
7
|
+
const registry = globals[KEY];
|
|
8
|
+
export const registerControl = (entry) => {
|
|
9
|
+
registry.controls.set(entry.element, entry);
|
|
10
|
+
};
|
|
11
|
+
export const unregisterControl = (element) => {
|
|
12
|
+
registry.controls.delete(element);
|
|
13
|
+
};
|
|
14
|
+
export const registerShortcut = (entry) => {
|
|
15
|
+
registry.shortcuts.add(entry);
|
|
16
|
+
return () => {
|
|
17
|
+
registry.shortcuts.delete(entry);
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
const CHORD = {
|
|
21
|
+
' ': 'space',
|
|
22
|
+
arrowdown: 'down',
|
|
23
|
+
arrowleft: 'left',
|
|
24
|
+
arrowright: 'right',
|
|
25
|
+
arrowup: 'up',
|
|
26
|
+
control: 'ctrl',
|
|
27
|
+
delete: 'del',
|
|
28
|
+
escape: 'esc',
|
|
29
|
+
meta: 'meta',
|
|
30
|
+
};
|
|
31
|
+
export const chordOfAria = (aria) => aria
|
|
32
|
+
.split('+')
|
|
33
|
+
.map((part) => {
|
|
34
|
+
const name = part.trim().toLowerCase();
|
|
35
|
+
return CHORD[name] ?? name;
|
|
36
|
+
})
|
|
37
|
+
.join('+');
|
|
38
|
+
const parentOf = (element) => element.parentElement ??
|
|
39
|
+
element.getRootNode().host ??
|
|
40
|
+
null;
|
|
41
|
+
export const controlName = (element) => {
|
|
42
|
+
const own = element.label;
|
|
43
|
+
if (typeof own === 'string' && own.trim() !== '') {
|
|
44
|
+
return own.trim();
|
|
45
|
+
}
|
|
46
|
+
const label = element.getAttribute('aria-label');
|
|
47
|
+
if (label?.trim()) {
|
|
48
|
+
return label.trim();
|
|
49
|
+
}
|
|
50
|
+
const ids = element.getAttribute('aria-labelledby');
|
|
51
|
+
if (ids) {
|
|
52
|
+
const scope = element.getRootNode();
|
|
53
|
+
const words = ids
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.map((id) => scope.getElementById?.(id)?.textContent ?? '')
|
|
56
|
+
.join(' ')
|
|
57
|
+
.trim();
|
|
58
|
+
if (words !== '') {
|
|
59
|
+
return words;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return nameText(element).replace(/\s+/g, ' ').trim();
|
|
63
|
+
};
|
|
64
|
+
const groupOf = (element) => {
|
|
65
|
+
for (let at = parentOf(element); at; at = parentOf(at)) {
|
|
66
|
+
const label = at.getAttribute('aria-label') ?? at.label;
|
|
67
|
+
if (typeof label === 'string' && label.trim() !== '' && at.localName !== 'helix-command') {
|
|
68
|
+
return label.trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
};
|
|
73
|
+
export const controlReachable = (element) => {
|
|
74
|
+
if (!element.isConnected) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (element.disabled === true ||
|
|
78
|
+
element.getAttribute('aria-disabled') === 'true') {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
for (let at = element; at; at = parentOf(at)) {
|
|
82
|
+
if (at.hasAttribute('unlisted') ||
|
|
83
|
+
at.hasAttribute('inert') ||
|
|
84
|
+
at.inert === true) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return typeof element.checkVisibility === 'function'
|
|
89
|
+
? element.checkVisibility({ visibilityProperty: true })
|
|
90
|
+
: true;
|
|
91
|
+
};
|
|
92
|
+
const keysOf = (entry) => {
|
|
93
|
+
const written = [entry.element.getAttribute('aria-keyshortcuts'), entry.keys?.()]
|
|
94
|
+
.filter((list) => typeof list === 'string')
|
|
95
|
+
.flatMap((list) => list.split(/\s+/))
|
|
96
|
+
.filter((one) => one !== '')
|
|
97
|
+
.map(chordOfAria);
|
|
98
|
+
return [...new Set(written)];
|
|
99
|
+
};
|
|
100
|
+
export const listControls = () => {
|
|
101
|
+
const listed = [...registry.controls.values()]
|
|
102
|
+
.filter((entry) => controlReachable(entry.element))
|
|
103
|
+
.map((entry) => ({
|
|
104
|
+
element: entry.element,
|
|
105
|
+
group: groupOf(entry.element),
|
|
106
|
+
keys: keysOf(entry),
|
|
107
|
+
kind: entry.kind,
|
|
108
|
+
name: controlName(entry.element),
|
|
109
|
+
}))
|
|
110
|
+
.filter((control) => control.name !== '');
|
|
111
|
+
return listed.sort((a, b) => {
|
|
112
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
113
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING
|
|
114
|
+
? -1
|
|
115
|
+
: position & Node.DOCUMENT_POSITION_PRECEDING
|
|
116
|
+
? 1
|
|
117
|
+
: 0;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
export const listShortcuts = () => [...registry.shortcuts]
|
|
121
|
+
.filter((entry) => entry.owner.isConnected)
|
|
122
|
+
.map((entry) => ({
|
|
123
|
+
keys: entry.keys(),
|
|
124
|
+
label: entry.label(),
|
|
125
|
+
owner: entry.owner,
|
|
126
|
+
target: entry.target,
|
|
127
|
+
}))
|
|
128
|
+
.filter((entry) => entry.keys !== '');
|
|
129
|
+
export const runControl = (control) => {
|
|
130
|
+
control.element.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
131
|
+
if (control.kind === 'focus') {
|
|
132
|
+
control.element.focus();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
control.element.click();
|
|
136
|
+
};
|
|
@@ -85,6 +85,8 @@ export type RuntimeKind =
|
|
|
85
85
|
* Development builds only.
|
|
86
86
|
*/
|
|
87
87
|
| 'solid-handler-syntax'
|
|
88
|
+
/** A page's copy of the sandbox's compiler worker file is from another release. */
|
|
89
|
+
| 'stale-worker'
|
|
88
90
|
/** A stencil's regions went unused: no named parts, or parts too small. */
|
|
89
91
|
| 'stencil-regions-unused'
|
|
90
92
|
/** A stencil has no inside to place the notes in. */
|