@lightningtv/solid 0.0.2 → 0.0.4
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/dist/esm/index.js +466 -5
- package/dist/esm/index.js.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/primitives/announcer/announcer.js +121 -0
- package/dist/source/primitives/announcer/index.js +8 -0
- package/dist/source/primitives/announcer/speech.js +152 -0
- package/dist/source/primitives/createInfiniteItems.js +45 -0
- package/dist/source/primitives/createSpriteMap.js +17 -0
- package/dist/source/primitives/index.js +5 -0
- package/dist/source/primitives/useFocusManager.js +88 -0
- package/dist/source/primitives/withPadding.js +48 -0
- package/dist/source/solidOpts.js +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/primitives/announcer/announcer.d.ts +37 -0
- package/dist/types/primitives/announcer/index.d.ts +2 -0
- package/dist/types/primitives/announcer/speech.d.ts +10 -0
- package/dist/types/primitives/createInfiniteItems.d.ts +27 -0
- package/dist/types/primitives/createSpriteMap.d.ts +8 -0
- package/dist/types/primitives/index.d.ts +5 -0
- package/dist/types/primitives/useFocusManager.d.ts +46 -0
- package/dist/types/primitives/withPadding.d.ts +3 -0
- package/package.json +5 -2
- package/src/index.ts +1 -0
- package/src/primitives/announcer/announcer.ts +190 -0
- package/src/primitives/announcer/index.ts +10 -0
- package/src/primitives/announcer/speech.ts +174 -0
- package/src/primitives/createInfiniteItems.ts +66 -0
- package/src/primitives/createSpriteMap.ts +31 -0
- package/src/primitives/index.ts +5 -0
- package/src/primitives/jsx-runtime.d.ts +11 -0
- package/src/primitives/useFocusManager.ts +194 -0
- package/src/primitives/withPadding.ts +56 -0
- package/src/solidOpts.ts +2 -2
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { renderer } from '@lightningtv/solid';
|
|
2
|
+
export function createSpriteMap(src, subTextures) {
|
|
3
|
+
const spriteMapTexture = renderer.createTexture('ImageTexture', {
|
|
4
|
+
src,
|
|
5
|
+
});
|
|
6
|
+
return subTextures.reduce((acc, t) => {
|
|
7
|
+
const { x, y, width, height } = t;
|
|
8
|
+
acc[t.name] = renderer.createTexture('SubTexture', {
|
|
9
|
+
texture: spriteMapTexture,
|
|
10
|
+
x,
|
|
11
|
+
y,
|
|
12
|
+
width,
|
|
13
|
+
height,
|
|
14
|
+
});
|
|
15
|
+
return acc;
|
|
16
|
+
}, {});
|
|
17
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createEffect, on, createSignal, untrack } from 'solid-js';
|
|
2
|
+
import { useKeyDownEvent } from '@solid-primitives/keyboard';
|
|
3
|
+
import { activeElement, isFunc, isArray, } from '@lightningtv/solid';
|
|
4
|
+
const keyMapEntries = {
|
|
5
|
+
ArrowLeft: 'Left',
|
|
6
|
+
ArrowRight: 'Right',
|
|
7
|
+
ArrowUp: 'Up',
|
|
8
|
+
ArrowDown: 'Down',
|
|
9
|
+
Enter: 'Enter',
|
|
10
|
+
l: 'Last',
|
|
11
|
+
' ': 'Space',
|
|
12
|
+
Backspace: 'Back',
|
|
13
|
+
Escape: 'Escape',
|
|
14
|
+
};
|
|
15
|
+
const [focusPath, setFocusPath] = createSignal([]);
|
|
16
|
+
export { focusPath };
|
|
17
|
+
export const useFocusManager = (userKeyMap) => {
|
|
18
|
+
const keypressEvent = useKeyDownEvent();
|
|
19
|
+
if (userKeyMap) {
|
|
20
|
+
// Flatten the userKeyMap to a hash
|
|
21
|
+
for (const [key, value] of Object.entries(userKeyMap)) {
|
|
22
|
+
if (isArray(value)) {
|
|
23
|
+
value.forEach((v) => {
|
|
24
|
+
keyMapEntries[v] = key;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
keyMapEntries[value] = key;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
createEffect(on(activeElement, (currentFocusedElm, prevFocusedElm, prevFocusPath = []) => {
|
|
33
|
+
const newFocusedElms = [];
|
|
34
|
+
let current = currentFocusedElm;
|
|
35
|
+
const fp = [];
|
|
36
|
+
while (current) {
|
|
37
|
+
if (!current.states.has('focus')) {
|
|
38
|
+
current.states.add('focus');
|
|
39
|
+
isFunc(current.onFocus) &&
|
|
40
|
+
current.onFocus.call(current, currentFocusedElm, prevFocusedElm);
|
|
41
|
+
newFocusedElms.push(current);
|
|
42
|
+
}
|
|
43
|
+
fp.push(current);
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
prevFocusPath.forEach((elm) => {
|
|
47
|
+
if (!fp.includes(elm)) {
|
|
48
|
+
elm.states.remove('focus');
|
|
49
|
+
isFunc(elm.onBlur) &&
|
|
50
|
+
elm.onBlur.call(elm, currentFocusedElm, prevFocusedElm);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
setFocusPath(fp);
|
|
54
|
+
return fp;
|
|
55
|
+
}, { defer: true }));
|
|
56
|
+
createEffect(() => {
|
|
57
|
+
const e = keypressEvent();
|
|
58
|
+
if (e) {
|
|
59
|
+
// Search keyMap for the value of the pressed key or keyCode if value undefined
|
|
60
|
+
const mappedKeyEvent = keyMapEntries[e.key] || keyMapEntries[e.keyCode];
|
|
61
|
+
untrack(() => {
|
|
62
|
+
const fp = focusPath();
|
|
63
|
+
let finalFocusElm = undefined;
|
|
64
|
+
for (const elm of fp) {
|
|
65
|
+
finalFocusElm = finalFocusElm || elm;
|
|
66
|
+
if (mappedKeyEvent) {
|
|
67
|
+
const onKeyHandler = elm[`on${mappedKeyEvent}`];
|
|
68
|
+
if (isFunc(onKeyHandler)) {
|
|
69
|
+
if (onKeyHandler.call(elm, e, elm, finalFocusElm) === true) {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
console.log(`Unhandled key event: ${e.key || e.keyCode}`);
|
|
76
|
+
}
|
|
77
|
+
if (isFunc(elm.onKeyPress)) {
|
|
78
|
+
if (elm.onKeyPress.call(elm, e, mappedKeyEvent, elm, finalFocusElm) === true) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return focusPath;
|
|
88
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// To use with TS import withPadding and then put withPadding; on the next line to prevent tree shaking
|
|
2
|
+
export function withPadding(el, padding) {
|
|
3
|
+
const pad = padding();
|
|
4
|
+
let top, left, right, bottom;
|
|
5
|
+
if (Array.isArray(pad)) {
|
|
6
|
+
// top right bottom left
|
|
7
|
+
if (pad.length === 2) {
|
|
8
|
+
top = bottom = pad[0];
|
|
9
|
+
left = right = pad[1];
|
|
10
|
+
}
|
|
11
|
+
else if (pad.length === 3) {
|
|
12
|
+
top = pad[0];
|
|
13
|
+
left = right = pad[1];
|
|
14
|
+
bottom = pad[2];
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
[top, right, bottom, left] = pad;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
top = right = bottom = left = pad;
|
|
22
|
+
}
|
|
23
|
+
el.onBeforeLayout = (node, size) => {
|
|
24
|
+
if (size) {
|
|
25
|
+
el.width =
|
|
26
|
+
el.children.reduce((acc, c) => {
|
|
27
|
+
return acc + (c.width || 0);
|
|
28
|
+
}, 0) +
|
|
29
|
+
left +
|
|
30
|
+
right;
|
|
31
|
+
const firstChild = el.children[0];
|
|
32
|
+
if (firstChild) {
|
|
33
|
+
// set padding or marginLeft for flex
|
|
34
|
+
firstChild.x = left;
|
|
35
|
+
firstChild.marginLeft = left;
|
|
36
|
+
}
|
|
37
|
+
let maxHeight = 0;
|
|
38
|
+
el.children.forEach((c) => {
|
|
39
|
+
c.y = top;
|
|
40
|
+
c.marginTop = top;
|
|
41
|
+
maxHeight = Math.max(maxHeight, c.height || 0);
|
|
42
|
+
});
|
|
43
|
+
el.height = maxHeight + top + bottom;
|
|
44
|
+
// let flex know we need to re-layout
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/source/solidOpts.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { assertTruthy } from '@lightningjs/renderer/utils';
|
|
2
|
-
import { ElementNode, log } from '@lightningtv/core';
|
|
2
|
+
import { ElementNode, NodeType, log } from '@lightningtv/core';
|
|
3
3
|
export default {
|
|
4
4
|
createElement(name) {
|
|
5
5
|
return new ElementNode(name);
|
|
6
6
|
},
|
|
7
7
|
createTextNode(text) {
|
|
8
8
|
// A text node is just a string - not the <text> node
|
|
9
|
-
return { type:
|
|
9
|
+
return { type: NodeType.Text, text, parent: undefined };
|
|
10
10
|
},
|
|
11
11
|
replaceText(node, value) {
|
|
12
12
|
log('Replace Text: ', node, value);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ElementNode } from '@lightningtv/solid';
|
|
2
|
+
import { type SeriesResult, type SpeechType } from './speech.js';
|
|
3
|
+
type DebounceWithFlushFunction<T> = {
|
|
4
|
+
(newValue: T): void;
|
|
5
|
+
flush(): void;
|
|
6
|
+
clear: VoidFunction;
|
|
7
|
+
};
|
|
8
|
+
declare module '@lightningtv/solid' {
|
|
9
|
+
/**
|
|
10
|
+
* Augment the existing ElementNode interface with our own
|
|
11
|
+
* Announcer-specific properties.
|
|
12
|
+
*/
|
|
13
|
+
interface IntrinsicCommonProps {
|
|
14
|
+
announce?: SpeechType;
|
|
15
|
+
announceContext?: SpeechType;
|
|
16
|
+
title?: SpeechType;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export interface Announcer {
|
|
21
|
+
debug: boolean;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
cancel: VoidFunction;
|
|
24
|
+
clearPrevFocus: (depth?: number) => void;
|
|
25
|
+
speak: (text: SpeechType, options?: {
|
|
26
|
+
append?: boolean;
|
|
27
|
+
notification?: boolean;
|
|
28
|
+
}) => SeriesResult;
|
|
29
|
+
setupTimers: (options?: {
|
|
30
|
+
focusDebounce?: number;
|
|
31
|
+
focusChangeTimeout?: number;
|
|
32
|
+
}) => void;
|
|
33
|
+
onFocusChange?: DebounceWithFlushFunction<ElementNode[]>;
|
|
34
|
+
refresh: (depth?: number) => void;
|
|
35
|
+
}
|
|
36
|
+
export declare const Announcer: Announcer;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type CoreSpeechType = string | (() => SpeechType) | SpeechType[];
|
|
2
|
+
export type SpeechType = CoreSpeechType | Promise<CoreSpeechType>;
|
|
3
|
+
export interface SeriesResult {
|
|
4
|
+
series: Promise<void>;
|
|
5
|
+
readonly active: boolean;
|
|
6
|
+
append: (toSpeak: SpeechType) => void;
|
|
7
|
+
cancel: () => void;
|
|
8
|
+
}
|
|
9
|
+
export default function (toSpeak: SpeechType, lang?: string): SeriesResult;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Accessor, type Setter } from 'solid-js';
|
|
2
|
+
/**
|
|
3
|
+
* Provides an easy way to implement infinite items.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const [items, loader, { item, setItem, setItems, end, setEnd }] = createInfiniteScroll(fetcher);
|
|
7
|
+
* ```
|
|
8
|
+
* @param fetcher `(item: number) => Promise<T[]>`
|
|
9
|
+
* @return `items()` is an accessor contains array of contents
|
|
10
|
+
* @property `items.loading` is a boolean indicator for the loading state
|
|
11
|
+
* @property `items.error` contains any error encountered
|
|
12
|
+
* @method `page` is an accessor that contains page number
|
|
13
|
+
* @method `setPage` allows to manually change the page number
|
|
14
|
+
* @method `setItems` allows to manually change the contents of the item
|
|
15
|
+
* @method `end` is a boolean indicator for end of the item
|
|
16
|
+
* @method `setEnd` allows to manually change the end
|
|
17
|
+
*/
|
|
18
|
+
export declare function createInfiniteItems<T>(fetcher: (item: number) => Promise<T[]>): [
|
|
19
|
+
items: Accessor<T[]>,
|
|
20
|
+
options: {
|
|
21
|
+
page: Accessor<number>;
|
|
22
|
+
setPage: Setter<number>;
|
|
23
|
+
setItems: Setter<T[]>;
|
|
24
|
+
end: Accessor<boolean>;
|
|
25
|
+
setEnd: Setter<boolean>;
|
|
26
|
+
}
|
|
27
|
+
];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ElementNode } from '@lightningtv/solid';
|
|
2
|
+
export interface DefaultKeyMap {
|
|
3
|
+
Left: string | number | (string | number)[];
|
|
4
|
+
Right: string | number | (string | number)[];
|
|
5
|
+
Up: string | number | (string | number)[];
|
|
6
|
+
Down: string | number | (string | number)[];
|
|
7
|
+
Enter: string | number | (string | number)[];
|
|
8
|
+
Last: string | number | (string | number)[];
|
|
9
|
+
}
|
|
10
|
+
export interface KeyMap extends DefaultKeyMap {
|
|
11
|
+
}
|
|
12
|
+
export type KeyHandlerReturn = boolean | void;
|
|
13
|
+
export type KeyHandler = (this: ElementNode, e: KeyboardEvent, target: ElementNode, handlerElm: ElementNode) => KeyHandlerReturn;
|
|
14
|
+
/**
|
|
15
|
+
* Generates a map of event handlers for each key in the KeyMap
|
|
16
|
+
*/
|
|
17
|
+
type KeyMapEventHandlers = {
|
|
18
|
+
[K in keyof KeyMap as `on${Capitalize<K>}`]?: KeyHandler;
|
|
19
|
+
};
|
|
20
|
+
declare module '@lightningtv/solid' {
|
|
21
|
+
/**
|
|
22
|
+
* Augment the existing IntrinsicCommonProps interface with our own
|
|
23
|
+
* FocusManager-specific properties.
|
|
24
|
+
*/
|
|
25
|
+
interface IntrinsicCommonProps extends KeyMapEventHandlers {
|
|
26
|
+
onFocus?: (currentFocusedElm: ElementNode | undefined, prevFocusedElm: ElementNode | undefined) => void;
|
|
27
|
+
onBlur?: (currentFocusedElm: ElementNode | undefined, prevFocusedElm: ElementNode | undefined) => void;
|
|
28
|
+
onKeyPress?: (this: ElementNode, e: KeyboardEvent, mappedKeyEvent: string | undefined, handlerElm: ElementNode, currentFocusedElm: ElementNode) => KeyHandlerReturn;
|
|
29
|
+
onSelectedChanged?: (container: ElementNode, activeElm: ElementNode, selectedIndex: number | undefined, lastSelectedIndex: number | undefined) => void;
|
|
30
|
+
skipFocus?: boolean;
|
|
31
|
+
wrap?: boolean;
|
|
32
|
+
plinko?: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface IntrinsicNodeStyleProps {
|
|
35
|
+
focus?: IntrinsicNodeStyleProps;
|
|
36
|
+
}
|
|
37
|
+
interface IntrinsicTextNodeStyleProps {
|
|
38
|
+
focus?: IntrinsicTextNodeStyleProps;
|
|
39
|
+
}
|
|
40
|
+
interface TextNode {
|
|
41
|
+
skipFocus?: undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
declare const focusPath: import("solid-js").Accessor<ElementNode[]>;
|
|
45
|
+
export { focusPath };
|
|
46
|
+
export declare const useFocusManager: (userKeyMap?: Partial<KeyMap>) => import("solid-js").Accessor<ElementNode[]>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightningtv/solid",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Lightning Renderer for Solid Universal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"source": "src/index.ts",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"author": "Chris Lorenzo",
|
|
34
34
|
"license": "Apache-2.0",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@lightningtv/core": "^0.0.
|
|
36
|
+
"@lightningtv/core": "^0.0.5",
|
|
37
|
+
"@lightningtv/solid": "file:",
|
|
38
|
+
"@solid-primitives/keyboard": "^1.2.8",
|
|
39
|
+
"@solid-primitives/scheduled": "^1.4.3"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@rollup/plugin-replace": "^5.0.5",
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ElementNode } from '@lightningtv/solid';
|
|
2
|
+
import { untrack } from 'solid-js';
|
|
3
|
+
import SpeechEngine, { type SeriesResult, type SpeechType } from './speech.js';
|
|
4
|
+
import { debounce } from '@solid-primitives/scheduled';
|
|
5
|
+
import { focusPath } from '../useFocusManager.js';
|
|
6
|
+
|
|
7
|
+
type DebounceWithFlushFunction<T> = {
|
|
8
|
+
(newValue: T): void;
|
|
9
|
+
flush(): void;
|
|
10
|
+
clear: VoidFunction;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
declare module '@lightningtv/solid' {
|
|
14
|
+
/**
|
|
15
|
+
* Augment the existing ElementNode interface with our own
|
|
16
|
+
* Announcer-specific properties.
|
|
17
|
+
*/
|
|
18
|
+
interface IntrinsicCommonProps {
|
|
19
|
+
announce?: SpeechType;
|
|
20
|
+
announceContext?: SpeechType;
|
|
21
|
+
title?: SpeechType;
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let resetFocusPathTimer: DebounceWithFlushFunction<void>;
|
|
27
|
+
let prevFocusPath: ElementNode[] = [];
|
|
28
|
+
let currentlySpeaking: SeriesResult | undefined;
|
|
29
|
+
let voiceOutDisabled = false;
|
|
30
|
+
const fiveMinutes = 300000;
|
|
31
|
+
|
|
32
|
+
function debounceWithFlush<T>(
|
|
33
|
+
callback: (newValue: T) => void,
|
|
34
|
+
time?: number,
|
|
35
|
+
): DebounceWithFlushFunction<T> {
|
|
36
|
+
const trigger = debounce(callback, time);
|
|
37
|
+
let scopedValue: T;
|
|
38
|
+
|
|
39
|
+
const debounced = (newValue: T) => {
|
|
40
|
+
scopedValue = newValue;
|
|
41
|
+
trigger(newValue);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
debounced.flush = () => {
|
|
45
|
+
trigger.clear();
|
|
46
|
+
callback(scopedValue);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
debounced.clear = trigger.clear;
|
|
50
|
+
|
|
51
|
+
return debounced;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getElmName(elm: ElementNode): string {
|
|
55
|
+
return (elm.id || elm.name) as string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onFocusChangeCore(focusPath: ElementNode[] = []) {
|
|
59
|
+
if (!Announcer.onFocusChange || !Announcer.enabled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const loaded = focusPath.every((elm) => !elm.loading);
|
|
64
|
+
const focusDiff = focusPath.filter((elm) => !prevFocusPath.includes(elm));
|
|
65
|
+
|
|
66
|
+
resetFocusPathTimer();
|
|
67
|
+
|
|
68
|
+
if (!loaded && Announcer.onFocusChange) {
|
|
69
|
+
Announcer.onFocusChange([]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
prevFocusPath = focusPath.slice(0);
|
|
74
|
+
|
|
75
|
+
const toAnnounceText: SpeechType[] = [];
|
|
76
|
+
const toAnnounce = focusDiff.reduce(
|
|
77
|
+
(acc: [string, string, SpeechType][], elm) => {
|
|
78
|
+
if (elm.announce) {
|
|
79
|
+
acc.push([getElmName(elm), 'Announce', elm.announce]);
|
|
80
|
+
toAnnounceText.push(elm.announce);
|
|
81
|
+
} else if (elm.title) {
|
|
82
|
+
acc.push([getElmName(elm), 'Title', elm.title]);
|
|
83
|
+
toAnnounceText.push(elm.title);
|
|
84
|
+
} else {
|
|
85
|
+
acc.push([getElmName(elm), 'No Announce', '']);
|
|
86
|
+
}
|
|
87
|
+
return acc;
|
|
88
|
+
},
|
|
89
|
+
[],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
focusDiff.reverse().reduce((acc, elm) => {
|
|
93
|
+
if (elm.announceContext) {
|
|
94
|
+
acc.push([getElmName(elm), 'Context', elm.announceContext]);
|
|
95
|
+
toAnnounceText.push(elm.announceContext);
|
|
96
|
+
} else {
|
|
97
|
+
acc.push([getElmName(elm), 'No Context', '']);
|
|
98
|
+
}
|
|
99
|
+
return acc;
|
|
100
|
+
}, toAnnounce);
|
|
101
|
+
|
|
102
|
+
if (Announcer.debug) {
|
|
103
|
+
console.table(toAnnounce);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (toAnnounceText.length) {
|
|
107
|
+
return Announcer.speak(
|
|
108
|
+
toAnnounceText.reduce((acc: SpeechType[], val) => acc.concat(val), []),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function textToSpeech(toSpeak: SpeechType) {
|
|
114
|
+
if (voiceOutDisabled) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (currentlySpeaking = SpeechEngine(toSpeak));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface Announcer {
|
|
122
|
+
debug: boolean;
|
|
123
|
+
enabled: boolean;
|
|
124
|
+
cancel: VoidFunction;
|
|
125
|
+
clearPrevFocus: (depth?: number) => void;
|
|
126
|
+
speak: (
|
|
127
|
+
text: SpeechType,
|
|
128
|
+
options?: { append?: boolean; notification?: boolean },
|
|
129
|
+
) => SeriesResult;
|
|
130
|
+
setupTimers: (options?: {
|
|
131
|
+
focusDebounce?: number;
|
|
132
|
+
focusChangeTimeout?: number;
|
|
133
|
+
}) => void;
|
|
134
|
+
onFocusChange?: DebounceWithFlushFunction<ElementNode[]>;
|
|
135
|
+
refresh: (depth?: number) => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const Announcer: Announcer = {
|
|
139
|
+
debug: false,
|
|
140
|
+
enabled: true,
|
|
141
|
+
cancel: function () {
|
|
142
|
+
currentlySpeaking && currentlySpeaking.cancel();
|
|
143
|
+
},
|
|
144
|
+
clearPrevFocus: function (depth = 0) {
|
|
145
|
+
prevFocusPath = prevFocusPath.slice(0, depth);
|
|
146
|
+
resetFocusPathTimer();
|
|
147
|
+
},
|
|
148
|
+
speak: function (text, { append = false, notification = false } = {}) {
|
|
149
|
+
if (Announcer.onFocusChange && Announcer.enabled) {
|
|
150
|
+
Announcer.onFocusChange.flush();
|
|
151
|
+
if (append && currentlySpeaking && currentlySpeaking.active) {
|
|
152
|
+
currentlySpeaking.append(text);
|
|
153
|
+
} else {
|
|
154
|
+
Announcer.cancel();
|
|
155
|
+
textToSpeech(text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (notification) {
|
|
159
|
+
voiceOutDisabled = true;
|
|
160
|
+
currentlySpeaking?.series
|
|
161
|
+
.finally(() => {
|
|
162
|
+
voiceOutDisabled = false;
|
|
163
|
+
Announcer.refresh();
|
|
164
|
+
})
|
|
165
|
+
.catch(console.error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return currentlySpeaking as SeriesResult;
|
|
170
|
+
},
|
|
171
|
+
refresh: function (depth = 0) {
|
|
172
|
+
Announcer.clearPrevFocus(depth);
|
|
173
|
+
Announcer.onFocusChange &&
|
|
174
|
+
Announcer.onFocusChange(untrack(() => focusPath()));
|
|
175
|
+
},
|
|
176
|
+
setupTimers: function ({
|
|
177
|
+
focusDebounce = 400,
|
|
178
|
+
focusChangeTimeout = fiveMinutes,
|
|
179
|
+
} = {}) {
|
|
180
|
+
Announcer.onFocusChange = debounceWithFlush(
|
|
181
|
+
onFocusChangeCore,
|
|
182
|
+
focusDebounce,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
resetFocusPathTimer = debounceWithFlush(() => {
|
|
186
|
+
// Reset focus path for full announce
|
|
187
|
+
prevFocusPath = [];
|
|
188
|
+
}, focusChangeTimeout);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createEffect, on } from 'solid-js';
|
|
2
|
+
import { Announcer } from './announcer.js';
|
|
3
|
+
import { focusPath } from '../useFocusManager.js';
|
|
4
|
+
|
|
5
|
+
export const useAnnouncer = () => {
|
|
6
|
+
Announcer.setupTimers();
|
|
7
|
+
createEffect(on(focusPath, Announcer.onFocusChange!, { defer: true }));
|
|
8
|
+
|
|
9
|
+
return Announcer;
|
|
10
|
+
};
|