@chancestv/tv-focus 0.1.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @shell/core/focus 公共类型
3
+ */
4
+
5
+ export type Direction = 'up' | 'down' | 'left' | 'right'
6
+
7
+ export type Restrict = 'self-first' | 'self-only' | 'none'
8
+
9
+ export type EnterTo = '' | 'last-focused' | 'default-element'
10
+
11
+ /**
12
+ * <extSelector>
13
+ * - 'querySelectorAll' 字符串
14
+ * - NodeList / Element 数组
15
+ * - 单个 Element
16
+ * - '@<sectionId>' / '@'(指定 section)
17
+ */
18
+ export type ExtSelector =
19
+ | string
20
+ | Element
21
+ | Element[]
22
+ | NodeListOf<Element>
23
+ | ArrayLike<Element>
24
+
25
+ export interface LeaveFor {
26
+ left?: ExtSelector
27
+ right?: ExtSelector
28
+ up?: ExtSelector
29
+ down?: ExtSelector
30
+ }
31
+
32
+ export interface SectionConfig {
33
+ /** querySelectorAll 字符串、NodeList、Element 数组、单个 Element(不接受 "@" 语法)*/
34
+ selector?: ExtSelector
35
+ /** 是否只允许严格方向(不允许斜向重叠) */
36
+ straightOnly?: boolean
37
+ /** 重叠判定阈值 [0, 1] */
38
+ straightOverlapThreshold?: number
39
+ /** 离开 section 时记住焦点来源(再返回时可复焦) */
40
+ rememberSource?: boolean
41
+ /** section 整体禁用 */
42
+ disabled?: boolean
43
+ /** 进入 section 时默认聚焦的元素 */
44
+ defaultElement?: ExtSelector
45
+ /** 进入 section 时的策略 */
46
+ enterTo?: EnterTo
47
+ /** 跨方向跳转规则 */
48
+ leaveFor?: LeaveFor | null
49
+ /** 边界策略 */
50
+ restrict?: Restrict
51
+ /** 不自动加 tabindex 的元素选择器 */
52
+ tabIndexIgnoreList?: string
53
+ /** 自定义元素可导航过滤器 */
54
+ navigableFilter?: ((elem: Element, sectionId?: string) => boolean) | null
55
+ /** section id(add 时可显式指定) */
56
+ id?: string
57
+ }
58
+
59
+ export type GlobalConfig = SectionConfig
60
+
61
+ /**
62
+ * SpatialNavigation 公开 API(薄类型,主体实现来自 fork 源码)。
63
+ * 详见 spatial-navigation.ts。
64
+ */
65
+ export interface SpatialNavigationAPI {
66
+ init(): void
67
+ uninit(): void
68
+ clear(): void
69
+ reset(): void
70
+ set(config: SectionConfig): void
71
+ set(sectionId: string, config: SectionConfig): void
72
+ add(config: SectionConfig): string
73
+ add(sectionId: string, config: SectionConfig): string
74
+ remove(sectionId: string): boolean
75
+ disable(sectionId: string): boolean
76
+ enable(sectionId: string): boolean
77
+ pause(): void
78
+ resume(): void
79
+ focus(): boolean
80
+ focus(silent: boolean): boolean
81
+ focus(selector: ExtSelector, silent?: boolean): boolean
82
+ move(direction: Direction, selector?: ExtSelector): boolean
83
+ makeFocusable(sectionId?: string): void
84
+ setDefaultSection(sectionId?: string): void
85
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * FocusLayer 通过 provide 注入的上下文。
3
+ * 内部 useFocusSection 通过 inject 拿到本对象,并把自己的 sectionId 注册进来,
4
+ * 避免被 layer onMounted 时一并禁用。
5
+ */
6
+ export interface FocusLayerContext {
7
+ layerId: string
8
+ registerInnerSection(sectionId: string): void
9
+ }
10
+
11
+ export const FOCUS_LAYER_KEY: symbol = Symbol('dwy:focus-layer')
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @shell/core/focus
3
+ *
4
+ * TV 焦点系统:空间导航引擎(./engine,fork 自 luke-chang/js-spatial-navigation, MPL 2.0)
5
+ * + Vue 3 适配层(组件 / composable / 原生按键 adapter)。
6
+ *
7
+ * 业务层禁止直连本入口,请用 @shell/tv-ui 的 EPage/ERow/EColumn/EFocusGroup 等组件。
8
+ */
9
+ export { setupFocus, SpatialNavigation } from './core'
10
+ export type { SetupFocusOptions } from './core'
11
+
12
+ export { nativeKeyAdapter } from './key-source/native-event'
13
+
14
+ export { useFocusable } from './useFocusable'
15
+ export type { UseFocusableOptions, UseFocusableResult } from './useFocusable'
16
+
17
+ export { useFocusSection, FOCUS_SECTION_KEY } from './useFocusSection'
18
+ export type { UseFocusSectionOptions, FocusSectionContext } from './useFocusSection'
19
+
20
+ export { useKeepAliveFocus } from './keep-alive-bridge'
21
+ export { hasOpenLayer } from './layer-stack'
22
+
23
+ export { default as Focusable } from './Focusable.vue'
24
+ export { default as FocusSection } from './FocusSection.vue'
25
+ export { default as FocusLayer } from './FocusLayer.vue'
26
+
27
+ export type {
28
+ Direction,
29
+ Restrict,
30
+ EnterTo,
31
+ LeaveFor,
32
+ SectionConfig,
33
+ ExtSelector,
34
+ } from './engine'
@@ -0,0 +1,17 @@
1
+ import { getCurrentInstance, onActivated, onDeactivated } from 'vue'
2
+ import SpatialNavigation from './engine'
3
+
4
+ /**
5
+ * 在 Vue KeepAlive 缓存页面中使用:被切走时暂停 SpatialNavigation,被切回时恢复。
6
+ *
7
+ * 用法:在被 KeepAlive 包裹的 view 组件 setup 顶部调用一次。
8
+ */
9
+ export function useKeepAliveFocus(): void {
10
+ if (!getCurrentInstance()) return
11
+ onActivated(() => {
12
+ ;(SpatialNavigation as any).resume()
13
+ })
14
+ onDeactivated(() => {
15
+ ;(SpatialNavigation as any).pause()
16
+ })
17
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 把 OTT 原生方向键事件(CustomEvent)转发为标准 keydown,
3
+ * 让 @shell/core/focus 内置的 keydown 监听透明响应原生遥控器。
4
+ *
5
+ * 使用:在 main.ts 调一次即可:
6
+ *
7
+ * import { nativeKeyAdapter } from '@shell/core/focus'
8
+ * nativeKeyAdapter('ott:native-keydown')
9
+ *
10
+ * 期望 CustomEvent.detail 形如 { key?, keyCode?, keyCodeString? } 之一。
11
+ */
12
+
13
+ interface NativeKeyDetail {
14
+ key?: string
15
+ keyCode?: number
16
+ keyCodeString?: string
17
+ }
18
+
19
+ // Android KeyEvent → DOM key 映射(覆盖最常见键)
20
+ const ANDROID_TO_KEY: Record<number, string> = {
21
+ 19: 'ArrowUp',
22
+ 20: 'ArrowDown',
23
+ 21: 'ArrowLeft',
24
+ 22: 'ArrowRight',
25
+ 23: 'Enter',
26
+ 66: 'Enter',
27
+ 4: 'Escape',
28
+ 67: 'Backspace',
29
+ }
30
+ const KEY_STR_TO_KEY: Record<string, string> = {
31
+ KEYCODE_DPAD_UP: 'ArrowUp',
32
+ KEYCODE_DPAD_DOWN: 'ArrowDown',
33
+ KEYCODE_DPAD_LEFT: 'ArrowLeft',
34
+ KEYCODE_DPAD_RIGHT: 'ArrowRight',
35
+ KEYCODE_DPAD_CENTER: 'Enter',
36
+ KEYCODE_ENTER: 'Enter',
37
+ KEYCODE_BACK: 'Escape',
38
+ KEYCODE_DEL: 'Backspace',
39
+ }
40
+ const KEY_TO_CODE: Record<string, number> = {
41
+ ArrowUp: 38,
42
+ ArrowDown: 40,
43
+ ArrowLeft: 37,
44
+ ArrowRight: 39,
45
+ Enter: 13,
46
+ Escape: 27,
47
+ Backspace: 8,
48
+ }
49
+
50
+ function normalize(detail: NativeKeyDetail): { key: string; keyCode: number } | null {
51
+ let key = detail.key
52
+ if (!key && typeof detail.keyCodeString === 'string') {
53
+ key = KEY_STR_TO_KEY[detail.keyCodeString]
54
+ }
55
+ if (!key && typeof detail.keyCode === 'number') {
56
+ key = ANDROID_TO_KEY[detail.keyCode]
57
+ }
58
+ if (!key) return null
59
+ const keyCode = KEY_TO_CODE[key] ?? 0
60
+ return { key, keyCode }
61
+ }
62
+
63
+ let attachedEventName: string | null = null
64
+ let handler: ((e: Event) => void) | null = null
65
+
66
+ /**
67
+ * 构造一个等价的 KeyboardEvent。
68
+ *
69
+ * 为什么不在构造器里传 keyCode/which:Blink 全版本(含 Chromium 53 / Chrome 66)的
70
+ * KeyboardEvent 构造器都不读 KeyboardEventInit 里的 keyCode/which(它们是只读 legacy 属性,
71
+ * 不在 init 字典内),构造产物 evt.keyCode 恒为 0。而 SpatialNavigation.onKeyDown/onKeyUp
72
+ * 完全靠 evt.keyCode(37/38/39/40/13)路由方向键与确定键,keyCode=0 会导致全部失效。
73
+ * 必须构造后用 Object.defineProperty 在实例上加 own 属性 shadow 掉原型 getter,强制写入 keyCode/which。
74
+ */
75
+ function makeKbEvent(type: 'keydown' | 'keyup', key: string, keyCode: number): KeyboardEvent {
76
+ let evt: any
77
+ try {
78
+ evt = new KeyboardEvent(type, { key, bubbles: true, cancelable: true })
79
+ } catch {
80
+ // 极旧内核无 KeyboardEvent 构造器时兜底
81
+ evt = document.createEvent('Event')
82
+ evt.initEvent(type, true, true)
83
+ Object.defineProperty(evt, 'key', { value: key, configurable: true })
84
+ }
85
+ Object.defineProperty(evt, 'keyCode', { value: keyCode, configurable: true })
86
+ Object.defineProperty(evt, 'which', { value: keyCode, configurable: true })
87
+ return evt as KeyboardEvent
88
+ }
89
+
90
+ /**
91
+ * 注册原生事件名监听器(如 'ott:native-keydown'),把它转为 keydown + keyup 派发到 window。
92
+ *
93
+ * 为什么成对派发:Android 壳 dispatchKeyEvent 只在 ACTION_DOWN 透传 onNativeKeyDown,
94
+ * 不发 ACTION_UP。但 focus-core 的 'enter-up' 事件挂在原生 keyup 上(spatial-navigation.ts onKeyUp),
95
+ * EButton/useFocusable 又只监听 'sn:enter-up'。只派 keydown 会导致 OK 键完全失活。
96
+ * 这里同步补一个 keyup,让 OTT 链路对外契约与浏览器键盘一致:"按一次 = down+up 成对"。
97
+ *
98
+ * 重复调用会先卸载上一个监听。
99
+ */
100
+ export function nativeKeyAdapter(eventName: string): () => void {
101
+ if (typeof window === 'undefined') return () => undefined
102
+ // 卸载旧的
103
+ if (attachedEventName && handler) {
104
+ window.removeEventListener(attachedEventName, handler as EventListener)
105
+ }
106
+ attachedEventName = eventName
107
+ handler = (e: Event) => {
108
+ const detail = ((e as CustomEvent).detail || {}) as NativeKeyDetail
109
+ const n = normalize(detail)
110
+ if (!n) return
111
+ window.dispatchEvent(makeKbEvent('keydown', n.key, n.keyCode))
112
+ window.dispatchEvent(makeKbEvent('keyup', n.key, n.keyCode))
113
+ }
114
+ window.addEventListener(eventName, handler as EventListener)
115
+ return () => {
116
+ if (attachedEventName && handler) {
117
+ window.removeEventListener(attachedEventName, handler as EventListener)
118
+ attachedEventName = null
119
+ handler = null
120
+ }
121
+ }
122
+ }
123
+
124
+ export default nativeKeyAdapter
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 模态层计数器:FocusLayer 挂载 +1、卸载 -1。
3
+ * 供 @shell/core 的 useBackButton 判断「当前有无打开的模态层」,
4
+ * 有则把 Back 交给最上层弹层自己关,避免全局返回键误触发路由后退/退出。
5
+ */
6
+ let openCount = 0
7
+
8
+ export function pushLayer(): void {
9
+ openCount += 1
10
+ }
11
+
12
+ export function popLayer(): void {
13
+ openCount = Math.max(0, openCount - 1)
14
+ }
15
+
16
+ export function hasOpenLayer(): boolean {
17
+ return openCount > 0
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 维护当前所有活跃 FocusSection 的 id 集合。
3
+ * 上游 SpatialNavigation 未暴露 listSections,FocusLayer 进入/离开时需要批量 disable/enable
4
+ * section,依赖此 registry 提供"哪些 section 当前存在"的信息。
5
+ */
6
+
7
+ const activeSectionIds = new Set<string>()
8
+
9
+ export function registerSection(id: string): void {
10
+ activeSectionIds.add(id)
11
+ }
12
+
13
+ export function unregisterSection(id: string): void {
14
+ activeSectionIds.delete(id)
15
+ }
16
+
17
+ export function listSections(): string[] {
18
+ const out: string[] = []
19
+ activeSectionIds.forEach((id) => out.push(id))
20
+ return out
21
+ }
@@ -0,0 +1,61 @@
1
+ import { inject, onMounted, onUnmounted, provide } from 'vue'
2
+ import SpatialNavigation from './engine'
3
+ import type { Restrict, EnterTo, LeaveFor, ExtSelector } from './engine'
4
+ import { registerSection, unregisterSection } from './section-registry'
5
+ import { FOCUS_LAYER_KEY, type FocusLayerContext } from './focus-layer-context'
6
+
7
+ let sectionCounter = 0
8
+
9
+ export interface UseFocusSectionOptions {
10
+ /** section id;未提供时自动生成 */
11
+ id?: string
12
+ /** 边界策略;默认 'self-first' */
13
+ restrict?: Restrict
14
+ /** 进入策略;默认 'last-focused' */
15
+ enterTo?: EnterTo
16
+ /** 跨方向跳转规则 */
17
+ leaveFor?: LeaveFor | null
18
+ /** 严格方向(无重叠时不跳)*/
19
+ straightOnly?: boolean
20
+ /** 离开后记忆来源焦点,默认 true */
21
+ rememberSource?: boolean
22
+ /** 进入 section 时默认聚焦的元素(CSS selector / Element) */
23
+ defaultElement?: ExtSelector
24
+ }
25
+
26
+ export interface FocusSectionContext {
27
+ sectionId: string
28
+ selectorAttr: string
29
+ }
30
+
31
+ export const FOCUS_SECTION_KEY: symbol = Symbol('dwy:focus-section')
32
+
33
+ export function useFocusSection(options: UseFocusSectionOptions = {}): FocusSectionContext {
34
+ const sectionId = options.id ?? `dwy-section-${++sectionCounter}`
35
+ const selectorAttr = sectionId
36
+ const enclosingLayer = inject<FocusLayerContext | null>(FOCUS_LAYER_KEY, null)
37
+
38
+ onMounted(() => {
39
+ ;(SpatialNavigation as any).add(sectionId, {
40
+ selector: `[data-sn-section="${selectorAttr}"]`,
41
+ restrict: options.restrict ?? 'self-first',
42
+ enterTo: options.enterTo ?? 'last-focused',
43
+ leaveFor: options.leaveFor ?? null,
44
+ straightOnly: options.straightOnly ?? false,
45
+ rememberSource: options.rememberSource ?? true,
46
+ defaultElement: options.defaultElement,
47
+ })
48
+ registerSection(sectionId)
49
+ // 在 FocusLayer 内的 section 标记自己,避免被 layer 一并禁用
50
+ if (enclosingLayer) enclosingLayer.registerInnerSection(sectionId)
51
+ })
52
+
53
+ onUnmounted(() => {
54
+ ;(SpatialNavigation as any).remove(sectionId)
55
+ unregisterSection(sectionId)
56
+ })
57
+
58
+ const ctx: FocusSectionContext = { sectionId, selectorAttr }
59
+ provide(FOCUS_SECTION_KEY, ctx)
60
+ return ctx
61
+ }
@@ -0,0 +1,82 @@
1
+ import { getCurrentInstance, inject, onMounted, onUnmounted, ref, shallowRef } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import { FOCUS_SECTION_KEY, type FocusSectionContext } from './useFocusSection'
4
+
5
+ export interface UseFocusableOptions {
6
+ /** 业务侧 key,会写到 data-focus-key,便于调试与脚本聚焦 */
7
+ focusKey?: string
8
+ /** Enter/OK 键回调 */
9
+ onEnter?: () => void
10
+ /** 获得焦点 */
11
+ onFocus?: () => void
12
+ /** 失去焦点 */
13
+ onBlur?: () => void
14
+ }
15
+
16
+ export interface UseFocusableResult {
17
+ /** 绑定到 DOM 元素的 ref,必须接到 v-bind 或 ref="elRef" 上 */
18
+ elRef: Ref<HTMLElement | null>
19
+ /** 响应式焦点状态 */
20
+ focused: Ref<boolean>
21
+ }
22
+
23
+ /**
24
+ * 把当前 DOM 元素注册为可聚焦项,自动归属到最近的 useFocusSection。
25
+ *
26
+ * 必须在 useFocusSection 提供的 provide 作用域内调用——
27
+ * 通常表示组件树外层有 <FocusSection> 或调用过 useFocusSection。
28
+ */
29
+ export function useFocusable(options: UseFocusableOptions = {}): UseFocusableResult {
30
+ const elRef = shallowRef<HTMLElement | null>(null)
31
+ const focused = ref(false)
32
+ const ctx = inject<FocusSectionContext | null>(FOCUS_SECTION_KEY, null)
33
+
34
+ function handleFocused() {
35
+ focused.value = true
36
+ if (options.onFocus) options.onFocus()
37
+ }
38
+ function handleUnfocused() {
39
+ focused.value = false
40
+ if (options.onBlur) options.onBlur()
41
+ }
42
+ function handleEnter() {
43
+ if (options.onEnter) options.onEnter()
44
+ }
45
+
46
+ onMounted(() => {
47
+ const el = elRef.value
48
+ if (!el) {
49
+ if (getCurrentInstance()) {
50
+ console.warn('[dwy:focus] useFocusable 的 elRef 未绑定到任何元素')
51
+ }
52
+ return
53
+ }
54
+ if (ctx) {
55
+ el.setAttribute('data-sn-section', ctx.selectorAttr)
56
+ } else {
57
+ console.warn('[dwy:focus] useFocusable 未找到外层 FocusSection;当前元素不会被纳入任何导航区域')
58
+ }
59
+ if (options.focusKey) {
60
+ el.setAttribute('data-focus-key', options.focusKey)
61
+ }
62
+ if (el.tabIndex < 0 && !el.getAttribute('tabindex')) {
63
+ el.setAttribute('tabindex', '-1')
64
+ }
65
+ el.addEventListener('sn:focused', handleFocused)
66
+ el.addEventListener('sn:unfocused', handleUnfocused)
67
+ el.addEventListener('sn:enter-up', handleEnter)
68
+ // 注:子 Focusable 的 onMounted 早于父 FocusSection 的 onMounted,
69
+ // 这里不再主动调用 makeFocusable —— tabindex 已在上一句自己加好,
70
+ // SpatialNavigation 在 navigate 时会按 selector 重新 query 到本元素。
71
+ })
72
+
73
+ onUnmounted(() => {
74
+ const el = elRef.value
75
+ if (!el) return
76
+ el.removeEventListener('sn:focused', handleFocused)
77
+ el.removeEventListener('sn:unfocused', handleUnfocused)
78
+ el.removeEventListener('sn:enter-up', handleEnter)
79
+ })
80
+
81
+ return { elRef, focused }
82
+ }