@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.
@@ -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'
@@ -6,3 +6,4 @@ export * from './Observer'
6
6
  export * from './Repeat'
7
7
  export * from './Portal'
8
8
  export * from './Boundary'
9
+ export * from './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
- use: () => [U, (newState: U | ((prevState: U) => U)) => void]
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.use();
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 = (callback: ExternalStateCallback<T> | undefined, newState: T, prevState: T) => {
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
- typeof newState === 'function'
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) as unknown as T)
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 use = () => {
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] = use()
198
+ const [value] = useState()
193
199
  return value
194
200
  }
195
201
 
196
202
  //@ts-expect-error ignore
197
- return {get, set, use, useGetter, __listeners: storeListeners}
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
  // 只在客户端环境中读取存储