@wwog/react 1.3.14 → 1.4.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/README.md +56 -2
- package/dist/index.d.mts +163 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/Sundry/FocusTrap.test.tsx +238 -0
- package/src/components/Sundry/FocusTrap.tsx +238 -0
- package/src/components/Sundry/index.ts +1 -0
- package/src/utils/createExternalState.test.tsx +23 -0
- package/src/utils/createExternalState.ts +25 -19
- package/src/utils/focusable.test.ts +376 -0
- package/src/utils/focusable.ts +609 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useCallback, type ReactNode } from 'react'
|
|
2
|
+
import { getTabbableElements, type FocusableOptions } from '../../utils/focusable'
|
|
3
|
+
|
|
4
|
+
export type FocusDirection = 'next' | 'prev' | 'first' | 'last'
|
|
5
|
+
|
|
6
|
+
export interface FocusTrapProps {
|
|
7
|
+
/**
|
|
8
|
+
* @description_en The child elements to trap focus within.
|
|
9
|
+
* @description_zh 需要劫持焦点的子元素。
|
|
10
|
+
*/
|
|
11
|
+
children?: ReactNode
|
|
12
|
+
/**
|
|
13
|
+
* @description_en Whether to disable the focus trap.
|
|
14
|
+
* @description_zh 是否禁用焦点劫持。
|
|
15
|
+
* @default false
|
|
16
|
+
*/
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* @description_en Whether to auto-focus the first tabbable element on mount.
|
|
20
|
+
* @description_zh 是否在挂载时自动聚焦到第一个可 Tab 聚焦的元素。
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* @description_en Whether to restore focus to the previously focused element on unmount.
|
|
26
|
+
* @description_zh 是否在卸载时恢复焦点到之前聚焦的元素。
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
restoreFocus?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* @description_en Custom key-to-direction mapping to extend or override the default Tab-based navigation.
|
|
32
|
+
* @description_zh 自定义按键到焦点方向的映射,用于扩展或覆盖默认的 Tab 导航。
|
|
33
|
+
* @default { Tab: 'next' }
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* // Arrow up/down navigation
|
|
37
|
+
* keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}
|
|
38
|
+
* // Arrow left/right navigation
|
|
39
|
+
* keyMap={{ ArrowRight: 'next', ArrowLeft: 'prev' }}
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
keyMap?: Partial<Record<string, FocusDirection>>
|
|
43
|
+
/**
|
|
44
|
+
* @description_en Custom focus resolution function. Return the element to focus, or null to use default cycle.
|
|
45
|
+
* @description_zh 自定义焦点解析函数。返回要聚焦的元素,或返回 null 使用默认循环行为。
|
|
46
|
+
* @optional
|
|
47
|
+
*/
|
|
48
|
+
onNavigate?: (
|
|
49
|
+
current: HTMLElement | null,
|
|
50
|
+
elements: HTMLElement[],
|
|
51
|
+
direction: FocusDirection,
|
|
52
|
+
) => HTMLElement | null
|
|
53
|
+
/**
|
|
54
|
+
* @description_en Options passed to getTabbableElements.
|
|
55
|
+
* @description_zh 传递给 getTabbableElements 的选项。
|
|
56
|
+
* @optional
|
|
57
|
+
*/
|
|
58
|
+
focusableOptions?: FocusableOptions
|
|
59
|
+
/**
|
|
60
|
+
* @description_en CSS class name for the container.
|
|
61
|
+
* @description_zh 容器元素的 CSS 类名。
|
|
62
|
+
* @optional
|
|
63
|
+
*/
|
|
64
|
+
className?: string
|
|
65
|
+
/**
|
|
66
|
+
* @description_en Inline styles for the container.
|
|
67
|
+
* @description_zh 容器元素的内联样式。
|
|
68
|
+
* @optional
|
|
69
|
+
*/
|
|
70
|
+
style?: React.CSSProperties
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultKeyMap: Record<string, FocusDirection> = { Tab: 'next' }
|
|
74
|
+
|
|
75
|
+
function getTargetIndex(
|
|
76
|
+
currentIndex: number,
|
|
77
|
+
elements: HTMLElement[],
|
|
78
|
+
direction: FocusDirection,
|
|
79
|
+
): number {
|
|
80
|
+
const last = elements.length - 1
|
|
81
|
+
switch (direction) {
|
|
82
|
+
case 'next':
|
|
83
|
+
return currentIndex < last ? currentIndex + 1 : 0
|
|
84
|
+
case 'prev':
|
|
85
|
+
return currentIndex > 0 ? currentIndex - 1 : last
|
|
86
|
+
case 'first':
|
|
87
|
+
return 0
|
|
88
|
+
case 'last':
|
|
89
|
+
return last
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @description_zh 焦点陷阱组件,将键盘焦点循环限制在容器内的可聚焦元素中,支持自定义按键映射和导航逻辑。
|
|
95
|
+
* @description_en Focus trap component that constrains keyboard focus cycling to focusable elements within a container, with support for custom key mappings and navigation logic.
|
|
96
|
+
* @component
|
|
97
|
+
* @example
|
|
98
|
+
* ```tsx
|
|
99
|
+
* // Default Tab trapping
|
|
100
|
+
* <FocusTrap>
|
|
101
|
+
* <input />
|
|
102
|
+
* <button>Save</button>
|
|
103
|
+
* </FocusTrap>
|
|
104
|
+
*
|
|
105
|
+
* // Arrow key navigation
|
|
106
|
+
* <FocusTrap keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}>
|
|
107
|
+
* <input />
|
|
108
|
+
* <button>Save</button>
|
|
109
|
+
* </FocusTrap>
|
|
110
|
+
*
|
|
111
|
+
* // With auto-focus and restore
|
|
112
|
+
* <FocusTrap autoFocus restoreFocus>
|
|
113
|
+
* <input />
|
|
114
|
+
* <button>Save</button>
|
|
115
|
+
* </FocusTrap>
|
|
116
|
+
*
|
|
117
|
+
* // Cross-list navigation: items from multiple lists are collected
|
|
118
|
+
* // into a single focus order, seamlessly crossing between lists.
|
|
119
|
+
* // ArrowDown from A-2 → B-1, ArrowUp from B-1 → A-2
|
|
120
|
+
* <FocusTrap keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}>
|
|
121
|
+
* <div>
|
|
122
|
+
* <h3>List A</h3>
|
|
123
|
+
* <button>A-1</button>
|
|
124
|
+
* <button>A-2</button>
|
|
125
|
+
* </div>
|
|
126
|
+
* <div>
|
|
127
|
+
* <h3>List B</h3>
|
|
128
|
+
* <button>B-1</button>
|
|
129
|
+
* <button>B-2</button>
|
|
130
|
+
* </div>
|
|
131
|
+
* </FocusTrap>
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function FocusTrap({
|
|
135
|
+
children,
|
|
136
|
+
disabled = false,
|
|
137
|
+
autoFocus = false,
|
|
138
|
+
restoreFocus = false,
|
|
139
|
+
keyMap,
|
|
140
|
+
onNavigate,
|
|
141
|
+
focusableOptions,
|
|
142
|
+
className,
|
|
143
|
+
style,
|
|
144
|
+
}: FocusTrapProps): ReactNode {
|
|
145
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const previousActiveElement = useRef<HTMLElement | null>(null)
|
|
147
|
+
|
|
148
|
+
const getTabbable = useCallback(() => {
|
|
149
|
+
if (!containerRef.current) return []
|
|
150
|
+
return getTabbableElements(containerRef.current, focusableOptions)
|
|
151
|
+
}, [focusableOptions])
|
|
152
|
+
|
|
153
|
+
const focusElement = useCallback((element: HTMLElement | null) => {
|
|
154
|
+
if (element && typeof element.focus === 'function') {
|
|
155
|
+
element.focus()
|
|
156
|
+
}
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
159
|
+
const handleKeyDown = useCallback(
|
|
160
|
+
(e: KeyboardEvent) => {
|
|
161
|
+
if (disabled) return
|
|
162
|
+
|
|
163
|
+
const mergedKeyMap = { ...defaultKeyMap, ...keyMap }
|
|
164
|
+
let direction = mergedKeyMap[e.key]
|
|
165
|
+
if (!direction) return
|
|
166
|
+
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
e.stopPropagation()
|
|
169
|
+
|
|
170
|
+
const elements = getTabbable()
|
|
171
|
+
if (elements.length === 0) return
|
|
172
|
+
|
|
173
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
174
|
+
direction = 'prev'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const currentElement = document.activeElement as HTMLElement | null
|
|
178
|
+
const currentIndex = currentElement ? elements.indexOf(currentElement) : -1
|
|
179
|
+
|
|
180
|
+
let target: HTMLElement | null = null
|
|
181
|
+
|
|
182
|
+
if (onNavigate) {
|
|
183
|
+
target = onNavigate(currentElement, elements, direction)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!target) {
|
|
187
|
+
const targetIndex = getTargetIndex(currentIndex, elements, direction)
|
|
188
|
+
target = elements[targetIndex]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
focusElement(target)
|
|
192
|
+
},
|
|
193
|
+
[disabled, keyMap, onNavigate, getTabbable, focusElement],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (disabled) return
|
|
198
|
+
const container = containerRef.current
|
|
199
|
+
if (!container) return
|
|
200
|
+
|
|
201
|
+
container.addEventListener('keydown', handleKeyDown)
|
|
202
|
+
return () => {
|
|
203
|
+
container.removeEventListener('keydown', handleKeyDown)
|
|
204
|
+
}
|
|
205
|
+
}, [disabled, handleKeyDown])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (disabled || !autoFocus) return
|
|
209
|
+
const elements = getTabbable()
|
|
210
|
+
if (elements.length > 0) {
|
|
211
|
+
elements[0].focus()
|
|
212
|
+
}
|
|
213
|
+
}, [disabled, autoFocus, getTabbable])
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (disabled || !restoreFocus) return
|
|
217
|
+
|
|
218
|
+
const prev = document.activeElement as HTMLElement | null
|
|
219
|
+
if (prev && prev !== containerRef.current) {
|
|
220
|
+
previousActiveElement.current = prev
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return () => {
|
|
224
|
+
const prev = previousActiveElement.current
|
|
225
|
+
if (prev && typeof prev.focus === 'function') {
|
|
226
|
+
prev.focus()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, [disabled, restoreFocus])
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div ref={containerRef} className={className} style={style}>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
FocusTrap.displayName = 'W/FocusTrap'
|
|
@@ -46,6 +46,29 @@ describe("createExternalState", () => {
|
|
|
46
46
|
expect(state.get()).toBe("updated");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it("测试useState钩子在组件中使用", async () => {
|
|
50
|
+
const initialState = "initial";
|
|
51
|
+
const state = createExternalState(initialState);
|
|
52
|
+
|
|
53
|
+
function TestComponent() {
|
|
54
|
+
const [value, setValue] = state.useState();
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
<span data-testid="value">{value}</span>
|
|
58
|
+
<button onClick={() => setValue("updated")}>Update</button>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { getByTestId, getByText } = render(<TestComponent />);
|
|
64
|
+
const valueLocator = getByTestId("value");
|
|
65
|
+
const buttonLocator = getByText("Update");
|
|
66
|
+
expect(valueLocator.element().textContent).toBe(initialState);
|
|
67
|
+
await buttonLocator.click();
|
|
68
|
+
expect(valueLocator.element().textContent).toBe("updated");
|
|
69
|
+
expect(state.get()).toBe("updated");
|
|
70
|
+
});
|
|
71
|
+
|
|
49
72
|
it("测试多个组件共享状态", async () => {
|
|
50
73
|
const initialState = "initial";
|
|
51
74
|
const state = createExternalState(initialState);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {useSyncExternalStore} from 'react'
|
|
2
|
-
import {safePromiseTry} from './promise'
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { safePromiseTry } from './promise'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @zh 状态回调函数。对于异步函数,会在状态更新后执行,不会阻塞状态更新,尽可能在外部使用 useEffect 处理异步副作用。
|
|
@@ -74,12 +74,13 @@ export interface ExternalState<T, U = T> {
|
|
|
74
74
|
*/
|
|
75
75
|
set: (newState: U | ((prevState: U) => U)) => void
|
|
76
76
|
|
|
77
|
+
|
|
77
78
|
/**
|
|
78
|
-
* @en React Hook for using external state in components
|
|
79
|
-
* @zh 在组件中使用外部状态的 React Hook
|
|
80
|
-
* @returns Array containing current state and update function, similar to useState / 包含当前状态和更新函数的数组,类似于 useState
|
|
79
|
+
* @en React Hook for using external state in components.
|
|
80
|
+
* @zh 在组件中使用外部状态的 React Hook。
|
|
81
|
+
* @returns Array containing current state and update function, similar to React useState / 包含当前状态和更新函数的数组,类似于 React useState
|
|
81
82
|
*/
|
|
82
|
-
|
|
83
|
+
useState: () => [U, (newState: U | ((prevState: U) => U)) => void]
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* @zh use的变体,只获取value.
|
|
@@ -111,7 +112,7 @@ export interface ExternalWithKernel<T, U = T> extends ExternalState<T, U> {
|
|
|
111
112
|
*
|
|
112
113
|
* // Use state in components
|
|
113
114
|
* function ThemeConsumer() {
|
|
114
|
-
* const [theme, setTheme] = themeState.
|
|
115
|
+
* const [theme, setTheme] = themeState.useState();
|
|
115
116
|
*
|
|
116
117
|
* return (
|
|
117
118
|
* <div className={theme}>
|
|
@@ -130,9 +131,13 @@ export function createExternalState<T, U = T>(
|
|
|
130
131
|
let state: T = typeof initialState === 'function' ? (initialState as () => T)() : initialState
|
|
131
132
|
|
|
132
133
|
const storeListeners: (() => void)[] = []
|
|
133
|
-
const {onSet, onChange, transform} = options
|
|
134
|
+
const { onSet, onChange, transform } = options
|
|
134
135
|
|
|
135
|
-
const runCallback = (
|
|
136
|
+
const runCallback = (
|
|
137
|
+
callback: ExternalStateCallback<T> | undefined,
|
|
138
|
+
newState: T,
|
|
139
|
+
prevState: T,
|
|
140
|
+
) => {
|
|
136
141
|
if (!callback) return
|
|
137
142
|
safePromiseTry(callback, newState, prevState).catch((error) => {
|
|
138
143
|
console.error('Error in external state callback, Please do it within side effects:', error)
|
|
@@ -151,13 +156,13 @@ export function createExternalState<T, U = T>(
|
|
|
151
156
|
: (prevState as unknown as U)
|
|
152
157
|
state = transform?.set
|
|
153
158
|
? transform.set(
|
|
154
|
-
|
|
155
|
-
? (newState as (prev: U) => U)(transformedPrevState)
|
|
156
|
-
: newState,
|
|
157
|
-
)
|
|
158
|
-
: ((typeof newState === 'function'
|
|
159
|
+
typeof newState === 'function'
|
|
159
160
|
? (newState as (prev: U) => U)(transformedPrevState)
|
|
160
|
-
: newState
|
|
161
|
+
: newState,
|
|
162
|
+
)
|
|
163
|
+
: ((typeof newState === 'function'
|
|
164
|
+
? (newState as (prev: U) => U)(transformedPrevState)
|
|
165
|
+
: newState) as unknown as T)
|
|
161
166
|
|
|
162
167
|
storeListeners.forEach((listener) => listener())
|
|
163
168
|
|
|
@@ -167,7 +172,7 @@ export function createExternalState<T, U = T>(
|
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
const
|
|
175
|
+
const useState = () => {
|
|
171
176
|
const localState = useSyncExternalStore(
|
|
172
177
|
(onStoreChange) => {
|
|
173
178
|
storeListeners.push(onStoreChange)
|
|
@@ -188,13 +193,14 @@ export function createExternalState<T, U = T>(
|
|
|
188
193
|
]
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
|
|
191
197
|
const useGetter = () => {
|
|
192
|
-
const [value] =
|
|
198
|
+
const [value] = useState()
|
|
193
199
|
return value
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
//@ts-expect-error ignore
|
|
197
|
-
return {get, set,
|
|
203
|
+
return { get, set, useState, useGetter, __listeners: storeListeners }
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
export interface StorageStateOptions<T, U> {
|
|
@@ -209,7 +215,7 @@ export function createStorageState<T, U = T>(
|
|
|
209
215
|
initialState: T,
|
|
210
216
|
options?: StorageStateOptions<T, U>,
|
|
211
217
|
) {
|
|
212
|
-
const {storageType = 'local', onSet, onChange, transform} = options ?? {}
|
|
218
|
+
const { storageType = 'local', onSet, onChange, transform } = options ?? {}
|
|
213
219
|
let _initState: T = initialState
|
|
214
220
|
|
|
215
221
|
// 只在客户端环境中读取存储
|