vue-tv-focus
v0.1.0
Published
Vue 3 电视端焦点管理库 · 方向键/D-pad 空间导航 · FocusGroup · 懒采集与节流 · v-focus / useFocus
Maintainers
Readme
Vue-TV-Focus
安装
npm install vue-tv-focus依赖:Vue 3.4+(以 peer 形式依赖,需在业务项目中自行安装)。
功能概览
| 能力 | 说明 |
|------|------|
| 空间寻址 | 半平面候选过滤 + insiders 优先 + 加权欧几里得距离(Android FocusFinder 风格) |
| FocusGroup | 按 group 分片,当前组无候选时跨组寻焦(v-focus-group + groupId) |
| 性能 | 懒采集几何、坐标缓存、requestIdleCallback 批量更新、节流 |
| Vue 集成 | v-focus / v-focus-group 指令、useFocus()、navigate(dir)、响应式 activeId |
| 生命周期 | onFocus / onBlur / onSelect / onMove;长按/双击确认键(onLongPress、onDblEnter);每元素方向键/确认键自定义 |
| 事件 | navbeforefocus(可 preventDefault)、navnotarget |
| 调试 | debug(控制台)、visualDebug(页面高亮当前焦点框) |
快速开始
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createFocusApp, vFocus, vFocusGroup } from 'vue-tv-focus'
const app = createApp(App)
createFocusApp(app, { throttleMs: 120, debug: false })
app.directive('focus', vFocus)
app.directive('focus-group', vFocusGroup)
app.mount('#app')配置
createFocusApp(app, options) 的 options 类型为 FocusOptions:
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| throttleMs | number | 120 | 方向键节流间隔(ms),建议 100–150 |
| debug | boolean | false | 为 true 时在控制台输出寻焦路径、候选数、动作 |
| visualDebug | boolean | false | 为 true 时在页面上用边框高亮当前焦点元素 |
| weightPrimary | number | 1 | 主轴距离权重(wp),越大越优先“正对方向” |
| weightSecondary | number | 0.5 | 副轴距离权重(ws),通常 wp >> ws |
| smoothTime | number | 300 | 滚动动画时长(ms) |
| easing | string \| EasingFn | 'quart-out' | 滚动缓动:内置名或自定义函数 |
| offsetDistanceX | number | 50 | 落焦后目标与视口边缘的 X 留白(px) |
| offsetDistanceY | number | 50 | 落焦后目标与视口边缘的 Y 留白(px) |
| distanceToCenter | boolean | false | 落焦是否在滚动区域居中 |
| endToNext | boolean | false | 上一焦点滚动结束后再落下一个焦点的焦 |
| longPressTime | number | 700 | 长按判定时长(ms) |
| dblEnterTime | number | 200 | 双击确认键判定时间(ms) |
| scrollDelay | number | 0 | 按住方向键时,第一次移动焦点前的延迟(ms),0 表示无延迟 |
可从 vue-tv-focus 引入 defaultFocusOptions 做合并:
import { defaultFocusOptions } from 'vue-tv-focus'
createFocusApp(app, { ...defaultFocusOptions, throttleMs: 150 })使用方式
初始化与指令注册
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createFocusApp, vFocus, vFocusGroup } from 'vue-tv-focus'
const app = createApp(App)
const teardown = createFocusApp(app, {
throttleMs: 120,
debug: false,
visualDebug: false,
weightPrimary: 1,
weightSecondary: 0.5,
})
app.directive('focus', vFocus)
app.directive('focus-group', vFocusGroup)
app.mount('#app')
// 如需销毁焦点系统(如应用卸载),可调用 teardown()v-focus
可聚焦元素使用 v-focus,绑定值为对象或字符串(作为 focusKey):
- group — 组名,同组内优先寻焦
- groupId — 所属 FocusGroup 容器的 id(与
v-focus-group的id对应),用于边界路由时上树到父组 - focusKey — 业务侧标识,便于调试与按 key 高亮(需配合
focusStore.getEntries()与当前activeId使用) - priority — 数字,默认
0 - disabled — 为
true时不参与寻焦 - onFocus / onBlur / onSelect / onMove — 生命周期钩子;
onMove(dir)在方向键即将触发移动时调用 - onLongPress / onDblEnter — 长按确认键、双击确认键回调
- enter / dblenter / longPress — 确认键单击/双击/长按时触发(可传
FocusCallbackEvent) - up / right / down / left — 按方向键时触发,参数为连按次数
num,可用于每元素自定义行为
示例:
<template>
<div
v-focus="{
group: 'main',
groupId: 'main-group',
focusKey: 'card-1',
onFocus: () => log('focus'),
onSelect: () => open(),
}"
:class="{ 'is-focused': currentFocusKey === 'card-1' }"
>
Card 1
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useFocus, focusStore } from 'vue-tv-focus'
const { activeId, navigate, setFocus, isFocused } = useFocus()
// 按 focusKey 高亮:从 store 中根据当前 activeId 取对应条目的 focusKey
const currentFocusKey = computed(() => {
const id = activeId.value
if (!id) return null
return focusStore.getEntries().find((e) => e.id === id)?.focusKey ?? null
})
</script>v-focus-group
标记“空间导航容器”,与 v-focus 的 group / groupId 配合后,当前组无候选时会先尝试滚动,再上树到父组寻焦。
- group — 组名,与子项
v-focus的group一致 - id — 容器唯一 id,与子项
v-focus的groupId一致;不传则用元素id或自动生成 - parentGroupId — 父 FocusGroup 的 id,根容器传
null - scrollable — 是否可滚动(无候选时先滚动),默认
false
示例:
<template>
<div v-focus-group="{ group: 'main', id: 'main-group', parentGroupId: null }">
<div v-focus="{ group: 'main', groupId: 'main-group', focusKey: 'card-1' }">Card 1</div>
<div v-focus="{ group: 'main', groupId: 'main-group', focusKey: 'card-2' }">Card 2</div>
</div>
</template>useFocus()
在 setup 中注入:
| 成员 | 说明 |
|------|------|
| activeId | 当前焦点条目的内部 id(Ref),由库在注册时分配(如 focus-1) |
| navigate(dir) | 按方向移动焦点,dir 为 'up' \| 'down' \| 'left' \| 'right',返回是否处理 |
| setFocus(id) | 编程式设焦到指定 id(id 为条目的内部 id,可通过 focusStore.getEntries() 查看) |
| isFocused(id) | 判断当前焦点是否等于给定 id。若需按 focusKey 判断或做 class 绑定,可用 focusStore.getEntries() 根据 activeId 取对应条目的 focusKey(参见上方示例) |
事件:navbeforefocus / navnotarget
在 document 上监听:
- navbeforefocus — 即将移动焦点前触发,
detail: { dir, nextCandidate, nextId },可调用event.preventDefault()取消本次移动并自行setFocus - navnotarget — 当前组无候选且不可滚动(或已到边界)、且上树/全局仍无候选时触发,
detail: { dir },可用于提示或回退
document.addEventListener('navbeforefocus', (e: CustomEvent) => {
if (e.detail.nextId === 'some-id') e.preventDefault()
})
document.addEventListener('navnotarget', (e: CustomEvent) => {
console.log('no target', e.detail.dir)
})调试
- debug: true — 控制台输出每次
navigate的方向、当前focusKey、同组/候选数、动作(sameGroup/scroll/parentGroup/global)、下一项focusKey或navnotarget - visualDebug: true — 页面固定层高亮当前焦点元素边界框(橙色边框),便于视觉验证
技术设计
开发与贡献
npm install
npm run dev # 启动调试应用(http://localhost:5173)
npm run build # 构建库产物(输出到 dist/)
npm run build:dev # 构建调试应用
npm run typecheck # 类型检查- src/ — 库源码(focus 核心 + Vue 插件)
- dev/ — 调试用 Vue 页面,不随包发布
仓库与反馈:github.com/vega-lee/vue-tv-focus
License
MIT
