@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.
- package/LICENSE +27 -0
- package/README.md +52 -0
- package/dist/FocusLayer.vue.d.ts +33 -0
- package/dist/FocusSection.vue.d.ts +36 -0
- package/dist/Focusable.vue.d.ts +42 -0
- package/dist/core.d.ts +12 -0
- package/dist/engine/index.d.ts +5 -0
- package/dist/engine/spatial-navigation.d.ts +19 -0
- package/dist/engine/types.d.ts +72 -0
- package/dist/focus-layer-context.d.ts +10 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1351 -0
- package/dist/index.js.map +1 -0
- package/dist/keep-alive-bridge.d.ts +6 -0
- package/dist/key-source/native-event.d.ts +23 -0
- package/dist/layer-stack.d.ts +3 -0
- package/dist/section-registry.d.ts +8 -0
- package/dist/useFocusSection.d.ts +23 -0
- package/dist/useFocusable.d.ts +24 -0
- package/package.json +50 -0
- package/src/FocusLayer.vue +67 -0
- package/src/FocusSection.vue +38 -0
- package/src/Focusable.vue +41 -0
- package/src/core.ts +25 -0
- package/src/engine/ATTRIBUTION.md +37 -0
- package/src/engine/LICENSE +362 -0
- package/src/engine/index.ts +16 -0
- package/src/engine/spatial-navigation.ts +1249 -0
- package/src/engine/types.ts +85 -0
- package/src/focus-layer-context.ts +11 -0
- package/src/index.ts +34 -0
- package/src/keep-alive-bridge.ts +17 -0
- package/src/key-source/native-event.ts +124 -0
- package/src/layer-stack.ts +18 -0
- package/src/section-registry.ts +21 -0
- package/src/useFocusSection.ts +61 -0
- package/src/useFocusable.ts +82 -0
|
@@ -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
|
+
}
|