@lightningtv/solid 3.0.0-2 → 3.0.0-20
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/LICENSE +1 -1
- package/README.md +6 -0
- package/dist/src/jsx-runtime.d.ts +1 -3
- package/dist/src/primitives/Column.jsx +9 -10
- package/dist/src/primitives/Column.jsx.map +1 -1
- package/dist/src/primitives/Grid.d.ts +15 -6
- package/dist/src/primitives/Grid.jsx +35 -22
- package/dist/src/primitives/Grid.jsx.map +1 -1
- package/dist/src/primitives/Image.d.ts +8 -0
- package/dist/src/primitives/Image.jsx +24 -0
- package/dist/src/primitives/Image.jsx.map +1 -0
- package/dist/src/primitives/KeepAlive.d.ts +30 -0
- package/dist/src/primitives/KeepAlive.jsx +77 -0
- package/dist/src/primitives/KeepAlive.jsx.map +1 -0
- package/dist/src/primitives/Lazy.d.ts +8 -7
- package/dist/src/primitives/Lazy.jsx +49 -23
- package/dist/src/primitives/Lazy.jsx.map +1 -1
- package/dist/src/primitives/Marquee.d.ts +64 -0
- package/dist/src/primitives/Marquee.jsx +86 -0
- package/dist/src/primitives/Marquee.jsx.map +1 -0
- package/dist/src/primitives/Preserve.d.ts +4 -0
- package/dist/src/primitives/Preserve.jsx +11 -0
- package/dist/src/primitives/Preserve.jsx.map +1 -0
- package/dist/src/primitives/Row.jsx +9 -10
- package/dist/src/primitives/Row.jsx.map +1 -1
- package/dist/src/primitives/Suspense.d.ts +22 -0
- package/dist/src/primitives/Suspense.jsx +33 -0
- package/dist/src/primitives/Suspense.jsx.map +1 -0
- package/dist/src/primitives/Virtual.d.ts +18 -0
- package/dist/src/primitives/Virtual.jsx +434 -0
- package/dist/src/primitives/Virtual.jsx.map +1 -0
- package/dist/src/primitives/VirtualGrid.d.ts +13 -0
- package/dist/src/primitives/VirtualGrid.jsx +139 -0
- package/dist/src/primitives/VirtualGrid.jsx.map +1 -0
- package/dist/src/primitives/VirtualList.d.ts +11 -0
- package/dist/src/primitives/VirtualList.jsx +96 -0
- package/dist/src/primitives/VirtualList.jsx.map +1 -0
- package/dist/src/primitives/VirtualRow.d.ts +13 -0
- package/dist/src/primitives/VirtualRow.jsx +97 -0
- package/dist/src/primitives/VirtualRow.jsx.map +1 -0
- package/dist/src/primitives/Visible.d.ts +0 -1
- package/dist/src/primitives/Visible.jsx +1 -1
- package/dist/src/primitives/Visible.jsx.map +1 -1
- package/dist/src/primitives/announcer/announcer.d.ts +2 -0
- package/dist/src/primitives/announcer/announcer.js +7 -5
- package/dist/src/primitives/announcer/announcer.js.map +1 -1
- package/dist/src/primitives/announcer/index.d.ts +5 -1
- package/dist/src/primitives/announcer/index.js +8 -2
- package/dist/src/primitives/announcer/index.js.map +1 -1
- package/dist/src/primitives/announcer/speech.d.ts +2 -2
- package/dist/src/primitives/announcer/speech.js +157 -28
- package/dist/src/primitives/announcer/speech.js.map +1 -1
- package/dist/src/primitives/createFocusStack.d.ts +4 -4
- package/dist/src/primitives/createFocusStack.jsx +15 -6
- package/dist/src/primitives/createFocusStack.jsx.map +1 -1
- package/dist/src/primitives/createTag.d.ts +8 -0
- package/dist/src/primitives/createTag.jsx +20 -0
- package/dist/src/primitives/createTag.jsx.map +1 -0
- package/dist/src/primitives/index.d.ts +13 -3
- package/dist/src/primitives/index.js +13 -3
- package/dist/src/primitives/index.js.map +1 -1
- package/dist/src/primitives/types.d.ts +3 -0
- package/dist/src/primitives/useHold.d.ts +27 -0
- package/dist/src/primitives/useHold.js +54 -0
- package/dist/src/primitives/useHold.js.map +1 -0
- package/dist/src/primitives/useMouse.d.ts +24 -1
- package/dist/src/primitives/useMouse.js +153 -47
- package/dist/src/primitives/useMouse.js.map +1 -1
- package/dist/src/primitives/utils/chainFunctions.d.ts +30 -4
- package/dist/src/primitives/utils/chainFunctions.js +14 -3
- package/dist/src/primitives/utils/chainFunctions.js.map +1 -1
- package/dist/src/primitives/utils/createBlurredImage.d.ts +56 -0
- package/dist/src/primitives/utils/createBlurredImage.js +223 -0
- package/dist/src/primitives/utils/createBlurredImage.js.map +1 -0
- package/dist/src/primitives/utils/createSpriteMap.d.ts +2 -2
- package/dist/src/primitives/utils/createSpriteMap.js.map +1 -1
- package/dist/src/primitives/utils/handleNavigation.d.ts +85 -5
- package/dist/src/primitives/utils/handleNavigation.js +242 -69
- package/dist/src/primitives/utils/handleNavigation.js.map +1 -1
- package/dist/src/primitives/utils/withScrolling.d.ts +8 -1
- package/dist/src/primitives/utils/withScrolling.js +25 -6
- package/dist/src/primitives/utils/withScrolling.js.map +1 -1
- package/dist/src/render.d.ts +6 -5
- package/dist/src/render.js +4 -0
- package/dist/src/render.js.map +1 -1
- package/dist/src/solidOpts.d.ts +3 -2
- package/dist/src/solidOpts.js +31 -15
- package/dist/src/solidOpts.js.map +1 -1
- package/dist/src/universal.d.ts +25 -0
- package/dist/src/universal.js +232 -0
- package/dist/src/universal.js.map +1 -0
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +8 -0
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/jsx-runtime.d.ts +2 -4
- package/package.json +19 -10
- package/src/primitives/Column.tsx +10 -12
- package/src/primitives/Grid.tsx +57 -33
- package/src/primitives/Image.tsx +36 -0
- package/src/primitives/KeepAlive.tsx +124 -0
- package/src/primitives/Lazy.tsx +60 -37
- package/src/primitives/Marquee.tsx +149 -0
- package/src/primitives/Preserve.tsx +18 -0
- package/src/primitives/Row.tsx +11 -12
- package/src/primitives/Suspense.tsx +39 -0
- package/src/primitives/Virtual.tsx +478 -0
- package/src/primitives/VirtualGrid.tsx +199 -0
- package/src/primitives/Visible.tsx +1 -2
- package/src/primitives/announcer/announcer.ts +16 -10
- package/src/primitives/announcer/index.ts +12 -2
- package/src/primitives/announcer/speech.ts +188 -27
- package/src/primitives/createFocusStack.tsx +18 -7
- package/src/primitives/createTag.tsx +31 -0
- package/src/primitives/index.ts +17 -3
- package/src/primitives/types.ts +10 -0
- package/src/primitives/useHold.ts +69 -0
- package/src/primitives/useMouse.ts +283 -66
- package/src/primitives/utils/chainFunctions.ts +40 -9
- package/src/primitives/utils/createBlurredImage.ts +366 -0
- package/src/primitives/utils/createSpriteMap.ts +6 -4
- package/src/primitives/utils/handleNavigation.ts +307 -84
- package/src/primitives/utils/withScrolling.ts +47 -16
- package/src/render.ts +9 -7
- package/src/solidOpts.ts +34 -19
- package/src/utils.ts +10 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as s from 'solid-js';
|
|
2
|
+
import * as lng from '@lightningtv/solid';
|
|
3
|
+
import * as lngp from '@lightningtv/solid/primitives';
|
|
4
|
+
import { List } from '@solid-primitives/list';
|
|
5
|
+
import * as utils from '../utils.js';
|
|
6
|
+
|
|
7
|
+
const columnScroll = lngp.withScrolling(false);
|
|
8
|
+
|
|
9
|
+
const rowStyles: lng.NodeStyles = {
|
|
10
|
+
display: 'flex',
|
|
11
|
+
flexWrap: 'wrap',
|
|
12
|
+
transition: {
|
|
13
|
+
y: true,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type VirtualGridProps<T> = lng.NewOmit<lngp.RowProps, 'children'> & {
|
|
18
|
+
each: readonly T[] | undefined | null | false;
|
|
19
|
+
columns: number; // items per row
|
|
20
|
+
rows?: number; // number of visible rows (default: 1)
|
|
21
|
+
buffer?: number;
|
|
22
|
+
onEndReached?: () => void;
|
|
23
|
+
onEndReachedThreshold?: number;
|
|
24
|
+
children: (item: s.Accessor<T>, index: s.Accessor<number>) => s.JSX.Element;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function VirtualGrid<T>(props: VirtualGridProps<T>): s.JSX.Element {
|
|
28
|
+
const bufferSize = () => props.buffer ?? 2;
|
|
29
|
+
const [ cursor, setCursor ] = s.createSignal(props.selected ?? 0);
|
|
30
|
+
const items = s.createMemo(() => props.each || []);
|
|
31
|
+
const itemsPerRow = () => props.columns;
|
|
32
|
+
const numberOfRows = () => props.rows ?? 1;
|
|
33
|
+
const totalVisibleItems = () => itemsPerRow() * numberOfRows();
|
|
34
|
+
|
|
35
|
+
const start = s.createMemo(() => {
|
|
36
|
+
const perRow = itemsPerRow();
|
|
37
|
+
const newRowIndex = Math.floor(cursor() / perRow);
|
|
38
|
+
const rawStart = newRowIndex * perRow - bufferSize() * perRow;
|
|
39
|
+
return Math.max(0, rawStart);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const end = s.createMemo(() => {
|
|
43
|
+
const perRow = itemsPerRow();
|
|
44
|
+
const newRowIndex = Math.floor(cursor() / perRow);
|
|
45
|
+
const rawEnd = (newRowIndex + bufferSize()) * perRow + totalVisibleItems();
|
|
46
|
+
return Math.min(items().length, rawEnd);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const [slice, setSlice] = s.createSignal(items().slice(start(), end()));
|
|
50
|
+
|
|
51
|
+
let viewRef!: lngp.NavigableElement;
|
|
52
|
+
|
|
53
|
+
function onVerticalNav(dir: -1 | 1): lngp.KeyHandler {
|
|
54
|
+
return function () {
|
|
55
|
+
const perRow = itemsPerRow();
|
|
56
|
+
const currentRowIndex = Math.floor(cursor() / perRow);
|
|
57
|
+
const maxRows = Math.floor(items().length / perRow);
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
currentRowIndex === 0 && dir === -1
|
|
61
|
+
|| currentRowIndex === maxRows && dir === 1
|
|
62
|
+
) return;
|
|
63
|
+
|
|
64
|
+
const selected = this.selected || 0;
|
|
65
|
+
const offset = dir * perRow;
|
|
66
|
+
const newIndex = utils.clamp(selected + offset, 0, items().length - 1);
|
|
67
|
+
const lastIdx = selected;
|
|
68
|
+
this.selected = newIndex;
|
|
69
|
+
const active = this.children[this.selected];
|
|
70
|
+
|
|
71
|
+
if (active instanceof lng.ElementNode) {
|
|
72
|
+
active.setFocus();
|
|
73
|
+
chainedOnSelectedChanged.call(
|
|
74
|
+
this as lngp.NavigableElement,
|
|
75
|
+
this.selected,
|
|
76
|
+
this as lngp.NavigableElement,
|
|
77
|
+
active,
|
|
78
|
+
lastIdx
|
|
79
|
+
);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onUp = onVerticalNav(-1);
|
|
86
|
+
const onDown = onVerticalNav(1);
|
|
87
|
+
|
|
88
|
+
const onSelectedChanged: lngp.OnSelectedChanged = function (_idx, elm, active, _lastIdx,) {
|
|
89
|
+
let idx = _idx;
|
|
90
|
+
let lastIdx = _lastIdx;
|
|
91
|
+
const perRow = itemsPerRow();
|
|
92
|
+
const newRowIndex = Math.floor(idx / perRow);
|
|
93
|
+
const prevRowIndex = Math.floor((lastIdx || 0) / perRow);
|
|
94
|
+
const prevStart = start();
|
|
95
|
+
|
|
96
|
+
setCursor(prevStart + idx);
|
|
97
|
+
if (newRowIndex === prevRowIndex) return;
|
|
98
|
+
|
|
99
|
+
setSlice(items().slice(start(), end()));
|
|
100
|
+
|
|
101
|
+
// this.selected is relative to the slice
|
|
102
|
+
// and it doesn't get corrected automatically after children change
|
|
103
|
+
const idxCorrection = prevStart - start();
|
|
104
|
+
if (lastIdx) lastIdx += idxCorrection;
|
|
105
|
+
idx += idxCorrection;
|
|
106
|
+
this.selected += idxCorrection;
|
|
107
|
+
|
|
108
|
+
if (props.onEndReachedThreshold !== undefined && cursor() >= items().length - props.onEndReachedThreshold) {
|
|
109
|
+
props.onEndReached?.();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
queueMicrotask(() => {
|
|
113
|
+
const prevRowY = this.y + active.y;
|
|
114
|
+
this.updateLayout();
|
|
115
|
+
this.lng.y = prevRowY - active.y;
|
|
116
|
+
columnScroll(idx, elm, active, lastIdx);
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const chainedOnSelectedChanged = lngp.chainFunctions(props.onSelectedChanged, onSelectedChanged)!;
|
|
121
|
+
|
|
122
|
+
let cachedSelected: number | undefined;
|
|
123
|
+
const updateSelected = ([selected, _items]: [number?, any?]) => {
|
|
124
|
+
if (!viewRef || selected == null) return;
|
|
125
|
+
|
|
126
|
+
if (cachedSelected !== undefined) {
|
|
127
|
+
selected = cachedSelected;
|
|
128
|
+
cachedSelected = undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (selected >= items().length && props.onEndReached) {
|
|
132
|
+
props.onEndReached?.();
|
|
133
|
+
cachedSelected = selected;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const item = items()[selected];
|
|
138
|
+
let active = viewRef.children.find(x => x.item === item);
|
|
139
|
+
const lastSelected = viewRef.selected;
|
|
140
|
+
|
|
141
|
+
if (active instanceof lng.ElementNode) {
|
|
142
|
+
viewRef.selected = viewRef.children.indexOf(active);
|
|
143
|
+
active.setFocus();
|
|
144
|
+
chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
|
|
145
|
+
} else {
|
|
146
|
+
setCursor(selected);
|
|
147
|
+
setSlice(items().slice(start(), end()));
|
|
148
|
+
|
|
149
|
+
queueMicrotask(() => {
|
|
150
|
+
viewRef.updateLayout();
|
|
151
|
+
active = viewRef.children.find(x => x.item === item);
|
|
152
|
+
if (active instanceof lng.ElementNode) {
|
|
153
|
+
viewRef.selected = viewRef.children.indexOf(active);
|
|
154
|
+
active.setFocus();
|
|
155
|
+
chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const scrollToIndex = (index: number) => {
|
|
162
|
+
s.untrack(() => updateSelected([index]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
s.createEffect(s.on([() => props.selected, items], updateSelected));
|
|
166
|
+
|
|
167
|
+
s.createEffect(
|
|
168
|
+
s.on(items, () => {
|
|
169
|
+
if (!viewRef) return;
|
|
170
|
+
if (cachedSelected !== undefined) {
|
|
171
|
+
updateSelected([cachedSelected]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setSlice(items().slice(start(), end()));
|
|
175
|
+
}, { defer: true })
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<view
|
|
181
|
+
{...props}
|
|
182
|
+
scroll={props.scroll || 'always'}
|
|
183
|
+
ref={lngp.chainRefs(el => { viewRef = el as lngp.NavigableElement; }, props.ref)}
|
|
184
|
+
selected={props.selected || 0}
|
|
185
|
+
cursor={cursor()}
|
|
186
|
+
onLeft={/* @once */ lngp.chainFunctions(props.onLeft, lngp.navigableHandleNavigation)}
|
|
187
|
+
onRight={/* @once */ lngp.chainFunctions(props.onRight, lngp.navigableHandleNavigation)}
|
|
188
|
+
onUp={/* @once */ lngp.chainFunctions(props.onUp, onUp)}
|
|
189
|
+
onDown={/* @once */ lngp.chainFunctions(props.onDown, onDown)}
|
|
190
|
+
forwardFocus={/* @once */ lngp.navigableForwardFocus}
|
|
191
|
+
onCreate={/* @once */ props.selected ? lngp.chainFunctions(props.onCreate, columnScroll) : props.onCreate}
|
|
192
|
+
scrollToIndex={/* @once */ scrollToIndex}
|
|
193
|
+
onSelectedChanged={/* @once */ chainedOnSelectedChanged}
|
|
194
|
+
style={/* @once */ lng.combineStyles(props.style, rowStyles)}
|
|
195
|
+
>
|
|
196
|
+
<List each={slice()}>{props.children}</List>
|
|
197
|
+
</view>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -13,7 +13,6 @@ import { ElementNode } from '@lightningtv/solid';
|
|
|
13
13
|
export function Visible<T>(props: {
|
|
14
14
|
when: T | undefined | null | false;
|
|
15
15
|
keyed?: boolean;
|
|
16
|
-
fallback?: JSX.Element;
|
|
17
16
|
children: JSX.Element;
|
|
18
17
|
}): JSX.Element {
|
|
19
18
|
let child: ChildrenReturn | undefined;
|
|
@@ -55,6 +54,6 @@ export function Visible<T>(props: {
|
|
|
55
54
|
}
|
|
56
55
|
});
|
|
57
56
|
|
|
58
|
-
return c ? child :
|
|
57
|
+
return c || child ? child : null;
|
|
59
58
|
}) as unknown as JSX.Element;
|
|
60
59
|
};
|
|
@@ -73,8 +73,9 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
|
|
|
73
73
|
prevFocusPath = focusPath.slice(0);
|
|
74
74
|
|
|
75
75
|
const toAnnounceText: SpeechType[] = [];
|
|
76
|
-
const toAnnounce = focusDiff
|
|
77
|
-
(
|
|
76
|
+
const toAnnounce = focusDiff
|
|
77
|
+
.reverse()
|
|
78
|
+
.reduce((acc: [string, string, SpeechType][], elm) => {
|
|
78
79
|
if (elm.announce) {
|
|
79
80
|
acc.push([getElmName(elm), 'Announce', elm.announce]);
|
|
80
81
|
toAnnounceText.push(elm.announce);
|
|
@@ -85,9 +86,7 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
|
|
|
85
86
|
acc.push([getElmName(elm), 'No Announce', '']);
|
|
86
87
|
}
|
|
87
88
|
return acc;
|
|
88
|
-
},
|
|
89
|
-
[],
|
|
90
|
-
);
|
|
89
|
+
}, []);
|
|
91
90
|
|
|
92
91
|
focusDiff.reverse().reduce((acc, elm) => {
|
|
93
92
|
if (elm.announceContext) {
|
|
@@ -110,18 +109,25 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
|
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
function textToSpeech(
|
|
112
|
+
function textToSpeech(
|
|
113
|
+
toSpeak: SpeechType,
|
|
114
|
+
aria: boolean,
|
|
115
|
+
lang: string,
|
|
116
|
+
voice?: string,
|
|
117
|
+
) {
|
|
114
118
|
if (voiceOutDisabled) {
|
|
115
119
|
return;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
return (currentlySpeaking = SpeechEngine(toSpeak, lang));
|
|
122
|
+
return (currentlySpeaking = SpeechEngine(toSpeak, aria, lang, voice));
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
export interface Announcer {
|
|
122
126
|
debug: boolean;
|
|
123
127
|
enabled: boolean;
|
|
124
128
|
lang: string;
|
|
129
|
+
aria: boolean;
|
|
130
|
+
voice?: string;
|
|
125
131
|
cancel: VoidFunction;
|
|
126
132
|
clearPrevFocus: (depth?: number) => void;
|
|
127
133
|
speak: (
|
|
@@ -140,6 +146,7 @@ export const Announcer: Announcer = {
|
|
|
140
146
|
debug: false,
|
|
141
147
|
enabled: true,
|
|
142
148
|
lang: 'en-US',
|
|
149
|
+
aria: false,
|
|
143
150
|
cancel: function () {
|
|
144
151
|
currentlySpeaking && currentlySpeaking.cancel();
|
|
145
152
|
},
|
|
@@ -147,14 +154,13 @@ export const Announcer: Announcer = {
|
|
|
147
154
|
prevFocusPath = prevFocusPath.slice(0, depth);
|
|
148
155
|
resetFocusPathTimer();
|
|
149
156
|
},
|
|
150
|
-
speak: function (text, { append = false, notification = false} = {}) {
|
|
157
|
+
speak: function (text, { append = false, notification = false } = {}) {
|
|
151
158
|
if (Announcer.onFocusChange && Announcer.enabled) {
|
|
152
|
-
Announcer.onFocusChange.flush();
|
|
153
159
|
if (append && currentlySpeaking && currentlySpeaking.active) {
|
|
154
160
|
currentlySpeaking.append(text);
|
|
155
161
|
} else {
|
|
156
162
|
Announcer.cancel();
|
|
157
|
-
textToSpeech(text, Announcer.lang);
|
|
163
|
+
textToSpeech(text, Announcer.aria, Announcer.lang, Announcer.voice);
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
if (notification) {
|
|
@@ -2,9 +2,19 @@ import { createEffect, on } from 'solid-js';
|
|
|
2
2
|
import { Announcer } from './announcer.js';
|
|
3
3
|
import { focusPath } from '../useFocusManager.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
let doOnce = false;
|
|
6
|
+
export const useAnnouncer = (options?: {
|
|
7
|
+
focusDebounce?: number;
|
|
8
|
+
focusChangeTimeout?: number;
|
|
9
|
+
}) => {
|
|
10
|
+
if (doOnce) {
|
|
11
|
+
return Announcer;
|
|
12
|
+
}
|
|
13
|
+
doOnce = true;
|
|
14
|
+
Announcer.setupTimers(options);
|
|
7
15
|
createEffect(on(focusPath, Announcer.onFocusChange!, { defer: true }));
|
|
8
16
|
|
|
9
17
|
return Announcer;
|
|
10
18
|
};
|
|
19
|
+
|
|
20
|
+
export { Announcer };
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
type CoreSpeechType =
|
|
1
|
+
type CoreSpeechType =
|
|
2
|
+
| string
|
|
3
|
+
| (() => SpeechType)
|
|
4
|
+
| SpeechType[]
|
|
5
|
+
| SpeechSynthesisUtterance;
|
|
2
6
|
export type SpeechType = CoreSpeechType | Promise<CoreSpeechType>;
|
|
3
7
|
|
|
4
8
|
export interface SeriesResult {
|
|
@@ -8,6 +12,11 @@ export interface SeriesResult {
|
|
|
8
12
|
cancel: () => void;
|
|
9
13
|
}
|
|
10
14
|
|
|
15
|
+
// Aria label
|
|
16
|
+
type AriaLabel = { text: string; lang: string };
|
|
17
|
+
const ARIA_PARENT_ID = 'aria-parent';
|
|
18
|
+
let ariaLabelPhrases: AriaLabel[] = [];
|
|
19
|
+
|
|
11
20
|
/* global SpeechSynthesisErrorEvent */
|
|
12
21
|
function flattenStrings(series: SpeechType[] = []): SpeechType[] {
|
|
13
22
|
const flattenedSeries = [];
|
|
@@ -36,6 +45,82 @@ function delay(pause: number) {
|
|
|
36
45
|
});
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
/**
|
|
49
|
+
* @description This function is called at the end of the speak series
|
|
50
|
+
* @param Phrase is an object containing the text and the language
|
|
51
|
+
*/
|
|
52
|
+
function addChildrenToAriaDiv(phrase: AriaLabel) {
|
|
53
|
+
if (phrase?.text?.trim().length === 0) return;
|
|
54
|
+
ariaLabelPhrases.push(phrase);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @description This function is triggered finally when the speak series is finished and we are to speak the aria labels
|
|
59
|
+
*/
|
|
60
|
+
function focusElementForAria() {
|
|
61
|
+
const element = createAriaElement();
|
|
62
|
+
|
|
63
|
+
if (!element) {
|
|
64
|
+
console.error(`ARIA div not found: ${ARIA_PARENT_ID}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const object of ariaLabelPhrases) {
|
|
69
|
+
const span = document.createElement('span');
|
|
70
|
+
|
|
71
|
+
// TODO: Not sure LG or Samsung support lang attribute on span or switching language
|
|
72
|
+
span.setAttribute('lang', object.lang);
|
|
73
|
+
span.setAttribute('aria-label', object.text);
|
|
74
|
+
element.appendChild(span);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Cleanup
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
ariaLabelPhrases = [];
|
|
80
|
+
cleanAriaLabelParent();
|
|
81
|
+
focusCanvas();
|
|
82
|
+
}, 100);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @description Clean the aria label parent after speaking
|
|
87
|
+
*/
|
|
88
|
+
function cleanAriaLabelParent(): void {
|
|
89
|
+
const parentTag = document.getElementById(ARIA_PARENT_ID);
|
|
90
|
+
if (parentTag) {
|
|
91
|
+
while (parentTag.firstChild) {
|
|
92
|
+
parentTag.removeChild(parentTag.firstChild);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @description Focus the canvas element
|
|
99
|
+
*/
|
|
100
|
+
function focusCanvas(): void {
|
|
101
|
+
const canvas = document.getElementById('app')?.firstChild as HTMLElement;
|
|
102
|
+
canvas?.focus();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @description Create the aria element in the DOM if it doesn't exist
|
|
107
|
+
* @private For xbox, we may need to create a different element each time we wanna use aria
|
|
108
|
+
*/
|
|
109
|
+
function createAriaElement(): HTMLDivElement | HTMLElement {
|
|
110
|
+
const aria_container = document.getElementById(ARIA_PARENT_ID);
|
|
111
|
+
|
|
112
|
+
if (!aria_container) {
|
|
113
|
+
const element = document.createElement('div');
|
|
114
|
+
element.setAttribute('id', ARIA_PARENT_ID);
|
|
115
|
+
element.setAttribute('aria-live', 'assertive');
|
|
116
|
+
element.setAttribute('tabindex', '0');
|
|
117
|
+
document.body.appendChild(element);
|
|
118
|
+
return element;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return aria_container;
|
|
122
|
+
}
|
|
123
|
+
|
|
39
124
|
/**
|
|
40
125
|
* Speak a string
|
|
41
126
|
*
|
|
@@ -48,11 +133,23 @@ function speak(
|
|
|
48
133
|
phrase: string,
|
|
49
134
|
utterances: SpeechSynthesisUtterance[],
|
|
50
135
|
lang = 'en-US',
|
|
136
|
+
voiceName?: string,
|
|
51
137
|
) {
|
|
52
138
|
const synth = window.speechSynthesis;
|
|
139
|
+
|
|
53
140
|
return new Promise<void>((resolve, reject) => {
|
|
141
|
+
let selectedVoice;
|
|
142
|
+
if (voiceName) {
|
|
143
|
+
const availableVoices = synth.getVoices();
|
|
144
|
+
selectedVoice =
|
|
145
|
+
availableVoices.find((v) => v.name === voiceName) || availableVoices[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
54
148
|
const utterance = new SpeechSynthesisUtterance(phrase);
|
|
55
149
|
utterance.lang = lang;
|
|
150
|
+
if (selectedVoice) {
|
|
151
|
+
utterance.voice = selectedVoice;
|
|
152
|
+
}
|
|
56
153
|
utterance.onend = () => {
|
|
57
154
|
resolve();
|
|
58
155
|
};
|
|
@@ -66,7 +163,9 @@ function speak(
|
|
|
66
163
|
|
|
67
164
|
function speakSeries(
|
|
68
165
|
series: SpeechType,
|
|
166
|
+
aria: boolean,
|
|
69
167
|
lang: string,
|
|
168
|
+
voice?: string,
|
|
70
169
|
root = true,
|
|
71
170
|
): SeriesResult {
|
|
72
171
|
const synth = window.speechSynthesis;
|
|
@@ -74,11 +173,6 @@ function speakSeries(
|
|
|
74
173
|
Array.isArray(series) ? series : [series],
|
|
75
174
|
);
|
|
76
175
|
const nestedSeriesResults: SeriesResult[] = [];
|
|
77
|
-
/*
|
|
78
|
-
We hold this array of SpeechSynthesisUtterances in order to prevent them from being
|
|
79
|
-
garbage collected prematurely on STB hardware which can cause the 'onend' events of
|
|
80
|
-
utterances to not fire consistently.
|
|
81
|
-
*/
|
|
82
176
|
const utterances: SpeechSynthesisUtterance[] = [];
|
|
83
177
|
let active: boolean = true;
|
|
84
178
|
|
|
@@ -87,24 +181,66 @@ function speakSeries(
|
|
|
87
181
|
while (active && remainingPhrases.length) {
|
|
88
182
|
const phrase = await Promise.resolve(remainingPhrases.shift());
|
|
89
183
|
if (!active) {
|
|
90
|
-
// Exit
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
if (isNaN(pause)) {
|
|
97
|
-
pause
|
|
184
|
+
break; // Exit if canceled
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (typeof phrase === 'string' && phrase.includes('PAUSE-')) {
|
|
188
|
+
// Handle pauses
|
|
189
|
+
const pause = Number(phrase.split('PAUSE-')[1]) * 1000;
|
|
190
|
+
if (!isNaN(pause)) {
|
|
191
|
+
await delay(pause);
|
|
192
|
+
}
|
|
193
|
+
} else if (typeof phrase === 'string') {
|
|
194
|
+
if (!phrase) {
|
|
195
|
+
continue; // Skip empty strings
|
|
98
196
|
}
|
|
99
|
-
|
|
100
|
-
} else if (typeof phrase === 'string' && phrase.length) {
|
|
101
|
-
// Speak it
|
|
197
|
+
// Handle regular strings with retry logic
|
|
102
198
|
const totalRetries = 3;
|
|
103
199
|
let retriesLeft = totalRetries;
|
|
200
|
+
|
|
201
|
+
while (active && retriesLeft > 0) {
|
|
202
|
+
try {
|
|
203
|
+
if (aria) addChildrenToAriaDiv({ text: phrase, lang });
|
|
204
|
+
else await speak(phrase, utterances, lang, voice);
|
|
205
|
+
retriesLeft = 0; // Exit retry loop on success
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e instanceof SpeechSynthesisErrorEvent) {
|
|
208
|
+
if (e.error === 'network') {
|
|
209
|
+
retriesLeft--;
|
|
210
|
+
console.warn(
|
|
211
|
+
`Speech synthesis network error. Retries left: ${retriesLeft}`,
|
|
212
|
+
);
|
|
213
|
+
await delay(500 * (totalRetries - retriesLeft));
|
|
214
|
+
} else if (
|
|
215
|
+
e.error === 'canceled' ||
|
|
216
|
+
e.error === 'interrupted'
|
|
217
|
+
) {
|
|
218
|
+
// Cancel or interrupt error (ignore)
|
|
219
|
+
retriesLeft = 0;
|
|
220
|
+
} else {
|
|
221
|
+
throw new Error(`SpeechSynthesisErrorEvent: ${e.error}`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else if (phrase instanceof SpeechSynthesisUtterance) {
|
|
229
|
+
// Handle SpeechSynthesisUtterance objects with retry logic
|
|
230
|
+
const totalRetries = 3;
|
|
231
|
+
let retriesLeft = totalRetries;
|
|
232
|
+
const text = phrase.text;
|
|
233
|
+
const objectLang = phrase?.lang;
|
|
234
|
+
const objectVoice = phrase?.voice;
|
|
235
|
+
|
|
104
236
|
while (active && retriesLeft > 0) {
|
|
105
237
|
try {
|
|
106
|
-
|
|
107
|
-
|
|
238
|
+
if (text) {
|
|
239
|
+
if (aria) addChildrenToAriaDiv({ text, lang: objectLang });
|
|
240
|
+
else
|
|
241
|
+
await speak(text, utterances, objectLang, objectVoice?.name);
|
|
242
|
+
retriesLeft = 0; // Exit retry loop on success
|
|
243
|
+
}
|
|
108
244
|
} catch (e) {
|
|
109
245
|
if (e instanceof SpeechSynthesisErrorEvent) {
|
|
110
246
|
if (e.error === 'network') {
|
|
@@ -128,20 +264,26 @@ function speakSeries(
|
|
|
128
264
|
}
|
|
129
265
|
}
|
|
130
266
|
} else if (typeof phrase === 'function') {
|
|
131
|
-
|
|
267
|
+
// Handle functions
|
|
268
|
+
const seriesResult = speakSeries(phrase(), aria, lang, voice, false);
|
|
132
269
|
nestedSeriesResults.push(seriesResult);
|
|
133
270
|
await seriesResult.series;
|
|
134
271
|
} else if (Array.isArray(phrase)) {
|
|
135
|
-
//
|
|
136
|
-
const seriesResult = speakSeries(phrase, lang, false);
|
|
272
|
+
// Handle nested arrays
|
|
273
|
+
const seriesResult = speakSeries(phrase, aria, lang, voice, false);
|
|
137
274
|
nestedSeriesResults.push(seriesResult);
|
|
138
275
|
await seriesResult.series;
|
|
139
276
|
}
|
|
140
277
|
}
|
|
141
278
|
} finally {
|
|
142
279
|
active = false;
|
|
280
|
+
// Call completion logic only for the original (root) series
|
|
281
|
+
if (root && aria) {
|
|
282
|
+
focusElementForAria();
|
|
283
|
+
}
|
|
143
284
|
}
|
|
144
285
|
})();
|
|
286
|
+
|
|
145
287
|
return {
|
|
146
288
|
series: seriesChain,
|
|
147
289
|
get active() {
|
|
@@ -154,11 +296,25 @@ function speakSeries(
|
|
|
154
296
|
if (!active) {
|
|
155
297
|
return;
|
|
156
298
|
}
|
|
299
|
+
|
|
157
300
|
if (root) {
|
|
158
|
-
|
|
301
|
+
if (aria) {
|
|
302
|
+
const element = createAriaElement();
|
|
303
|
+
|
|
304
|
+
if (element) {
|
|
305
|
+
ariaLabelPhrases = [];
|
|
306
|
+
cleanAriaLabelParent();
|
|
307
|
+
element.focus();
|
|
308
|
+
focusCanvas();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
synth.cancel(); // Cancel all ongoing speech
|
|
159
315
|
}
|
|
160
|
-
nestedSeriesResults.forEach((
|
|
161
|
-
|
|
316
|
+
nestedSeriesResults.forEach((nestedSeriesResult) => {
|
|
317
|
+
nestedSeriesResult.cancel();
|
|
162
318
|
});
|
|
163
319
|
active = false;
|
|
164
320
|
},
|
|
@@ -166,8 +322,13 @@ function speakSeries(
|
|
|
166
322
|
}
|
|
167
323
|
|
|
168
324
|
let currentSeries: SeriesResult | undefined;
|
|
169
|
-
export default function (
|
|
325
|
+
export default function (
|
|
326
|
+
toSpeak: SpeechType,
|
|
327
|
+
aria: boolean,
|
|
328
|
+
lang: string = 'en-US',
|
|
329
|
+
voice?: string,
|
|
330
|
+
) {
|
|
170
331
|
currentSeries && currentSeries.cancel();
|
|
171
|
-
currentSeries = speakSeries(toSpeak, lang);
|
|
332
|
+
currentSeries = speakSeries(toSpeak, aria, lang, voice);
|
|
172
333
|
return currentSeries;
|
|
173
334
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - `restoreFocus()`: Restores focus to the last stored element and removes it from the stack. Returns `true` if successful, `false` otherwise.
|
|
18
18
|
* - `clearFocusStack()`: Empties the focus stack.
|
|
19
19
|
*/
|
|
20
|
-
import
|
|
20
|
+
import * as s from 'solid-js';
|
|
21
21
|
import { type ElementNode } from '@lightningtv/solid';
|
|
22
22
|
|
|
23
23
|
interface FocusStackContextType {
|
|
@@ -26,13 +26,16 @@ interface FocusStackContextType {
|
|
|
26
26
|
clearFocusStack: () => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const FocusStackContext = createContext<FocusStackContextType | undefined>(undefined);
|
|
29
|
+
const FocusStackContext = s.createContext<FocusStackContextType | undefined>(undefined);
|
|
30
30
|
|
|
31
|
-
export function FocusStackProvider(props: { children: JSX.Element}) {
|
|
32
|
-
const [_focusStack, setFocusStack] = createSignal<ElementNode[]>([]);
|
|
31
|
+
export function FocusStackProvider(props: { children: s.JSX.Element}) {
|
|
32
|
+
const [_focusStack, setFocusStack] = s.createSignal<ElementNode[]>([]);
|
|
33
33
|
|
|
34
34
|
function storeFocus(element: ElementNode, prevElement?: ElementNode) {
|
|
35
|
-
|
|
35
|
+
const elm = prevElement || element;
|
|
36
|
+
if (elm) {
|
|
37
|
+
setFocusStack(stack => [...stack, elm]);
|
|
38
|
+
}
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
function restoreFocus(): boolean {
|
|
@@ -59,10 +62,18 @@ export function FocusStackProvider(props: { children: JSX.Element}) {
|
|
|
59
62
|
);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
export function useFocusStack() {
|
|
63
|
-
const context = useContext(FocusStackContext);
|
|
65
|
+
export function useFocusStack(autoClear = true) {
|
|
66
|
+
const context = s.useContext(FocusStackContext);
|
|
64
67
|
if (!context) {
|
|
65
68
|
throw new Error("useFocusStack must be used within a FocusStackProvider");
|
|
66
69
|
}
|
|
70
|
+
|
|
71
|
+
if (autoClear) {
|
|
72
|
+
s.onCleanup(() => {
|
|
73
|
+
// delay clearing the focus stack so restoreFocus can happen first.
|
|
74
|
+
setTimeout(() => context.clearFocusStack(), 5);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
return context;
|
|
68
79
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as s from 'solid-js'
|
|
2
|
+
import * as lng from '@lightningtv/solid'
|
|
3
|
+
|
|
4
|
+
interface Destroyable {
|
|
5
|
+
(props: lng.NodeProps): s.JSX.Element;
|
|
6
|
+
destroy: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTag(children: s.JSX.Element): Destroyable {
|
|
10
|
+
const [texture, setTexture] = s.createSignal<lng.Texture | null | undefined>(null);
|
|
11
|
+
const Tag = <view
|
|
12
|
+
display='flex'
|
|
13
|
+
onLayout={(n) => {
|
|
14
|
+
if (n.preFlexwidth && n.width !== n.preFlexwidth) {
|
|
15
|
+
n.rtt = true;
|
|
16
|
+
setTimeout(() => setTexture(n.texture), 1);
|
|
17
|
+
}
|
|
18
|
+
}}
|
|
19
|
+
parent={lng.rootNode} children={children}
|
|
20
|
+
textureOptions={{
|
|
21
|
+
preventCleanup: true
|
|
22
|
+
}} /> as any as lng.ElementNode
|
|
23
|
+
Tag.render(false);
|
|
24
|
+
|
|
25
|
+
const TagComponent = (props: lng.NodeProps) => {
|
|
26
|
+
return <view color={0xffffffff} autosize {...props} texture={texture()} />;
|
|
27
|
+
};
|
|
28
|
+
TagComponent.destroy = () => Tag.destroy();
|
|
29
|
+
|
|
30
|
+
return TagComponent;
|
|
31
|
+
}
|