@splicetree/plugin-keyboard 0.0.1
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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +147 -0
- package/package.json +32 -0
- package/src/index.ts +252 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @splicetree/plugin-keyboard
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- chore: 搭建 SpliceTree 框架基础架构与工程体系
|
|
8
|
+
|
|
9
|
+
框架架构:
|
|
10
|
+
- 建立无头树(Headless Tree)核心模型与数据结构
|
|
11
|
+
- 定义节点操作 API、事件系统和状态管理机制
|
|
12
|
+
- 设计并实现插件体系(生命周期、注册机制、能力扩展)
|
|
13
|
+
- 引入模块化架构,明确 core / plugins / adapters 的边界
|
|
14
|
+
- 预留扩展点与内部协议,构建可插拔式架构基础
|
|
15
|
+
|
|
16
|
+
适配层与生态:
|
|
17
|
+
- 添加 Vue 3 适配层(渲染无关、纯接口绑定)
|
|
18
|
+
- 设计独立的 UI 层解耦策略,确保跨框架可迁移
|
|
19
|
+
- 预留未来 React/Svelte/WebComponents 的适配接口
|
|
20
|
+
|
|
21
|
+
工程化与工具链:
|
|
22
|
+
- 初始化 monorepo 工程结构(packages + docs)
|
|
23
|
+
- 配置构建工具链 tsdown(多包构建、类型输出)
|
|
24
|
+
- 构建文档系统(VitePress)与基础导航结构
|
|
25
|
+
- 设置开发环境、代码规范(ESLint)、格式化流程
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michael Cocova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @splicetree/plugin-keyboard
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@splicetree/plugin-keyboard)
|
|
4
|
+
[](https://npmcharts.com/compare/%40splicetree%2Fplugin-keyboard?minimal=true)
|
|
5
|
+
[](https://www.npmjs.com/package/@splicetree/plugin-keyboard)
|
|
6
|
+
[](https://www.splicetree.dev)
|
|
7
|
+
[](https://github.com/michaelcocova/splicetree)
|
|
8
|
+
|
|
9
|
+
提供快捷键导航能力:上下移动、展开与收起。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
`pnpm add @splicetree/plugin-keyboard`
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createSpliceTree } from '@splicetree/core'
|
|
19
|
+
import keyboardNavigation from '@splicetree/plugin-keyboard'
|
|
20
|
+
|
|
21
|
+
const tree = createSpliceTree(data, {
|
|
22
|
+
plugins: [keyboardNavigation],
|
|
23
|
+
defaultActive: 'a',
|
|
24
|
+
autoListenKeyboard: true,
|
|
25
|
+
keymap: { expand: 'ArrowRight', collapse: 'ArrowLeft', next: 'ArrowDown', prev: 'ArrowUp' },
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Api
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
|
|
33
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
34
|
+
| -------------------- | ---------------------------------------------------------------------- | --------------- | -------------------------------- |
|
|
35
|
+
| `defaultActive` | `string` | `undefined` | 默认激活节点 |
|
|
36
|
+
| `autoListenKeyboard` | `boolean` | `true` | 是否自动监听键盘事件 |
|
|
37
|
+
| `keymap` | `{ expand?: string; collapse?: string; next?: string; prev?: string }` | `见下` | 快捷键映射(默认:左右上下箭头) |
|
|
38
|
+
| `keyboardTarget` | `HTMLElement` | `document.body` | 键盘监听目标 |
|
|
39
|
+
|
|
40
|
+
### 实例方法
|
|
41
|
+
|
|
42
|
+
| 名称 | 参数 | 说明 |
|
|
43
|
+
| ----------------------- | -------------------------- | ---------------------- |
|
|
44
|
+
| `activeId` | `无` | 当前激活的节点 |
|
|
45
|
+
| `toggleActive(id, on?)` | `id: string; on?: boolean` | 切换或显式设置激活状态 |
|
|
46
|
+
|
|
47
|
+
### 节点方法
|
|
48
|
+
|
|
49
|
+
| 名称 | 参数 | 说明 |
|
|
50
|
+
| ------------------- | -------------- | -------------------- |
|
|
51
|
+
| `toggleActive(on?)` | `on?: boolean` | 切换或显式设置激活态 |
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { SpliceTreePlugin } from "@splicetree/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
type KeyboardTargetType = HTMLElement | string | null | undefined;
|
|
5
|
+
type KeyboardTarget = KeyboardTargetType | (() => KeyboardTargetType);
|
|
6
|
+
declare module '@splicetree/core' {
|
|
7
|
+
interface UseSpliceTreeOptions {
|
|
8
|
+
/**
|
|
9
|
+
* 默认激活节点 ID
|
|
10
|
+
* @default undefined
|
|
11
|
+
*/
|
|
12
|
+
defaultActive?: string;
|
|
13
|
+
/**
|
|
14
|
+
* 是否自动监听键盘事件
|
|
15
|
+
* 开启后,插件会自动监听键盘事件
|
|
16
|
+
* 关闭后,需要手动调用 `listenKeyboard` 方法监听键盘事件
|
|
17
|
+
* @default true
|
|
18
|
+
*/
|
|
19
|
+
autoListenKeyboard?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 键盘导航快捷键
|
|
22
|
+
* @default { expand: 'ArrowRight', collapse: 'ArrowLeft', next: 'ArrowDown', prev: 'ArrowUp' }
|
|
23
|
+
*/
|
|
24
|
+
keymap?: {
|
|
25
|
+
expand?: string;
|
|
26
|
+
collapse?: string;
|
|
27
|
+
next?: string;
|
|
28
|
+
prev?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* 键盘导航目标元素
|
|
32
|
+
* @default document.body
|
|
33
|
+
*/
|
|
34
|
+
keyboardTarget?: KeyboardTarget;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 实例扩展(Keyboard)
|
|
38
|
+
* - activeId:当前激活的节点 id
|
|
39
|
+
* - toggleActive:切换或显式设置某节点的激活态
|
|
40
|
+
*/
|
|
41
|
+
interface SpliceTreeInstance {
|
|
42
|
+
/**
|
|
43
|
+
* 当前激活的节点 id(未激活时为 undefined)
|
|
44
|
+
*/
|
|
45
|
+
activeId?: string;
|
|
46
|
+
/**
|
|
47
|
+
* 切换或显式设置某节点的激活态
|
|
48
|
+
* @param id 节点 id
|
|
49
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
50
|
+
*/
|
|
51
|
+
toggleActive: (id: string, active?: boolean) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 节点扩展(Keyboard)
|
|
55
|
+
* - isActive:当前节点是否激活
|
|
56
|
+
* - toggleActive:切换当前节点的激活态
|
|
57
|
+
*/
|
|
58
|
+
interface SpliceTreeNode {
|
|
59
|
+
/**
|
|
60
|
+
* 当前节点是否为激活态
|
|
61
|
+
*/
|
|
62
|
+
isActive: () => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* 切换当前节点的激活态
|
|
65
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
66
|
+
*/
|
|
67
|
+
toggleActive: (active?: boolean) => void;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
declare const keyboardNavigation: SpliceTreePlugin;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { keyboardNavigation as default, keyboardNavigation };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* 解析键盘监听目标元素
|
|
4
|
+
* 支持传入选择器、元素实例或函数
|
|
5
|
+
*/
|
|
6
|
+
function resolveKeyboardTarget(targe) {
|
|
7
|
+
if (typeof targe === "string") return document.querySelector(targe);
|
|
8
|
+
if (targe instanceof HTMLElement) return targe;
|
|
9
|
+
if (typeof targe === "function") return resolveKeyboardTarget(targe?.());
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const keyboardNavigation = {
|
|
13
|
+
name: "keyboard",
|
|
14
|
+
setup(ctx) {
|
|
15
|
+
const { defaultActive, autoListenKeyboard = true, keyboardTarget, keymap } = ctx.options;
|
|
16
|
+
let activeId = defaultActive;
|
|
17
|
+
Object.defineProperty(ctx.tree, "activeId", {
|
|
18
|
+
get() {
|
|
19
|
+
return activeId;
|
|
20
|
+
},
|
|
21
|
+
configurable: true,
|
|
22
|
+
enumerable: true
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* 切换或设置激活节点
|
|
26
|
+
* 更新后派发 visibility 事件以刷新视图
|
|
27
|
+
* @param id 节点 id
|
|
28
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
29
|
+
*/
|
|
30
|
+
const toggleActive = (id, active) => {
|
|
31
|
+
if (active === void 0) activeId = activeId === id ? void 0 : id;
|
|
32
|
+
else if (active) activeId = id;
|
|
33
|
+
else if (activeId === id) activeId = void 0;
|
|
34
|
+
ctx.events.emit({
|
|
35
|
+
name: "visibility",
|
|
36
|
+
keys: ctx.tree.expandedKeys()
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* 按可见序列移动激活项
|
|
41
|
+
* @param delta 移动步长(-1 上一个,1 下一个)
|
|
42
|
+
*/
|
|
43
|
+
const moveActive = (delta) => {
|
|
44
|
+
const items = ctx.tree.items();
|
|
45
|
+
if (!items.length) return;
|
|
46
|
+
const idx = activeId ? items.findIndex((n) => n.id === activeId) : -1;
|
|
47
|
+
activeId = items[Math.max(0, Math.min(items.length - 1, idx < 0 ? 0 : idx + delta))]?.id;
|
|
48
|
+
ctx.events.emit({
|
|
49
|
+
name: "visibility",
|
|
50
|
+
keys: ctx.tree.expandedKeys()
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* 激活上一个可见节点
|
|
55
|
+
*/
|
|
56
|
+
const keyUp = () => moveActive(-1);
|
|
57
|
+
/**
|
|
58
|
+
* 激活下一个可见节点
|
|
59
|
+
*/
|
|
60
|
+
const keyDown = () => moveActive(1);
|
|
61
|
+
/**
|
|
62
|
+
* 左方向键:优先收起;否则激活父节点
|
|
63
|
+
*/
|
|
64
|
+
const keyLeft = () => {
|
|
65
|
+
if (!activeId) return;
|
|
66
|
+
const node = ctx.tree.getNode(activeId);
|
|
67
|
+
if (!node) return;
|
|
68
|
+
if (ctx.tree.isExpanded(node.id)) ctx.tree.collapse(node.id);
|
|
69
|
+
else if (node.getParent()?.id) activeId = node.getParent().id;
|
|
70
|
+
ctx.events.emit({
|
|
71
|
+
name: "visibility",
|
|
72
|
+
keys: ctx.tree.expandedKeys()
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* 右方向键:优先展开;已展开则进入第一个子节点
|
|
77
|
+
*/
|
|
78
|
+
const keyRight = () => {
|
|
79
|
+
if (!activeId) return;
|
|
80
|
+
const node = ctx.tree.getNode(activeId);
|
|
81
|
+
if (!node) return;
|
|
82
|
+
if (!ctx.tree.isExpanded(node.id)) ctx.tree.expand(node.id);
|
|
83
|
+
else {
|
|
84
|
+
const first = node.getChildren()[0];
|
|
85
|
+
if (first) activeId = first.id;
|
|
86
|
+
}
|
|
87
|
+
ctx.events.emit({
|
|
88
|
+
name: "visibility",
|
|
89
|
+
keys: ctx.tree.expandedKeys()
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* 键盘按下事件处理
|
|
94
|
+
* - 根据 keymap 映射调用对应处理函数
|
|
95
|
+
* - 自动阻止默认行为以避免滚动
|
|
96
|
+
*/
|
|
97
|
+
const onKeydown = (e) => {
|
|
98
|
+
const kExpand = keymap?.expand ?? "ArrowRight";
|
|
99
|
+
const kCollapse = keymap?.collapse ?? "ArrowLeft";
|
|
100
|
+
const kNext = keymap?.next ?? "ArrowDown";
|
|
101
|
+
const kPrev = keymap?.prev ?? "ArrowUp";
|
|
102
|
+
const key = e.key;
|
|
103
|
+
if (key === kPrev) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
keyUp();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (key === kNext) {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
keyDown();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key === kCollapse) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
keyLeft();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (key === kExpand) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
keyRight();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
if (autoListenKeyboard && typeof document !== "undefined") setTimeout(() => {
|
|
124
|
+
const root = resolveKeyboardTarget(keyboardTarget);
|
|
125
|
+
if (root) {
|
|
126
|
+
root.setAttribute("tabindex", "0");
|
|
127
|
+
root?.focus?.();
|
|
128
|
+
}
|
|
129
|
+
const handler = (e) => {
|
|
130
|
+
const active = document.activeElement;
|
|
131
|
+
if (!root || root.contains(active)) onKeydown(e);
|
|
132
|
+
};
|
|
133
|
+
document.addEventListener("keydown", handler);
|
|
134
|
+
});
|
|
135
|
+
return { toggleActive };
|
|
136
|
+
},
|
|
137
|
+
extendNode(node, ctx) {
|
|
138
|
+
node.isActive = () => ctx.tree?.activeId === node.id;
|
|
139
|
+
node.toggleActive = (active) => {
|
|
140
|
+
ctx.tree.toggleActive(node.id, active);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
var src_default = keyboardNavigation;
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
export { src_default as default, keyboardNavigation };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splicetree/plugin-keyboard",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"author": {
|
|
6
|
+
"email": "michael.cocova@gmail.com",
|
|
7
|
+
"name": "Michael Cocova"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"homepage": "https://www.splicetree.dev",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/michaelcocova/splicetree"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@splicetree/core": "0.0.1"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "tsdown --watch",
|
|
30
|
+
"build": "tsdown"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { SpliceTreePlugin, SpliceTreePluginContext } from '@splicetree/core'
|
|
2
|
+
import '@splicetree/core'
|
|
3
|
+
|
|
4
|
+
type KeyboardTargetType = HTMLElement | string | null | undefined
|
|
5
|
+
type KeyboardTarget = KeyboardTargetType | (() => KeyboardTargetType)
|
|
6
|
+
declare module '@splicetree/core' {
|
|
7
|
+
export interface UseSpliceTreeOptions {
|
|
8
|
+
/**
|
|
9
|
+
* 默认激活节点 ID
|
|
10
|
+
* @default undefined
|
|
11
|
+
*/
|
|
12
|
+
defaultActive?: string
|
|
13
|
+
/**
|
|
14
|
+
* 是否自动监听键盘事件
|
|
15
|
+
* 开启后,插件会自动监听键盘事件
|
|
16
|
+
* 关闭后,需要手动调用 `listenKeyboard` 方法监听键盘事件
|
|
17
|
+
* @default true
|
|
18
|
+
*/
|
|
19
|
+
autoListenKeyboard?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* 键盘导航快捷键
|
|
22
|
+
* @default { expand: 'ArrowRight', collapse: 'ArrowLeft', next: 'ArrowDown', prev: 'ArrowUp' }
|
|
23
|
+
*/
|
|
24
|
+
keymap?: {
|
|
25
|
+
expand?: string
|
|
26
|
+
collapse?: string
|
|
27
|
+
next?: string
|
|
28
|
+
prev?: string
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 键盘导航目标元素
|
|
32
|
+
* @default document.body
|
|
33
|
+
*/
|
|
34
|
+
keyboardTarget?: KeyboardTarget
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 实例扩展(Keyboard)
|
|
39
|
+
* - activeId:当前激活的节点 id
|
|
40
|
+
* - toggleActive:切换或显式设置某节点的激活态
|
|
41
|
+
*/
|
|
42
|
+
interface SpliceTreeInstance {
|
|
43
|
+
/**
|
|
44
|
+
* 当前激活的节点 id(未激活时为 undefined)
|
|
45
|
+
*/
|
|
46
|
+
activeId?: string
|
|
47
|
+
/**
|
|
48
|
+
* 切换或显式设置某节点的激活态
|
|
49
|
+
* @param id 节点 id
|
|
50
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
51
|
+
*/
|
|
52
|
+
toggleActive: (id: string, active?: boolean) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 节点扩展(Keyboard)
|
|
57
|
+
* - isActive:当前节点是否激活
|
|
58
|
+
* - toggleActive:切换当前节点的激活态
|
|
59
|
+
*/
|
|
60
|
+
interface SpliceTreeNode {
|
|
61
|
+
/**
|
|
62
|
+
* 当前节点是否为激活态
|
|
63
|
+
*/
|
|
64
|
+
isActive: () => boolean
|
|
65
|
+
/**
|
|
66
|
+
* 切换当前节点的激活态
|
|
67
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
68
|
+
*/
|
|
69
|
+
toggleActive: (active?: boolean) => void
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 解析键盘监听目标元素
|
|
75
|
+
* 支持传入选择器、元素实例或函数
|
|
76
|
+
*/
|
|
77
|
+
function resolveKeyboardTarget(targe?: KeyboardTarget): HTMLElement | null {
|
|
78
|
+
if (typeof targe === 'string') {
|
|
79
|
+
return document.querySelector(targe)
|
|
80
|
+
}
|
|
81
|
+
if (targe instanceof HTMLElement) {
|
|
82
|
+
return targe
|
|
83
|
+
}
|
|
84
|
+
if (typeof targe === 'function') {
|
|
85
|
+
return resolveKeyboardTarget(targe?.())
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
export const keyboardNavigation: SpliceTreePlugin = {
|
|
90
|
+
name: 'keyboard',
|
|
91
|
+
/**
|
|
92
|
+
* 键盘导航插件
|
|
93
|
+
* - 提供 activeId 概念与切换 API
|
|
94
|
+
* - 支持上下移动、展开/收起与进入子级
|
|
95
|
+
* - 自动或自定义监听目标元素的键盘事件
|
|
96
|
+
*/
|
|
97
|
+
setup(ctx: SpliceTreePluginContext) {
|
|
98
|
+
const { defaultActive, autoListenKeyboard = true, keyboardTarget, keymap } = ctx.options
|
|
99
|
+
let activeId: string | undefined = defaultActive
|
|
100
|
+
|
|
101
|
+
Object.defineProperty(ctx.tree, 'activeId', {
|
|
102
|
+
get() {
|
|
103
|
+
return activeId
|
|
104
|
+
},
|
|
105
|
+
configurable: true,
|
|
106
|
+
enumerable: true,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 切换或设置激活节点
|
|
111
|
+
* 更新后派发 visibility 事件以刷新视图
|
|
112
|
+
* @param id 节点 id
|
|
113
|
+
* @param active 不传表示切换;true/false 表示显式设置
|
|
114
|
+
*/
|
|
115
|
+
const toggleActive = (id: string, active?: boolean) => {
|
|
116
|
+
if (active === undefined) {
|
|
117
|
+
activeId = activeId === id ? undefined : id
|
|
118
|
+
} else {
|
|
119
|
+
if (active)
|
|
120
|
+
activeId = id
|
|
121
|
+
else if (activeId === id)
|
|
122
|
+
activeId = undefined
|
|
123
|
+
}
|
|
124
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 按可见序列移动激活项
|
|
129
|
+
* @param delta 移动步长(-1 上一个,1 下一个)
|
|
130
|
+
*/
|
|
131
|
+
const moveActive = (delta: number) => {
|
|
132
|
+
const items = ctx.tree.items()
|
|
133
|
+
if (!items.length) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const idx = activeId ? items.findIndex(n => n.id === activeId) : -1
|
|
137
|
+
const next = Math.max(0, Math.min(items.length - 1, (idx < 0 ? 0 : idx + delta)))
|
|
138
|
+
activeId = items[next]?.id
|
|
139
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 激活上一个可见节点
|
|
144
|
+
*/
|
|
145
|
+
const keyUp = () => moveActive(-1)
|
|
146
|
+
/**
|
|
147
|
+
* 激活下一个可见节点
|
|
148
|
+
*/
|
|
149
|
+
const keyDown = () => moveActive(1)
|
|
150
|
+
/**
|
|
151
|
+
* 左方向键:优先收起;否则激活父节点
|
|
152
|
+
*/
|
|
153
|
+
const keyLeft = () => {
|
|
154
|
+
if (!activeId) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const node = ctx.tree.getNode(activeId)
|
|
158
|
+
if (!node) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (ctx.tree.isExpanded(node.id)) {
|
|
162
|
+
ctx.tree.collapse(node.id)
|
|
163
|
+
} else if (node.getParent()?.id) {
|
|
164
|
+
activeId = node.getParent()!.id
|
|
165
|
+
}
|
|
166
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 右方向键:优先展开;已展开则进入第一个子节点
|
|
170
|
+
*/
|
|
171
|
+
const keyRight = () => {
|
|
172
|
+
if (!activeId) {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const node = ctx.tree.getNode(activeId)
|
|
176
|
+
if (!node) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (!ctx.tree.isExpanded(node.id)) {
|
|
180
|
+
ctx.tree.expand(node.id)
|
|
181
|
+
} else {
|
|
182
|
+
const first = node.getChildren()[0]
|
|
183
|
+
if (first) {
|
|
184
|
+
activeId = first.id
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 键盘按下事件处理
|
|
191
|
+
* - 根据 keymap 映射调用对应处理函数
|
|
192
|
+
* - 自动阻止默认行为以避免滚动
|
|
193
|
+
*/
|
|
194
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
195
|
+
const kExpand = keymap?.expand ?? 'ArrowRight'
|
|
196
|
+
const kCollapse = keymap?.collapse ?? 'ArrowLeft'
|
|
197
|
+
const kNext = keymap?.next ?? 'ArrowDown'
|
|
198
|
+
const kPrev = keymap?.prev ?? 'ArrowUp'
|
|
199
|
+
const key = e.key
|
|
200
|
+
if (key === kPrev) {
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
keyUp()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (key === kNext) {
|
|
206
|
+
e.preventDefault()
|
|
207
|
+
keyDown()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (key === kCollapse) {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
keyLeft()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (key === kExpand) {
|
|
216
|
+
e.preventDefault()
|
|
217
|
+
keyRight()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (autoListenKeyboard && typeof document !== 'undefined') {
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
const root = resolveKeyboardTarget(keyboardTarget)
|
|
223
|
+
if (root) {
|
|
224
|
+
root.setAttribute('tabindex', '0')
|
|
225
|
+
root?.focus?.()
|
|
226
|
+
}
|
|
227
|
+
const handler = (e: KeyboardEvent) => {
|
|
228
|
+
const active = document.activeElement
|
|
229
|
+
// 仅当 activeElement 在树容器内时触发
|
|
230
|
+
if (!root || root.contains(active)) {
|
|
231
|
+
onKeydown(e)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
document.addEventListener('keydown', handler)
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
return { toggleActive }
|
|
238
|
+
},
|
|
239
|
+
/**
|
|
240
|
+
* 为节点扩展激活态判断方法
|
|
241
|
+
* - isActive:是否为当前激活项
|
|
242
|
+
* - toggleActive:切换当前节点的激活态
|
|
243
|
+
*/
|
|
244
|
+
extendNode(node, ctx) {
|
|
245
|
+
node.isActive = () => ctx.tree?.activeId === node.id
|
|
246
|
+
node.toggleActive = (active?: boolean) => {
|
|
247
|
+
ctx.tree.toggleActive(node.id, active)
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default keyboardNavigation
|
package/tsconfig.json
ADDED