@zhencai/vue-focus-scope 1.0.1 → 1.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.
package/index.d.ts CHANGED
@@ -1,30 +1,8 @@
1
- // index.d.ts
2
- import { DefineComponent } from "vue";
1
+ import { DefineComponent } from 'vue';
3
2
 
4
- type TabMode = "loop" | "loop-sentinel";
3
+ declare const FocusScope: DefineComponent<{
4
+ tabMode?: 'loop' | 'soft-loop';
5
+ }>;
5
6
 
6
- interface FocusScopeProps {
7
- autoFocus?: "first" | "last" | false;
8
- tabMode?: TabMode;
9
- }
7
+ export { FocusScope };
10
8
 
11
- export interface FocusScopeExpose {
12
- activate: () => void;
13
- deactivate: () => void;
14
- focusFirst: () => void;
15
- focusLast: () => void;
16
- }
17
-
18
- declare const FocusScope: DefineComponent<
19
- FocusScopeProps,
20
- {},
21
- {},
22
- {},
23
- {},
24
- {},
25
- {},
26
- {},
27
- FocusScopeExpose
28
- >;
29
-
30
- export default FocusScope;
package/index.js CHANGED
@@ -1,107 +1,71 @@
1
- import { createElementBlock, defineComponent, onMounted, onUnmounted, openBlock, ref, renderSlot } from "vue";
1
+ import { createCommentVNode as e, createElementBlock as t, createElementVNode as n, defineComponent as r, openBlock as i, renderSlot as a, useTemplateRef as o } from "vue";
2
2
  //#region src/components/FocusScope.vue?vue&type=script&setup=true&lang.ts
