Skip to content

Commit 7ff9e3b

Browse files
authored
Merge branch 'master' into react-19-up
2 parents e3e2448 + fd7c864 commit 7ff9e3b

File tree

14 files changed

+426
-72
lines changed

14 files changed

+426
-72
lines changed

.dumirc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// more config: https://d.umijs.org/config
22
import { defineConfig } from 'dumi';
3+
import path from 'path';
34

45
const basePath = process.env.GITHUB_ACTIONS ? '/util/' : '/';
56
const publicPath = process.env.GITHUB_ACTIONS ? '/util/' : '/';
@@ -14,4 +15,7 @@ export default defineConfig({
1415
exportStatic: {},
1516
base: basePath,
1617
publicPath,
18+
alias: {
19+
'rc-util/es': path.resolve(__dirname, 'src'),
20+
},
1721
});

docs/demo/focus.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Focus Utils
3+
---
4+
5+
# Focus Utils Demo
6+
7+
Demonstrates the usage of focus-related utility functions.
8+
9+
<code src="../examples/focus.tsx"></code>

docs/examples/focus.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, { useRef } from 'react';
2+
import { useLockFocus } from '../../src/Dom/focus';
3+
4+
export default function FocusDemo() {
5+
const containerRef = useRef<HTMLDivElement>(null);
6+
const [locking, setLocking] = React.useState(true);
7+
8+
useLockFocus(locking, () => containerRef.current);
9+
10+
return (
11+
<div style={{ padding: 32 }} className="focus-demo">
12+
<h2>Focus Utils Demo</h2>
13+
14+
{/* External buttons */}
15+
<button onClick={() => setLocking(!locking)}>
16+
Lock ({String(locking)})
17+
</button>
18+
19+
{/* Middle container - Tab key cycling is limited within this area */}
20+
<div
21+
ref={containerRef}
22+
tabIndex={0}
23+
style={{
24+
border: '2px solid green',
25+
padding: 24,
26+
margin: 16,
27+
borderRadius: 8,
28+
backgroundColor: '#f0f8ff',
29+
}}
30+
>
31+
<button>Container Button 1</button>
32+
<button>Container Button 2</button>
33+
<button>Container Button 3</button>
34+
</div>
35+
36+
{/* External buttons */}
37+
<button>External Button 2</button>
38+
</div>
39+
);
40+
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rc-component/util",
3-
"version": "1.3.1",
3+
"version": "1.7.0",
44
"description": "Common Utils For React Component",
55
"keywords": [
66
"react",
@@ -53,7 +53,7 @@
5353
"@types/responselike": "^1.0.0",
5454
"@types/warning": "^3.0.0",
5555
"@umijs/fabric": "^3.0.0",
56-
"cross-env": "^7.0.2",
56+
"cross-env": "^10.1.0",
5757
"dumi": "^2.1.3",
5858
"eslint": "^8.54.0",
5959
"eslint-plugin-jest": "^29.0.1",

src/Dom/focus.ts

Lines changed: 123 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react';
12
import isVisible from './isVisible';
23

34
type DisabledElement =
@@ -56,44 +57,137 @@ export function getFocusNodeList(node: HTMLElement, includePositive = false) {
5657
return res;
5758
}
5859

59-
let lastFocusElement = null;
60+
export interface InputFocusOptions extends FocusOptions {
61+
cursor?: 'start' | 'end' | 'all';
62+
}
63+
64+
// Used for `rc-input` `rc-textarea` `rc-input-number`
65+
/**
66+
* Focus element and set cursor position for input/textarea elements.
67+
*/
68+
export function triggerFocus(
69+
element?: HTMLElement,
70+
option?: InputFocusOptions,
71+
) {
72+
if (!element) return;
73+
74+
element.focus(option);
75+
76+
// Selection content
77+
const { cursor } = option || {};
78+
if (
79+
cursor &&
80+
(element instanceof HTMLInputElement ||
81+
element instanceof HTMLTextAreaElement)
82+
) {
83+
const len = element.value.length;
84+
85+
switch (cursor) {
86+
case 'start':
87+
element.setSelectionRange(0, 0);
88+
break;
89+
90+
case 'end':
91+
element.setSelectionRange(len, len);
92+
break;
6093

61-
/** @deprecated Do not use since this may failed when used in async */
62-
export function saveLastFocusNode() {
63-
lastFocusElement = document.activeElement;
94+
default:
95+
element.setSelectionRange(0, len);
96+
}
97+
}
98+
}
99+
100+
// ======================================================
101+
// == Lock Focus ==
102+
// ======================================================
103+
let lastFocusElement: HTMLElement | null = null;
104+
let focusElements: HTMLElement[] = [];
105+
106+
function getLastElement() {
107+
return focusElements[focusElements.length - 1];
64108
}
65109

66-
/** @deprecated Do not use since this may failed when used in async */
67-
export function clearLastFocusNode() {
68-
lastFocusElement = null;
110+
function hasFocus(element: HTMLElement) {
111+
const { activeElement } = document;
112+
return element === activeElement || element.contains(activeElement);
69113
}
70114

71-
/** @deprecated Do not use since this may failed when used in async */
72-
export function backLastFocusNode() {
73-
if (lastFocusElement) {
74-
try {
75-
// 元素可能已经被移动了
76-
lastFocusElement.focus();
115+
function syncFocus() {
116+
const lastElement = getLastElement();
117+
const { activeElement } = document;
77118

78-
/* eslint-disable no-empty */
79-
} catch (e) {
80-
// empty
81-
}
82-
/* eslint-enable no-empty */
119+
if (lastElement && !hasFocus(lastElement)) {
120+
const focusableList = getFocusNodeList(lastElement);
121+
122+
const matchElement = focusableList.includes(lastFocusElement as HTMLElement)
123+
? lastFocusElement
124+
: focusableList[0];
125+
126+
matchElement?.focus();
127+
} else {
128+
lastFocusElement = activeElement as HTMLElement;
83129
}
84130
}
85131

86-
export function limitTabRange(node: HTMLElement, e: KeyboardEvent) {
87-
if (e.keyCode === 9) {
88-
const tabNodeList = getFocusNodeList(node);
89-
const lastTabNode = tabNodeList[e.shiftKey ? 0 : tabNodeList.length - 1];
90-
const leavingTab =
91-
lastTabNode === document.activeElement || node === document.activeElement;
92-
93-
if (leavingTab) {
94-
const target = tabNodeList[e.shiftKey ? tabNodeList.length - 1 : 0];
95-
target.focus();
96-
e.preventDefault();
132+
function onWindowKeyDown(e: KeyboardEvent) {
133+
if (e.key === 'Tab') {
134+
const { activeElement } = document;
135+
const lastElement = getLastElement();
136+
const focusableList = getFocusNodeList(lastElement);
137+
const last = focusableList[focusableList.length - 1];
138+
139+
if (e.shiftKey && activeElement === focusableList[0]) {
140+
// Tab backward on first focusable element
141+
lastFocusElement = last;
142+
} else if (!e.shiftKey && activeElement === last) {
143+
// Tab forward on last focusable element
144+
lastFocusElement = focusableList[0];
97145
}
98146
}
99147
}
148+
149+
/**
150+
* Lock focus in the element.
151+
* It will force back to the first focusable element when focus leaves the element.
152+
*/
153+
export function lockFocus(element: HTMLElement): VoidFunction {
154+
if (element) {
155+
// Refresh focus elements
156+
focusElements = focusElements.filter(ele => ele !== element);
157+
focusElements.push(element);
158+
159+
// Just add event since it will de-duplicate
160+
window.addEventListener('focusin', syncFocus);
161+
window.addEventListener('keydown', onWindowKeyDown, true);
162+
syncFocus();
163+
}
164+
165+
// Always return unregister function
166+
return () => {
167+
lastFocusElement = null;
168+
focusElements = focusElements.filter(ele => ele !== element);
169+
if (focusElements.length === 0) {
170+
window.removeEventListener('focusin', syncFocus);
171+
window.removeEventListener('keydown', onWindowKeyDown, true);
172+
}
173+
};
174+
}
175+
176+
/**
177+
* Lock focus within an element.
178+
* When locked, focus will be restricted to focusable elements within the specified element.
179+
* If multiple elements are locked, only the last locked element will be effective.
180+
*/
181+
export function useLockFocus(
182+
lock: boolean,
183+
getElement: () => HTMLElement | null,
184+
) {
185+
useEffect(() => {
186+
if (lock) {
187+
const element = getElement();
188+
if (element) {
189+
return lockFocus(element);
190+
}
191+
}
192+
}, [lock]);
193+
}

src/KeyCode.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,26 @@ const KeyCode = {
517517
return false;
518518
}
519519
},
520+
521+
isEditableTarget: function isEditableTarget(e: KeyboardEvent) {
522+
const target = e.target;
523+
524+
if (!(target instanceof HTMLElement)) {
525+
return false;
526+
}
527+
528+
const tagName = target.tagName;
529+
if (
530+
tagName === 'INPUT' ||
531+
tagName === 'TEXTAREA' ||
532+
tagName === 'SELECT' ||
533+
target.isContentEditable
534+
) {
535+
return true;
536+
}
537+
538+
return false;
539+
},
520540
};
521541

522542
export default KeyCode;

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { default as useMergedState } from './hooks/useMergedState';
33
export { default as useControlledState } from './hooks/useControlledState';
44
export { supportNodeRef, supportRef, useComposeRef } from './ref';
55
export { default as get } from './utils/get';
6-
export { default as set, merge } from './utils/set';
6+
export { default as set, merge, mergeWith } from './utils/set';
77
export { default as warning, noteOnce } from './warning';
88
export { default as omit } from './omit';
99
export { default as toArray } from './Children/toArray';

src/test/mergeWith.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { mergeWith } from '../utils/set';
2+
3+
describe('mergeWith array merge', () => {
4+
it('should keep existing array length when merging sparse updates', () => {
5+
const allValues = { list: ['A', 'B', 'C', 'D'] };
6+
const changedValues = { list: new Array(2) };
7+
changedValues.list[1] = 'BB'; // 仅更新第 2 项,长度为 2
8+
9+
const merged = mergeWith([allValues, changedValues], {
10+
prepareArray: current => [...(current || [])],
11+
});
12+
13+
expect(merged.list).toEqual(['A', 'BB', 'C', 'D']);
14+
expect(merged.list).toHaveLength(4);
15+
});
16+
});

src/utils/set.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,25 @@ function createEmpty<T>(source: T) {
6666

6767
const keys = typeof Reflect === 'undefined' ? Object.keys : Reflect.ownKeys;
6868

69+
// ================================ Merge ================================
70+
export type MergeFn = (current: any, next: any) => any;
71+
6972
/**
70-
* Merge objects which will create
73+
* Merge multiple objects. Support custom merge logic.
74+
* @param sources object sources
75+
* @param config.prepareArray Customize array prepare function.
76+
* It will return empty [] by default.
77+
* So when match array, it will auto be override with next array in sources.
7178
*/
72-
export function merge<T extends object>(...sources: T[]) {
79+
export function mergeWith<T extends object>(
80+
sources: T[],
81+
config: {
82+
prepareArray?: MergeFn;
83+
} = {},
84+
) {
85+
const { prepareArray } = config;
86+
const finalPrepareArray: MergeFn = prepareArray || (() => []);
87+
7388
let clone = createEmpty(sources[0]);
7489

7590
sources.forEach(src => {
@@ -89,14 +104,16 @@ export function merge<T extends object>(...sources: T[]) {
89104

90105
if (isArr) {
91106
// Array will always be override
92-
clone = set(clone, path, []);
107+
clone = set(clone, path, finalPrepareArray(originValue, value));
93108
} else if (!originValue || typeof originValue !== 'object') {
94109
// Init container if not exist
95110
clone = set(clone, path, createEmpty(value));
96111
}
97112

98113
keys(value).forEach(key => {
99-
internalMerge([...path, key], loopSet);
114+
if (Object.getOwnPropertyDescriptor(value, key).enumerable) {
115+
internalMerge([...path, key], loopSet);
116+
}
100117
});
101118
}
102119
} else {
@@ -109,3 +126,11 @@ export function merge<T extends object>(...sources: T[]) {
109126

110127
return clone;
111128
}
129+
130+
/**
131+
* Merge multiple objects into a new single object.
132+
* Arrays will be replaced by default.
133+
*/
134+
export function merge<T extends object>(...sources: T[]) {
135+
return mergeWith(sources);
136+
}

0 commit comments

Comments
 (0)