@rainbow-oh/yee-c 0.14.6 → 0.15.0
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/dist/cjs/Form/Field.js +20 -1
- package/dist/cjs/Form/Group.js +3 -1
- package/dist/cjs/Form/index.d.ts +1 -1
- package/dist/cjs/Input/style/index.less +1 -0
- package/dist/cjs/InputNumber/style/index.less +1 -0
- package/dist/cjs/Select/hooks/useSelectKeyboard.js +5 -2
- package/dist/cjs/Selector/selector.js +6 -3
- package/dist/cjs/Selector/style/index.less +2 -2
- package/dist/cjs/TableSelect/hooks/useTableSelectKeyboard.d.ts +27 -0
- package/dist/cjs/TableSelect/hooks/useTableSelectKeyboard.js +128 -0
- package/dist/cjs/TableSelect/interface.d.ts +9 -4
- package/dist/cjs/TableSelect/internal-table.js +27 -14
- package/dist/cjs/TableSelect/style/index.less +3 -11
- package/dist/cjs/TableSelect/table-select.js +40 -3
- package/dist/cjs/style/color.css +5 -5
- package/dist/cjs/style/color.gemini.css +1 -1
- package/dist/cjs/style/size.css +1 -1
- package/dist/cjs/style/themes.css +2 -2
- package/dist/cjs/style/variables.css +7 -7
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/Form/Field.js +35 -4
- package/dist/esm/Form/Group.js +10 -6
- package/dist/esm/Form/index.d.ts +1 -1
- package/dist/esm/Input/style/index.less +1 -0
- package/dist/esm/InputNumber/style/index.less +1 -0
- package/dist/esm/Select/hooks/useSelectKeyboard.js +7 -2
- package/dist/esm/Selector/selector.js +10 -5
- package/dist/esm/Selector/style/index.less +2 -2
- package/dist/esm/TableSelect/hooks/useTableSelectKeyboard.d.ts +27 -0
- package/dist/esm/TableSelect/hooks/useTableSelectKeyboard.js +132 -0
- package/dist/esm/TableSelect/interface.d.ts +9 -4
- package/dist/esm/TableSelect/internal-table.js +6 -2
- package/dist/esm/TableSelect/style/index.less +3 -11
- package/dist/esm/TableSelect/table-select.js +36 -3
- package/dist/esm/style/color.css +5 -5
- package/dist/esm/style/color.gemini.css +1 -1
- package/dist/esm/style/size.css +1 -1
- package/dist/esm/style/themes.css +2 -2
- package/dist/esm/style/variables.css +7 -7
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/umd/yee-c.min.css +3 -3
- package/dist/umd/yee-c.min.js +1 -1
- package/package.json +1 -1
- package/src/Form/Field.required.test.tsx +146 -0
- package/src/Form/Field.tsx +32 -2
- package/src/Form/Group.tsx +7 -2
- package/src/Form/index.ts +4 -0
- package/src/Input/style/index.less +1 -0
- package/src/InputNumber/style/index.less +1 -0
- package/src/Select/hooks/useSelectKeyboard.ts +7 -2
- package/src/Selector/selector.tsx +10 -5
- package/src/Selector/style/index.less +2 -2
- package/src/TableSelect/hooks/useTableSelectKeyboard.ts +141 -0
- package/src/TableSelect/index.en-US.md +47 -44
- package/src/TableSelect/index.zh-CN.md +47 -44
- package/src/TableSelect/interface.ts +9 -4
- package/src/TableSelect/internal-table.tsx +14 -6
- package/src/TableSelect/style/index.less +3 -11
- package/src/TableSelect/table-select.tsx +45 -3
- package/src/style/color.css +5 -5
- package/src/style/color.gemini.css +1 -1
- package/src/style/size.css +1 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
4
|
+
import { act } from 'react';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// act 需要(仓库无全局 vitest setup,逐文件声明)
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
import Field from './Field';
|
|
10
|
+
import FieldContext from './FieldContext';
|
|
11
|
+
import FormContext from './FormContext';
|
|
12
|
+
import Group from './Group';
|
|
13
|
+
import { FormStore } from './form-store';
|
|
14
|
+
|
|
15
|
+
const formContextValue = { prefixCls: 'yee-form' } as const;
|
|
16
|
+
|
|
17
|
+
function Harness({ required }: { required: boolean }) {
|
|
18
|
+
return (
|
|
19
|
+
<Field name="username" required={required}>
|
|
20
|
+
<input />
|
|
21
|
+
</Field>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function GroupHarness({ required }: { required: boolean }) {
|
|
26
|
+
return (
|
|
27
|
+
<Group required={required} label="组">
|
|
28
|
+
<Field name="a">
|
|
29
|
+
<input />
|
|
30
|
+
</Field>
|
|
31
|
+
</Group>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderField(required: boolean) {
|
|
36
|
+
// 测试无 LocaleProvider,默认必填文案为空串(不渲染错误 DOM),手动设置
|
|
37
|
+
const formStore = new FormStore();
|
|
38
|
+
formStore.setDefaultValidateMessage('该字段必填');
|
|
39
|
+
const store = formStore.getForm();
|
|
40
|
+
const { container, rerender } = render(
|
|
41
|
+
<FormContext.Provider value={formContextValue}>
|
|
42
|
+
<FieldContext.Provider value={store}>
|
|
43
|
+
<Harness required={required} />
|
|
44
|
+
</FieldContext.Provider>
|
|
45
|
+
</FormContext.Provider>,
|
|
46
|
+
);
|
|
47
|
+
const rerenderWith = (nextRequired: boolean) =>
|
|
48
|
+
rerender(
|
|
49
|
+
<FormContext.Provider value={formContextValue}>
|
|
50
|
+
<FieldContext.Provider value={store}>
|
|
51
|
+
<Harness required={nextRequired} />
|
|
52
|
+
</FieldContext.Provider>
|
|
53
|
+
</FormContext.Provider>,
|
|
54
|
+
);
|
|
55
|
+
const errorEl = () => container.querySelector('.yee-form-item-additional-error');
|
|
56
|
+
return { store, rerenderWith, errorEl };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
cleanup();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// required 触发校验报错后,把 required 改为 false,错误应立即消失:
|
|
64
|
+
// 1) store 里注册的 entity 必须读到最新 props(旧实现是首帧快照,重校验仍失败)
|
|
65
|
+
// 2) required/rules 变化时需对已报错字段重新校验,否则旧错误无人清除
|
|
66
|
+
describe('Field required 运行时切换', () => {
|
|
67
|
+
it('报错后 required 改为 false,错误立即消失', async () => {
|
|
68
|
+
const { store, rerenderWith, errorEl } = renderField(true);
|
|
69
|
+
|
|
70
|
+
await act(async () => {
|
|
71
|
+
await store.validateField('username');
|
|
72
|
+
});
|
|
73
|
+
expect(store.getFieldValidate('username')).not.toBeNull();
|
|
74
|
+
expect(errorEl()).not.toBeNull();
|
|
75
|
+
|
|
76
|
+
rerenderWith(false);
|
|
77
|
+
|
|
78
|
+
expect(store.getFieldValidate('username')).toBeNull();
|
|
79
|
+
// 错误文案经 AnimatePresence 退出动画移除,jsdom 需等动画结束
|
|
80
|
+
await waitFor(() => expect(errorEl()).toBeNull());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('required 改为 false 后再次校验通过', async () => {
|
|
84
|
+
const { store, rerenderWith } = renderField(true);
|
|
85
|
+
|
|
86
|
+
await act(async () => {
|
|
87
|
+
await store.validateField('username');
|
|
88
|
+
});
|
|
89
|
+
rerenderWith(false);
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await store.validateField('username');
|
|
93
|
+
});
|
|
94
|
+
expect(store.getFieldValidate('username')).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('未报错字段在 rules 变化时保持无错(不骚扰未触碰字段)', () => {
|
|
98
|
+
const { store, rerenderWith } = renderField(true);
|
|
99
|
+
rerenderWith(false);
|
|
100
|
+
expect(store.getFieldValidate('username')).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('报错后 required 保持 true 且值仍为空,重校验仍报错', async () => {
|
|
104
|
+
const { store, rerenderWith, errorEl } = renderField(true);
|
|
105
|
+
|
|
106
|
+
await act(async () => {
|
|
107
|
+
await store.validateField('username');
|
|
108
|
+
});
|
|
109
|
+
rerenderWith(true);
|
|
110
|
+
|
|
111
|
+
expect(store.getFieldValidate('username')).not.toBeNull();
|
|
112
|
+
expect(errorEl()).not.toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('Group required 运行时切换', () => {
|
|
117
|
+
it('报错后 required 改为 false,submit 不再报组错误', async () => {
|
|
118
|
+
const store = new FormStore().getForm();
|
|
119
|
+
const { rerender } = render(
|
|
120
|
+
<FormContext.Provider value={formContextValue}>
|
|
121
|
+
<FieldContext.Provider value={store}>
|
|
122
|
+
<GroupHarness required />
|
|
123
|
+
</FieldContext.Provider>
|
|
124
|
+
</FormContext.Provider>,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
let errors: unknown[] = [];
|
|
128
|
+
await act(async () => {
|
|
129
|
+
errors = await store.submit();
|
|
130
|
+
});
|
|
131
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
132
|
+
|
|
133
|
+
rerender(
|
|
134
|
+
<FormContext.Provider value={formContextValue}>
|
|
135
|
+
<FieldContext.Provider value={store}>
|
|
136
|
+
<GroupHarness required={false} />
|
|
137
|
+
</FieldContext.Provider>
|
|
138
|
+
</FormContext.Provider>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
errors = await store.submit();
|
|
143
|
+
});
|
|
144
|
+
expect(errors).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
package/src/Form/Field.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import clsx from 'clsx';
|
|
2
2
|
import { HelpCircle } from 'lucide-react';
|
|
3
3
|
import { AnimatePresence, motion } from 'motion/react';
|
|
4
|
-
import React, { useContext, useLayoutEffect, useMemo, useReducer } from 'react';
|
|
4
|
+
import React, { useContext, useLayoutEffect, useMemo, useReducer, useRef } from 'react';
|
|
5
5
|
import Tooltip from '../Tooltip';
|
|
6
6
|
import FieldContext from './FieldContext';
|
|
7
7
|
import FormContext from './FormContext';
|
|
@@ -45,6 +45,7 @@ const Field: React.FC<FieldProps> = (props) => {
|
|
|
45
45
|
const {
|
|
46
46
|
getCallbacks,
|
|
47
47
|
getFieldStatus,
|
|
48
|
+
getFieldValidate,
|
|
48
49
|
getFieldValue,
|
|
49
50
|
getFieldsValue,
|
|
50
51
|
setFieldsValue,
|
|
@@ -74,14 +75,43 @@ const Field: React.FC<FieldProps> = (props) => {
|
|
|
74
75
|
return stringifyPath(fullFieldNamePath);
|
|
75
76
|
}, [fullFieldNamePath]);
|
|
76
77
|
|
|
78
|
+
// Registration happens once per field name, but the store (validateField)
|
|
79
|
+
// reads validation config off entity.props on every validation pass — the
|
|
80
|
+
// getter keeps those reads on the latest render's props so runtime changes
|
|
81
|
+
// to required/rules take effect.
|
|
82
|
+
const propsRef = useRef(props);
|
|
83
|
+
propsRef.current = props;
|
|
84
|
+
|
|
77
85
|
useLayoutEffect(() => {
|
|
78
86
|
const unregister = registerFieldEntities?.({
|
|
79
|
-
props
|
|
87
|
+
get props() {
|
|
88
|
+
return { ...propsRef.current, name: normalizedName };
|
|
89
|
+
},
|
|
80
90
|
onStoreChange: forceUpdate,
|
|
81
91
|
});
|
|
82
92
|
return unregister;
|
|
83
93
|
}, [normalizedName, registerFieldEntities]);
|
|
84
94
|
|
|
95
|
+
// JSON signature (not reference) because rules is typically an inline array
|
|
96
|
+
// literal; validator functions are dropped, but the getter above already
|
|
97
|
+
// makes the next natural validation use them.
|
|
98
|
+
const validateSignature = useMemo(
|
|
99
|
+
() => JSON.stringify({ required, rules }),
|
|
100
|
+
[required, rules],
|
|
101
|
+
);
|
|
102
|
+
const prevValidateSignatureRef = useRef(validateSignature);
|
|
103
|
+
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const prev = prevValidateSignatureRef.current;
|
|
106
|
+
prevValidateSignatureRef.current = validateSignature;
|
|
107
|
+
if (prev === validateSignature) return;
|
|
108
|
+
// Only re-check fields already showing a validation message — pristine
|
|
109
|
+
// fields must not start erroring just because rules changed.
|
|
110
|
+
if (getFieldValidate?.(normalizedName)) {
|
|
111
|
+
void validateField?.(normalizedName);
|
|
112
|
+
}
|
|
113
|
+
}, [validateSignature, normalizedName]);
|
|
114
|
+
|
|
85
115
|
const isRequired = () => {
|
|
86
116
|
return (
|
|
87
117
|
(Array.isArray(rules) && rules.find((rule) => rule.required)) || required
|
package/src/Form/Group.tsx
CHANGED
|
@@ -107,11 +107,16 @@ const Group: React.FC<FieldGroupProps> = (props) => {
|
|
|
107
107
|
return () => unsubscribes.forEach((fn) => fn());
|
|
108
108
|
}, [childNames, formInstance]);
|
|
109
109
|
|
|
110
|
-
// Register group validator to FormStore
|
|
110
|
+
// Register group validator to FormStore. The closure is registered once, so
|
|
111
|
+
// it reads the latest validate (and its required/rules/childNames captures)
|
|
112
|
+
// through a ref — otherwise runtime prop changes never take effect.
|
|
113
|
+
const validateRef = useRef(validate);
|
|
114
|
+
validateRef.current = validate;
|
|
115
|
+
|
|
111
116
|
useEffect(() => {
|
|
112
117
|
const unregister = formInstance.registerGroupEntity(groupId, {
|
|
113
118
|
validate: async (force) => {
|
|
114
|
-
const result = await
|
|
119
|
+
const result = await validateRef.current(force);
|
|
115
120
|
setErrors(result);
|
|
116
121
|
forceUpdate();
|
|
117
122
|
return result;
|
package/src/Form/index.ts
CHANGED
|
@@ -261,8 +261,13 @@ export default function useSelectKeyboard({
|
|
|
261
261
|
handleEnter();
|
|
262
262
|
break;
|
|
263
263
|
case 'Escape':
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
// Only swallow Escape while open so outer layers (e.g. a Dialog's
|
|
265
|
+
// useEsc listening on document) still receive it when closed.
|
|
266
|
+
if (open) {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
handleEscape();
|
|
270
|
+
}
|
|
266
271
|
break;
|
|
267
272
|
default:
|
|
268
273
|
}
|
|
@@ -284,12 +284,17 @@ const Selector = React.forwardRef(
|
|
|
284
284
|
aria-disabled={disabled || undefined}
|
|
285
285
|
onKeyDown={(event) => {
|
|
286
286
|
if (disabled) return;
|
|
287
|
-
//
|
|
288
|
-
if (
|
|
289
|
-
|
|
287
|
+
// Enter opens the dropdown while it is closed
|
|
288
|
+
if (event.key === 'Enter' && !open && onOpenChange) {
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
onOpenChange(true);
|
|
291
|
+
}
|
|
292
|
+
// Input forwards this handler for its own keydowns; handling them
|
|
293
|
+
// here too would fire the handler twice per keypress. Only handle
|
|
294
|
+
// events that originate on the container (non-searchable focus).
|
|
295
|
+
if (event.target !== inputRef.current) {
|
|
296
|
+
externalOnKeyDown?.(event as React.KeyboardEvent<HTMLInputElement>);
|
|
290
297
|
}
|
|
291
|
-
// Also call external onKeyDown if provided
|
|
292
|
-
externalOnKeyDown?.(event as React.KeyboardEvent<HTMLInputElement>);
|
|
293
298
|
}}
|
|
294
299
|
ref={ref}
|
|
295
300
|
>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { TableSelectDataType } from '../interface';
|
|
3
|
+
|
|
4
|
+
type KeyType = string | number;
|
|
5
|
+
|
|
6
|
+
interface UseTableSelectKeyboardParams {
|
|
7
|
+
open: boolean;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
dataSource: Array<TableSelectDataType>;
|
|
10
|
+
rowKey: string | ((record: any) => string);
|
|
11
|
+
selectionType: 'radio' | 'checkbox';
|
|
12
|
+
selectedRowKeys: Array<KeyType>;
|
|
13
|
+
searchValue: string;
|
|
14
|
+
onSelect: (keys: Array<KeyType>, rows: Array<TableSelectDataType>) => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Keyboard navigation for the popup table. The highlight is positional (like a
|
|
20
|
+
* hover cursor): `activeRow` is a rendered row index, and Space selects the
|
|
21
|
+
* record displayed there. The rendered order is the filter→sort→paginate
|
|
22
|
+
* result inside Table and is not derivable from `dataSource`, so the row count
|
|
23
|
+
* and the highlighted row's key are both read from the popup DOM.
|
|
24
|
+
*/
|
|
25
|
+
export default function useTableSelectKeyboard({
|
|
26
|
+
open,
|
|
27
|
+
disabled,
|
|
28
|
+
dataSource,
|
|
29
|
+
rowKey,
|
|
30
|
+
selectionType,
|
|
31
|
+
selectedRowKeys,
|
|
32
|
+
searchValue,
|
|
33
|
+
onSelect,
|
|
34
|
+
onClose,
|
|
35
|
+
}: UseTableSelectKeyboardParams) {
|
|
36
|
+
const popupRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
+
// 1-based index of the highlighted row (`tr[data-row-index]`), 0 = none.
|
|
38
|
+
const [activeRow, setActiveRow] = useState(0);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setActiveRow(0);
|
|
42
|
+
}, [open, searchValue]);
|
|
43
|
+
|
|
44
|
+
const getRecordKey = (item: TableSelectDataType) =>
|
|
45
|
+
typeof rowKey === 'function' ? rowKey(item) : item[rowKey];
|
|
46
|
+
|
|
47
|
+
const getRowCount = () =>
|
|
48
|
+
popupRef.current?.querySelectorAll('tr[data-row-index]').length ?? 0;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open || activeRow < 1) return;
|
|
52
|
+
const rowEl = popupRef.current?.querySelector(
|
|
53
|
+
`tr[data-row-index="${activeRow}"]`,
|
|
54
|
+
) as HTMLElement | null;
|
|
55
|
+
if (!rowEl) return;
|
|
56
|
+
// Manual scroll instead of scrollIntoView: the popup renders in a portal,
|
|
57
|
+
// and scrollIntoView would scroll the page as well as the popup.
|
|
58
|
+
let parent = rowEl.parentElement;
|
|
59
|
+
while (parent && parent !== popupRef.current) {
|
|
60
|
+
const { overflowY } = window.getComputedStyle(parent);
|
|
61
|
+
if (
|
|
62
|
+
/(auto|scroll)/.test(overflowY) &&
|
|
63
|
+
parent.scrollHeight > parent.clientHeight
|
|
64
|
+
) {
|
|
65
|
+
const parentRect = parent.getBoundingClientRect();
|
|
66
|
+
const rowRect = rowEl.getBoundingClientRect();
|
|
67
|
+
if (rowRect.bottom > parentRect.bottom) {
|
|
68
|
+
parent.scrollTop += rowRect.bottom - parentRect.bottom;
|
|
69
|
+
} else if (rowRect.top < parentRect.top) {
|
|
70
|
+
parent.scrollTop -= parentRect.top - rowRect.top;
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
parent = parent.parentElement;
|
|
75
|
+
}
|
|
76
|
+
}, [activeRow, open]);
|
|
77
|
+
|
|
78
|
+
const selectActiveRow = () => {
|
|
79
|
+
const rowEl = popupRef.current?.querySelector(
|
|
80
|
+
`tr[data-row-index="${activeRow}"]`,
|
|
81
|
+
);
|
|
82
|
+
const inputEl = rowEl?.querySelector(
|
|
83
|
+
'input[type="radio"],input[type="checkbox"]',
|
|
84
|
+
) as HTMLInputElement | null;
|
|
85
|
+
if (!inputEl || inputEl.disabled) return;
|
|
86
|
+
|
|
87
|
+
const record = dataSource.find(
|
|
88
|
+
(item) => String(getRecordKey(item)) === inputEl.value,
|
|
89
|
+
);
|
|
90
|
+
if (!record) return;
|
|
91
|
+
const key = getRecordKey(record);
|
|
92
|
+
|
|
93
|
+
if (selectionType === 'radio') {
|
|
94
|
+
// Same shape as the click path: arrays, normalized by the consumer.
|
|
95
|
+
onSelect([key], [record]);
|
|
96
|
+
onClose();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const checked = selectedRowKeys.some((k) => String(k) === String(key));
|
|
101
|
+
const nextKeys = checked
|
|
102
|
+
? selectedRowKeys.filter((k) => String(k) !== String(key))
|
|
103
|
+
: [...selectedRowKeys, key];
|
|
104
|
+
const rows = dataSource.filter((item) =>
|
|
105
|
+
nextKeys.some((k) => String(getRecordKey(item)) === String(k)),
|
|
106
|
+
);
|
|
107
|
+
onSelect(nextKeys, rows);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
111
|
+
if (!open || disabled) return;
|
|
112
|
+
switch (event.key) {
|
|
113
|
+
case 'ArrowDown':
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
setActiveRow(Math.min(activeRow + 1, getRowCount()));
|
|
116
|
+
break;
|
|
117
|
+
case 'ArrowUp':
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
setActiveRow(activeRow <= 1 ? getRowCount() : activeRow - 1);
|
|
120
|
+
break;
|
|
121
|
+
case ' ':
|
|
122
|
+
// With nothing highlighted, space keeps its native input behavior.
|
|
123
|
+
if (activeRow > 0) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
selectActiveRow();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case 'Escape':
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
// Stop the event from reaching outer layers (e.g. a Dialog's useEsc
|
|
131
|
+
// listening on document) so only the dropdown closes.
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
onClose();
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { popupRef, activeRow, handleKeyDown };
|
|
141
|
+
}
|
|
@@ -31,45 +31,45 @@ A component for selecting one or more options from table data, combining feature
|
|
|
31
31
|
|
|
32
32
|
### TableSelectProps
|
|
33
33
|
|
|
34
|
-
| Property
|
|
35
|
-
|
|
|
36
|
-
| prefixCls
|
|
37
|
-
| className
|
|
38
|
-
| style
|
|
39
|
-
| rowKey
|
|
40
|
-
| columns
|
|
41
|
-
| dataSource
|
|
42
|
-
| rowSelection
|
|
43
|
-
| value
|
|
44
|
-
| defaultValue
|
|
45
|
-
| disabled
|
|
46
|
-
| loading
|
|
47
|
-
| placement
|
|
48
|
-
| searchable
|
|
49
|
-
| searchOnInput
|
|
50
|
-
| allowClear
|
|
51
|
-
| closable
|
|
52
|
-
| placeholder
|
|
53
|
-
| optionLabelProp | `string \| ((option:
|
|
54
|
-
| type
|
|
55
|
-
| options
|
|
56
|
-
| isShowFilter
|
|
57
|
-
| containerId
|
|
58
|
-
| io
|
|
59
|
-
| onChange
|
|
60
|
-
| onSearch
|
|
61
|
-
| onOpenChange
|
|
34
|
+
| Property | Type | Description | Default Value |
|
|
35
|
+
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
|
36
|
+
| prefixCls | `string` | Custom class name prefix | `'yee-table-select'` |
|
|
37
|
+
| className | `string` | Root element class name | - |
|
|
38
|
+
| style | `React.CSSProperties` | Root element inline styles | - |
|
|
39
|
+
| rowKey | `string \| ((record: any) => string)` | Unique identifier field name for data | `'id'` |
|
|
40
|
+
| columns | `Array<any>` | Table column configuration | - |
|
|
41
|
+
| dataSource | `Array<TableSelectDataType>` | Data source | - |
|
|
42
|
+
| rowSelection | `RowSelectionType` | Row selection configuration | - |
|
|
43
|
+
| value | `SelectionKeyType \| SelectionKeyType[]` | Controlled value | - |
|
|
44
|
+
| defaultValue | `SelectionKeyType \| SelectionKeyType[]` | Default value | - |
|
|
45
|
+
| disabled | `boolean` | Whether disabled | - |
|
|
46
|
+
| loading | `boolean` | Whether data is loading; displays a gray loading indicator | `false` |
|
|
47
|
+
| placement | `'bottomLeft' \| 'topLeft'` | Popup placement | `'bottomLeft'` |
|
|
48
|
+
| searchable | `boolean` | Whether searchable | `true` |
|
|
49
|
+
| searchOnInput | `boolean` | Whether to search while typing | `true` |
|
|
50
|
+
| allowClear | `boolean` | Whether allow clear | `true` |
|
|
51
|
+
| closable | `boolean` | Whether tags are closable | `true` |
|
|
52
|
+
| placeholder | `string` | Placeholder text | - |
|
|
53
|
+
| optionLabelProp | `string \| ((option: TableSelectDataType) => string)` | Option label property | `'name'` |
|
|
54
|
+
| type | `'radio' \| 'checkbox'` | Selection type | `'radio'` |
|
|
55
|
+
| options | `TagType[]` | Preset options | - |
|
|
56
|
+
| isShowFilter | `boolean` | Whether to show filter (All/Selected for multiple) | `true` |
|
|
57
|
+
| containerId | `string` | Container ID (for popup mounting) | - |
|
|
58
|
+
| io | `'out'` | Input direction | - |
|
|
59
|
+
| onChange | `(keys, data, checked, changeData) => void` | Selection change callback | - |
|
|
60
|
+
| onSearch | `(value: string, e?) => void` | Search callback | - |
|
|
61
|
+
| onOpenChange | `(open: boolean) => void` | Popup state change callback | - |
|
|
62
62
|
|
|
63
63
|
### RowSelectionType
|
|
64
64
|
|
|
65
65
|
Row selection configuration, inherited from Table component's RowSelectionType.
|
|
66
66
|
|
|
67
|
-
| Property
|
|
68
|
-
|
|
|
69
|
-
| type
|
|
70
|
-
| selectedRowKeys
|
|
71
|
-
| defaultSelectedRowKeys | `Array<SelectionKeyType> \| SelectionKeyType` | Default selected row keys
|
|
72
|
-
| onChange
|
|
67
|
+
| Property | Type | Description | Default Value |
|
|
68
|
+
| ---------------------- | --------------------------------------------- | ------------------------------- | ------------- |
|
|
69
|
+
| type | `'radio' \| 'checkbox'` | Single/multiple selection | - |
|
|
70
|
+
| selectedRowKeys | `Array<SelectionKeyType> \| SelectionKeyType` | (Controlled) selected row keys | - |
|
|
71
|
+
| defaultSelectedRowKeys | `Array<SelectionKeyType> \| SelectionKeyType` | Default selected row keys | - |
|
|
72
|
+
| onChange | `(selectedRowKeys, selectedRows) => void` | Callback when selection changes | - |
|
|
73
73
|
|
|
74
74
|
## Methods
|
|
75
75
|
|
|
@@ -77,17 +77,20 @@ Row selection configuration, inherited from Table component's RowSelectionType.
|
|
|
77
77
|
|
|
78
78
|
InternalTable is an internal component of TableSelect and typically doesn't need to be used directly.
|
|
79
79
|
|
|
80
|
-
| Property
|
|
81
|
-
|
|
|
82
|
-
| rowKey
|
|
83
|
-
| columns
|
|
84
|
-
| dataSource
|
|
85
|
-
| rowSelection | `RowSelectionType`
|
|
86
|
-
| type
|
|
87
|
-
| value
|
|
88
|
-
| isShowFilter | `boolean`
|
|
89
|
-
| visible
|
|
80
|
+
| Property | Type | Description | Default Value |
|
|
81
|
+
| ------------ | ------------------------------------------- | ---------------------------------------------------------------------- | ------------- |
|
|
82
|
+
| rowKey | `string` | Unique identifier field name for data | - |
|
|
83
|
+
| columns | `Array<any>` | Table column configuration | - |
|
|
84
|
+
| dataSource | `Array<TableSelectDataType>` | Data source | - |
|
|
85
|
+
| rowSelection | `RowSelectionType` | Row selection configuration | - |
|
|
86
|
+
| type | `'radio' \| 'checkbox'` | Selection type | - |
|
|
87
|
+
| value | `SelectedKeyType \| Array<SelectedKeyType>` | Selected value | - |
|
|
88
|
+
| isShowFilter | `boolean` | Whether to show filter | `true` |
|
|
89
|
+
| visible | `boolean` | Whether popup is visible | - |
|
|
90
|
+
| popupRef | `React.RefObject<HTMLDivElement \| null>` | Ref to the popup root element, used internally for keyboard navigation | - |
|
|
90
91
|
|
|
91
92
|
## Notes
|
|
92
93
|
|
|
94
|
+
- When the dropdown is open, keyboard is supported: `↑`/`↓` move the highlight across rows, `Space` selects/toggles the highlighted row, `Esc` closes the dropdown; the text caret always stays in the input; press `Enter` while closed to open the dropdown
|
|
95
|
+
|
|
93
96
|
- Adapted for mobile (popup constrained with max-width/height and scrollable)
|