3
- var selector = "a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex='-1']),[contenteditable='true']";
4
- var FocusScope_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
3
+ var s = {
4
+ key: 0,
5
+ tabindex: "0"
6
+ }, c = /* @__PURE__ */ r({
5
7
  __name: "FocusScope",
6
- props: {
7
- autoFocus: {
8
- type: [String, Boolean],
9
- default: "first"
10
- },
11
- tabMode: { default: "loop" }
12
- },
13
- setup(__props) {
14
- const props = __props;
15
- const scopeRef = ref(null);
16
- let active = false;
17
- let previous = null;
18
- const getFocusable = () => {
19
- if (!scopeRef.value) return [];
20
- return [...scopeRef.value.querySelectorAll(selector)];
21
- };
22
- const focusFirst = () => {
23
- getFocusable()[0]?.focus();
24
- };
25
- const focusLast = () => {
26
- const list = getFocusable();
27
- list[list.length - 1]?.focus();
28
- };
29
- const handleMouseDown = () => {
30
- scopeRef.value?.focus();
31
- };
32
- const handleKeydown = (e) => {
33
- if (!active) return;
34
- if (e.key !== "Tab") return;
35
- if (!scopeRef.value) return;
36
- const list = getFocusable();
37
- if (!list.length) return;
38
- const first = list[0];
39
- const last = list[list.length - 1];
40
- const current = document.activeElement;
41
- if (e.shiftKey) {
42
- if (current === first) {
43
- e.preventDefault();
44
- if (props.tabMode === "loop") last.focus();
45
- else scopeRef.value.focus();
46
- } else if (current === scopeRef.value) {
47
- e.preventDefault();
48
- last.focus();
8
+ props: { tabMode: { default: "loop" } },
9
+ setup(r) {
10
+ let c = o("wrapper");
11
+ function l(e) {
12
+ return e ? Array.from(e.querySelectorAll("a[href]:not([tabindex=\"-1\"]):not(.sentinel-start):not(.sentinel-end), button:not([tabindex=\"-1\"]):not([disabled]):not(.sentinel-start):not(.sentinel-end), input:not([tabindex=\"-1\"]):not([disabled]):not(.sentinel-start):not(.sentinel-end), select:not([tabindex=\"-1\"]):not([disabled]):not(.sentinel-start):not(.sentinel-end), textarea:not([tabindex=\"-1\"]):not([disabled]):not(.sentinel-start):not(.sentinel-end), [tabindex]:not([tabindex=\"-1\"]):not(.sentinel-start):not(.sentinel-end)")).filter((e) => {
13
+ let t = e.parentElement;
14
+ for (; t;) {
15
+ if (t.tagName === "FIELDSET" && t.disabled) return !1;
16
+ t = t.parentElement;
49
17
  }
50
- } else if (current === last) {
51
- e.preventDefault();
52
- if (props.tabMode === "loop") first.focus();
53
- else scopeRef.value.focus();
54
- } else if (current === scopeRef.value) {
55
- e.preventDefault();
56
- first.focus();
18
+ return !0;
19
+ }) : [];
20
+ }
21
+ function u() {
22
+ try {
23
+ if (c.value) {
24
+ let e = l(c.value);
25
+ if (e.length === 0) return;
26
+ e[0].focus();
27
+ }
28
+ } catch (e) {
29
+ console.error("focusFirst error:", e);
30
+ }
31
+ }
32
+ function d() {
33
+ if (c.value) {
34
+ let e = l(c.value);
35
+ if (e.length === 0) return;
36
+ e[e.length - 1].focus();
37
+ }
38
+ }
39
+ function f(e) {
40
+ if (!c.value) return;
41
+ let t = e.relatedTarget;
42
+ if (!(t instanceof Node)) {
43
+ u();
44
+ return;
57
45
  }
58
- };
59
- const activate = () => {
60
- if (!scopeRef.value) return;
61
- active = true;
62
- previous = document.activeElement;
63
- if (props.autoFocus === "first") focusFirst();
64
- if (props.autoFocus === "last") focusLast();
65
- };
66
- const deactivate = () => {
67
- active = false;
68
- if (previous && document.body.contains(previous)) previous.focus();
69
- previous = null;
70
- };
71
- onMounted(() => {
72
- document.addEventListener("keydown", handleKeydown);
73
- activate();
74
- });
75
- onUnmounted(() => {
76
- document.removeEventListener("keydown", handleKeydown);
77
- deactivate();
78
- });
79
- return (_ctx, _cache) => {
80
- return openBlock(), createElementBlock("div", {
81
- ref_key: "scopeRef",
82
- ref: scopeRef,
83
- tabindex: "-1",
84
- onMousedown: handleMouseDown,
85
- "data-focus-scope": ""
86
- }, [renderSlot(_ctx.$slots, "default", {}, void 0, true)], 544);
87
- };
46
+ c.value.contains(t) ? d() : u();
47
+ }
48
+ function p() {
49
+ u();
50
+ }
51
+ return (o, l) => (i(), t("div", {
52
+ ref_key: "wrapper",
53
+ ref: c
54
+ }, [
55
+ n("div", {
56
+ tabindex: "0",
57
+ class: "sentinel-start",
58
+ onFocus: f
59
+ }, null, 32),
60
+ a(o.$slots, "default"),
61
+ r.tabMode === "soft-loop" ? (i(), t("div", s)) : e("", !0),
62
+ n("div", {
63
+ tabindex: "0",
64
+ class: "sentinel-end",
65
+ onFocus: p
66
+ }, null, 32)
67
+ ], 512));
88
68
  }
89
69
  });
90
70
  //#endregion
91
- //#region \0plugin-vue:export-helper
92
- var _plugin_vue_export_helper_default = (sfc, props) => {
93
- const target = sfc.__vccOpts || sfc;
94
- for (const [key, val] of props) target[key] = val;
95
- return target;
96
- };
97
- //#endregion
98
- //#region src/components/FocusScope.vue
99
- var FocusScope_default = /* @__PURE__ */ _plugin_vue_export_helper_default(FocusScope_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-5ffda61b"]]);
100
- //#endregion
101
- //#region src/index.ts
102
- var src_default = FocusScope_default;
103
-
104
- //#endregion
105
- export { FocusScope_default as FocusScope, src_default as default };
106
-
107
- //# sourceMappingURL=index.js.map
71
+ export { c as FocusScope };
package/package.json CHANGED
@@ -1,22 +1,9 @@
1
1
  {
2
2
  "name": "@zhencai/vue-focus-scope",
3
- "version": "1.0.1",
4
3
  "main": "index.js",
5
- "types": "index.d.ts",
6
- "files": [
7
- "index.js",
8
- "index.d.ts",
9
- "vue-focus-scope.css"
10
- ],
11
- "keywords": [
12
- "vue",
13
- "focus",
14
- "ui",
15
- "component"
16
- ],
17
- "author": "严硕",
18
- "license": "MIT",
4
+ "version": "1.0.4",
5
+ "module": "index.js",
19
6
  "peerDependencies": {
20
- "vue": "^3.2.0"
7
+ "vue": "^3.0.0"
21
8
  }
22
9
  }
package/readme.md ADDED
@@ -0,0 +1,107 @@
1
+ # 焦点管理组件
2
+
3
+ 一个基于 Vue 3 的焦点循环管理组件。它通过"哨兵节点"(Sentinel Nodes)技术,在指定的 DOM 作用域内实现键盘 Tab 键的无缝循环导航。
4
+
5
+ 适用于模态框(Modal)、下拉菜单、自定义表单或任何需要限制用户焦点范围的场景,符合 WAI-ARIA 无障碍最佳实践。
6
+
7
+ ## 功能特性
8
+
9
+ - **自动循环导航**
10
+ - 按 `Tab` 到达最后一个元素时,自动跳回第一个
11
+ - 按 `Shift + Tab` 到达第一个元素时,自动跳到最后一个
12
+
13
+ - **智能作用域限制**
14
+ - 自动过滤不可聚焦元素(`tabindex="-1"`, `disabled`)
15
+ - 自动忽略被 `<fieldset disabled>` 包裹的元素
16
+ - 排除内部哨兵节点,防止干扰正常流程
17
+
18
+ - **双模式支持**
19
+ - **`loop` (默认)**:强循环模式。焦点被严格限制在组件内部,无法通过 Tab 键移出
20
+ - **`soft-loop`**:软循环模式。在首尾之间提供缓冲,允许特定的交互逻辑(防止单元素死循环)
21
+
22
+ - **无障碍友好**:原生支持键盘导航,无需额外配置
23
+
24
+ ## 安装与使用
25
+
26
+ 将组件代码保存为 `FocusLoop.vue`,然后在你的项目中引入。
27
+
28
+ ### 基础用法
29
+
30
+ ```vue
31
+ <template>
32
+ <FocusLoop>
33
+ <h3>用户登录</h3>
34
+ <input type="text" placeholder="用户名" />
35
+ <input type="password" placeholder="密码" />
36
+ <button @click="submit">登录</button>
37
+ <a href="/forgot">忘记密码?</a>
38
+ </FocusLoop>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import FocusLoop from "./components/FocusLoop.vue";
43
+ </script>
44
+ ```
45
+
46
+ ### 模式切换
47
+
48
+ 通过 tabMode 属性控制循环行为:
49
+
50
+ ```vue
51
+ <template>
52
+ <!-- 强循环:焦点永远无法逃出此区域 -->
53
+ <FocusLoop tab-mode="loop">
54
+ <!-- 内容 -->
55
+ </FocusLoop>
56
+
57
+ <!-- 软循环:适合只有一个输入框或需要特殊逃逸逻辑的场景 -->
58
+ <FocusLoop tab-mode="soft-loop">
59
+ <input type="text" placeholder="唯一输入框" />
60
+ </FocusLoop>
61
+ </template>
62
+ ```
63
+
64
+ ## API 文档
65
+
66
+ ### Props
67
+
68
+ | 属性名 | 类型 | 默认值 | 说明 |
69
+ | :-------- | :---------------------- | :------- | :-------------------------------------------------------------------------------------------------------------- |
70
+ | `tabMode` | `'loop' \| 'soft-loop'` | `'loop'` | 循环模式:<br>- `loop`: 严格限制焦点在插槽内<br>- `soft-loop`: 允许一定程度的外部交互,主要用于防止单元素死循环 |
71
+
72
+ ### Slots
73
+
74
+ | 名称 | 说明 |
75
+ | :-------- | :------------------------------------------------------- |
76
+ | `default` | 放置需要被管理焦点的子元素(如 input, button, a 标签等) |
77
+
78
+ ## 工作原理
79
+
80
+ 该组件利用 **Focus Sentinel(焦点哨兵)** 技术:
81
+
82
+ ### 起始哨兵 (sentinel-start)
83
+
84
+ - 位于插槽内容之前
85
+ - 当用户按 `Shift + Tab` 从第一个元素回退时,焦点会落在此处
86
+ - 组件拦截该事件,立即将焦点重定向到最后一个可聚焦元素
87
+
88
+ ### 结束哨兵 (sentinel-end)
89
+
90
+ - 位于插槽内容之后
91
+ - 当用户按 `Tab` 从最后一个元素前进时,焦点会落在此处
92
+ - 组件拦截该事件,立即将焦点重定向到第一个可聚焦元素
93
+
94
+ ### 可聚焦元素检测
95
+
96
+ - 内部使用 `querySelectorAll` 动态计算当前作用域内的有效焦点元素
97
+ - 自动排除 `tabindex="-1"`、`disabled` 状态以及被禁用的 `fieldset`
98
+
99
+ ## 注意事项
100
+
101
+ - **子元素要求**:确保插槽内的元素是原生的可聚焦元素(如 `<input>`, `<button>`, `<a href>`)或具有有效的 `tabindex`
102
+
103
+ - **CSS 样式**:哨兵节点默认带有 `tabindex="0"` 但无视觉样式。如果需要,可以通过 CSS 类 `.sentinel-start` 或 `.sentinel-end` 进行调试(例如添加红色边框),但在生产环境中通常保持隐藏
104
+
105
+ ## 许可证
106
+
107
+ MIT
@@ -1,5 +0,0 @@
1
-
2
- [data-focus-scope][data-v-5ffda61b]:focus {
3
- outline: none;
4
- }
5
- /*$vite$:1*/