@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.
Files changed (33) hide show
  1. package/dist/esm/index.js +466 -5
  2. package/dist/esm/index.js.map +1 -1
  3. package/dist/source/index.js +1 -0
  4. package/dist/source/primitives/announcer/announcer.js +121 -0
  5. package/dist/source/primitives/announcer/index.js +8 -0
  6. package/dist/source/primitives/announcer/speech.js +152 -0
  7. package/dist/source/primitives/createInfiniteItems.js +45 -0
  8. package/dist/source/primitives/createSpriteMap.js +17 -0
  9. package/dist/source/primitives/index.js +5 -0
  10. package/dist/source/primitives/useFocusManager.js +88 -0
  11. package/dist/source/primitives/withPadding.js +48 -0
  12. package/dist/source/solidOpts.js +2 -2
  13. package/dist/types/index.d.ts +1 -0
  14. package/dist/types/primitives/announcer/announcer.d.ts +37 -0
  15. package/dist/types/primitives/announcer/index.d.ts +2 -0
  16. package/dist/types/primitives/announcer/speech.d.ts +10 -0
  17. package/dist/types/primitives/createInfiniteItems.d.ts +27 -0
  18. package/dist/types/primitives/createSpriteMap.d.ts +8 -0
  19. package/dist/types/primitives/index.d.ts +5 -0
  20. package/dist/types/primitives/useFocusManager.d.ts +46 -0
  21. package/dist/types/primitives/withPadding.d.ts +3 -0
  22. package/package.json +5 -2
  23. package/src/index.ts +1 -0
  24. package/src/primitives/announcer/announcer.ts +190 -0
  25. package/src/primitives/announcer/index.ts +10 -0
  26. package/src/primitives/announcer/speech.ts +174 -0
  27. package/src/primitives/createInfiniteItems.ts +66 -0
  28. package/src/primitives/createSpriteMap.ts +31 -0
  29. package/src/primitives/index.ts +5 -0
  30. package/src/primitives/jsx-runtime.d.ts +11 -0
  31. package/src/primitives/useFocusManager.ts +194 -0
  32. package/src/primitives/withPadding.ts +56 -0
  33. 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,5 @@
1
+ export * from './useFocusManager.js';
2
+ export * from './withPadding.js';
3
+ export * from './announcer/index.js';
4
+ export * from './createInfiniteItems.js';
5
+ export * from './createSpriteMap.js';
@@ -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
+ }
@@ -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: 2 /* NodeTypes.Text */, text, parent: undefined };
9
+ return { type: NodeType.Text, text, parent: undefined };
10
10
  },
11
11
  replaceText(node, value) {
12
12
  log('Replace Text: ', node, value);
@@ -6,3 +6,4 @@ export { View } from './View.jsx';
6
6
  export { Text } from './Text.jsx';
7
7
  export * from './utils.js';
8
8
  export * from './render.js';
9
+ export * from './primitives/index.js';
@@ -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,2 @@
1
+ import { Announcer } from './announcer.js';
2
+ export declare const useAnnouncer: () => Announcer;
@@ -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,8 @@
1
+ export interface SpriteDef {
2
+ name: string;
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export declare function createSpriteMap(src: string, subTextures: SpriteDef[]): Record<string, SpecificTextureRef<"SubTexture">>;
@@ -0,0 +1,5 @@
1
+ export * from './useFocusManager.js';
2
+ export * from './withPadding.js';
3
+ export * from './announcer/index.js';
4
+ export * from './createInfiniteItems.js';
5
+ export * from './createSpriteMap.js';
@@ -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[]>;
@@ -0,0 +1,3 @@
1
+ import type { ElementNode } from '@lightningtv/core';
2
+ export type withPaddingInput = number | [number, number] | [number, number, number] | [number, number, number, number];
3
+ export declare function withPadding(el: ElementNode, padding: () => withPaddingInput): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningtv/solid",
3
- "version": "0.0.2",
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.1"
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
@@ -15,3 +15,4 @@ export { View } from './View.jsx';
15
15
  export { Text } from './Text.jsx';
16
16
  export * from './utils.js';
17
17
  export * from './render.js';
18
+ export * from './primitives/index.js';
@@ -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
+ };