@rainbow-oh/yee-c 0.6.3 → 0.7.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/Button/style/index.less +5 -4
- package/dist/cjs/Checkbox/style/index.less +1 -1
- package/dist/cjs/Drawer/drawer.js +1 -1
- package/dist/cjs/Drawer/interface.d.ts +1 -1
- package/dist/cjs/Drawer/style/index.less +1 -0
- package/dist/cjs/List/demo/virtual.d.ts +3 -0
- package/dist/cjs/List/demo/virtual.js +53 -0
- package/dist/cjs/List/hooks/useListKeyboard.d.ts +28 -0
- package/dist/cjs/List/hooks/useListKeyboard.js +188 -0
- package/dist/cjs/List/interface.d.ts +29 -2
- package/dist/cjs/List/list.js +141 -21
- package/dist/cjs/List/style/index.less +24 -0
- package/dist/cjs/Select/demo/columns.d.ts +3 -0
- package/dist/cjs/Select/demo/columns.js +62 -0
- package/dist/cjs/Select/hooks/useSelectKeyboard.d.ts +13 -1
- package/dist/cjs/Select/hooks/useSelectKeyboard.js +81 -10
- package/dist/cjs/Select/interface.d.ts +30 -3
- package/dist/cjs/Select/option.js +4 -3
- package/dist/cjs/Select/options.js +90 -8
- package/dist/cjs/Select/select.js +32 -8
- package/dist/cjs/Select/style/index.less +14 -0
- package/dist/cjs/Selector/interface.d.ts +4 -0
- package/dist/cjs/Selector/selector.js +5 -3
- package/dist/cjs/Table/interface.d.ts +1 -1
- package/dist/cjs/Table/style/index.less +1 -1
- package/dist/cjs/Tree/demo/draggable.js +2 -0
- package/dist/cjs/Tree/interface.d.ts +46 -23
- package/dist/cjs/Tree/tree-node.d.ts +1 -1
- package/dist/cjs/Tree/tree-node.js +30 -23
- package/dist/cjs/Tree/tree.d.ts +13 -12
- package/dist/cjs/Tree/tree.js +452 -227
- package/dist/cjs/Tree/utils/tree-tools.d.ts +43 -10
- package/dist/cjs/Tree/utils/tree-tools.js +129 -91
- package/dist/cjs/locale/context.d.ts +5 -4
- package/dist/cjs/locale/context.js +15 -8
- package/dist/cjs/locale/interface.d.ts +20 -0
- package/dist/cjs/locale/util.d.ts +3 -3
- package/dist/cjs/style/color.css +1 -0
- package/dist/cjs/style/size.css +2 -1
- package/dist/cjs/style/themes.css +1 -1
- package/dist/cjs/style/variables.css +4 -2
- package/dist/cjs/utils/isEqual.d.ts +1 -1
- package/dist/cjs/utils/isEqual.js +24 -13
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/Button/style/index.less +5 -4
- package/dist/esm/Checkbox/style/index.less +1 -1
- package/dist/esm/Drawer/drawer.js +2 -1
- package/dist/esm/Drawer/interface.d.ts +1 -1
- package/dist/esm/Drawer/style/index.less +1 -0
- package/dist/esm/List/demo/virtual.d.ts +3 -0
- package/dist/esm/List/demo/virtual.js +22 -0
- package/dist/esm/List/hooks/useListKeyboard.d.ts +28 -0
- package/dist/esm/List/hooks/useListKeyboard.js +173 -0
- package/dist/esm/List/interface.d.ts +29 -2
- package/dist/esm/List/list.js +171 -14
- package/dist/esm/List/style/index.less +24 -0
- package/dist/esm/Select/demo/columns.d.ts +3 -0
- package/dist/esm/Select/demo/columns.js +36 -0
- package/dist/esm/Select/hooks/useSelectKeyboard.d.ts +13 -1
- package/dist/esm/Select/hooks/useSelectKeyboard.js +91 -14
- package/dist/esm/Select/interface.d.ts +30 -3
- package/dist/esm/Select/option.js +6 -2
- package/dist/esm/Select/options.js +114 -32
- package/dist/esm/Select/select.js +40 -11
- package/dist/esm/Select/style/index.less +14 -0
- package/dist/esm/Selector/interface.d.ts +4 -0
- package/dist/esm/Selector/selector.js +10 -4
- package/dist/esm/Table/interface.d.ts +1 -1
- package/dist/esm/Table/style/index.less +1 -1
- package/dist/esm/Tree/demo/draggable.js +2 -0
- package/dist/esm/Tree/interface.d.ts +46 -23
- package/dist/esm/Tree/tree-node.d.ts +1 -1
- package/dist/esm/Tree/tree-node.js +26 -31
- package/dist/esm/Tree/tree.d.ts +13 -12
- package/dist/esm/Tree/tree.js +572 -163
- package/dist/esm/Tree/utils/tree-tools.d.ts +43 -10
- package/dist/esm/Tree/utils/tree-tools.js +219 -104
- package/dist/esm/locale/context.d.ts +5 -4
- package/dist/esm/locale/context.js +25 -9
- package/dist/esm/locale/interface.d.ts +20 -0
- package/dist/esm/locale/util.d.ts +3 -3
- package/dist/esm/style/color.css +1 -0
- package/dist/esm/style/size.css +2 -1
- package/dist/esm/style/themes.css +1 -1
- package/dist/esm/style/variables.css +4 -2
- package/dist/esm/utils/isEqual.d.ts +1 -1
- package/dist/esm/utils/isEqual.js +31 -15
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/umd/yee-c.min.css +2 -2
- package/dist/umd/yee-c.min.js +1 -1
- package/package.json +1 -1
- package/src/Button/style/index.less +5 -4
- package/src/Checkbox/style/index.less +1 -1
- package/src/Drawer/drawer.tsx +1 -1
- package/src/Drawer/interface.ts +1 -0
- package/src/Drawer/style/index.less +1 -0
- package/src/List/demo/virtual.tsx +20 -0
- package/src/List/hooks/useListKeyboard.ts +235 -0
- package/src/List/index.en-US.md +6 -0
- package/src/List/index.zh-CN.md +6 -0
- package/src/List/interface.ts +29 -2
- package/src/List/list.tsx +157 -19
- package/src/List/style/index.less +24 -0
- package/src/Select/demo/columns.tsx +35 -0
- package/src/Select/hooks/useSelectKeyboard.ts +112 -15
- package/src/Select/index.en-US.md +4 -0
- package/src/Select/index.zh-CN.md +4 -0
- package/src/Select/interface.ts +31 -4
- package/src/Select/option.tsx +9 -3
- package/src/Select/options.tsx +119 -26
- package/src/Select/select.tsx +41 -8
- package/src/Select/style/index.less +14 -0
- package/src/Selector/interface.ts +4 -0
- package/src/Selector/selector.tsx +8 -3
- package/src/Table/interface.ts +1 -1
- package/src/Table/style/index.less +1 -1
- package/src/Tree/demo/draggable.tsx +2 -0
- package/src/Tree/interface.ts +46 -23
- package/src/Tree/tree-node.tsx +26 -29
- package/src/Tree/tree.tsx +573 -274
- package/src/Tree/utils/tree-tools.ts +231 -125
- package/src/locale/_README.md +22 -13
- package/src/locale/context.tsx +33 -16
- package/src/locale/interface.ts +24 -0
- package/src/locale/util.ts +6 -6
- package/src/style/color.css +1 -0
- package/src/style/size.css +2 -1
- package/src/utils/isEqual.ts +35 -14
- package/src/version.ts +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Select } from '@rainbow-oh/yee-c';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const options = Array.from({ length: 24 }, (_, i) => ({
|
|
5
|
+
label: `Option ${i + 1}`,
|
|
6
|
+
value: `opt-${i + 1}`,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
export default () => {
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
12
|
+
<div>
|
|
13
|
+
<p>3 columns:</p>
|
|
14
|
+
<Select
|
|
15
|
+
columns={3}
|
|
16
|
+
popupWidth={360}
|
|
17
|
+
options={options}
|
|
18
|
+
placeholder="Select an option"
|
|
19
|
+
style={{ width: 200 }}
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
<div>
|
|
23
|
+
<p>4 columns with multiple selection:</p>
|
|
24
|
+
<Select
|
|
25
|
+
columns={4}
|
|
26
|
+
popupWidth={480}
|
|
27
|
+
mode="multiple"
|
|
28
|
+
options={options}
|
|
29
|
+
placeholder="Select options"
|
|
30
|
+
style={{ width: 300 }}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -9,6 +9,18 @@ interface UseSelectKeyboardParams {
|
|
|
9
9
|
}>;
|
|
10
10
|
open: boolean;
|
|
11
11
|
selectedKeys: Array<string | number>;
|
|
12
|
+
/**
|
|
13
|
+
* Number of columns in multi-column mode.
|
|
14
|
+
* Affects ArrowLeft/ArrowRight and ArrowUp/ArrowDown behavior.
|
|
15
|
+
* @default 1
|
|
16
|
+
*/
|
|
17
|
+
columns?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Whether loose value matching is enabled.
|
|
20
|
+
* Affects initial focus resolution when selectedKeys types differ from option values.
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
looseMatch?: boolean;
|
|
12
24
|
onSelect: (value: string | number) => void;
|
|
13
25
|
onClose?: () => void;
|
|
14
26
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -25,6 +37,8 @@ export default function useSelectKeyboard({
|
|
|
25
37
|
options,
|
|
26
38
|
open,
|
|
27
39
|
selectedKeys,
|
|
40
|
+
columns = 1,
|
|
41
|
+
looseMatch = false,
|
|
28
42
|
onSelect,
|
|
29
43
|
onClose,
|
|
30
44
|
onOpenChange,
|
|
@@ -79,9 +93,15 @@ export default function useSelectKeyboard({
|
|
|
79
93
|
const optionElements = container.querySelectorAll('[role="option"]');
|
|
80
94
|
const targetElement = optionElements[index] as HTMLElement;
|
|
81
95
|
if (targetElement) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
// Use manual scroll calculation instead of scrollIntoView to avoid
|
|
97
|
+
// scrolling the entire page (popup is rendered in a portal).
|
|
98
|
+
const containerRect = container.getBoundingClientRect();
|
|
99
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
100
|
+
if (targetRect.bottom > containerRect.bottom) {
|
|
101
|
+
container.scrollTop += targetRect.bottom - containerRect.bottom;
|
|
102
|
+
} else if (targetRect.top < containerRect.top) {
|
|
103
|
+
container.scrollTop -= containerRect.top - targetRect.top;
|
|
104
|
+
}
|
|
85
105
|
}
|
|
86
106
|
};
|
|
87
107
|
|
|
@@ -90,12 +110,33 @@ export default function useSelectKeyboard({
|
|
|
90
110
|
const focusedIndex = getFocusedIndex();
|
|
91
111
|
let nextIndex: number;
|
|
92
112
|
|
|
93
|
-
if (
|
|
94
|
-
//
|
|
95
|
-
|
|
113
|
+
if (columns > 1) {
|
|
114
|
+
// Multi-column: move up one row (jump by `columns` items)
|
|
115
|
+
if (focusedIndex === -1) {
|
|
116
|
+
nextIndex = findNextEnabledIndex(options.length - 1, -1);
|
|
117
|
+
} else {
|
|
118
|
+
const target = focusedIndex - columns;
|
|
119
|
+
if (target >= 0) {
|
|
120
|
+
nextIndex = options[target].disabled
|
|
121
|
+
? findNextEnabledIndex(target, -1)
|
|
122
|
+
: target;
|
|
123
|
+
} else {
|
|
124
|
+
// Wrap to bottom: find the same column in the last row
|
|
125
|
+
const lastRowStart =
|
|
126
|
+
Math.floor((options.length - 1) / columns) * columns;
|
|
127
|
+
const col = focusedIndex % columns;
|
|
128
|
+
const wrapTarget = Math.min(lastRowStart + col, options.length - 1);
|
|
129
|
+
nextIndex = options[wrapTarget]?.disabled
|
|
130
|
+
? findNextEnabledIndex(wrapTarget, -1)
|
|
131
|
+
: wrapTarget;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
96
134
|
} else {
|
|
97
|
-
|
|
98
|
-
|
|
135
|
+
if (focusedIndex === -1) {
|
|
136
|
+
nextIndex = findNextEnabledIndex(options.length - 1, -1);
|
|
137
|
+
} else {
|
|
138
|
+
nextIndex = findNextEnabledIndex(focusedIndex, -1);
|
|
139
|
+
}
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
if (nextIndex !== -1) {
|
|
@@ -110,13 +151,33 @@ export default function useSelectKeyboard({
|
|
|
110
151
|
const focusedIndex = getFocusedIndex();
|
|
111
152
|
let nextIndex: number;
|
|
112
153
|
|
|
113
|
-
if (
|
|
114
|
-
//
|
|
115
|
-
|
|
154
|
+
if (columns > 1) {
|
|
155
|
+
// Multi-column: move down one row (jump by `columns` items)
|
|
156
|
+
if (focusedIndex === -1) {
|
|
157
|
+
nextIndex = findNextEnabledIndex(0, 1);
|
|
158
|
+
} else {
|
|
159
|
+
const target = focusedIndex + columns;
|
|
160
|
+
if (target < options.length) {
|
|
161
|
+
nextIndex = options[target].disabled
|
|
162
|
+
? findNextEnabledIndex(target, 1)
|
|
163
|
+
: target;
|
|
164
|
+
} else {
|
|
165
|
+
// Wrap to top: find the same column in the first row
|
|
166
|
+
const col = focusedIndex % columns;
|
|
167
|
+
const wrapTarget = col;
|
|
168
|
+
nextIndex = options[wrapTarget]?.disabled
|
|
169
|
+
? findNextEnabledIndex(wrapTarget, 1)
|
|
170
|
+
: wrapTarget;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
116
173
|
} else {
|
|
117
|
-
|
|
118
|
-
|
|
174
|
+
if (focusedIndex === -1) {
|
|
175
|
+
nextIndex = findNextEnabledIndex(0, 1);
|
|
176
|
+
} else {
|
|
177
|
+
nextIndex = findNextEnabledIndex(focusedIndex, 1);
|
|
178
|
+
}
|
|
119
179
|
}
|
|
180
|
+
|
|
120
181
|
if (nextIndex !== -1) {
|
|
121
182
|
const nextValue = options[nextIndex].value;
|
|
122
183
|
setFocusedKey(nextValue);
|
|
@@ -124,6 +185,28 @@ export default function useSelectKeyboard({
|
|
|
124
185
|
}
|
|
125
186
|
};
|
|
126
187
|
|
|
188
|
+
// Handle ArrowLeft key (multi-column: move to previous item)
|
|
189
|
+
const handleArrowLeft = () => {
|
|
190
|
+
const focusedIndex = getFocusedIndex();
|
|
191
|
+
if (focusedIndex <= 0) return;
|
|
192
|
+
const nextIndex = findNextEnabledIndex(focusedIndex, -1);
|
|
193
|
+
if (nextIndex !== -1) {
|
|
194
|
+
setFocusedKey(options[nextIndex].value);
|
|
195
|
+
scrollOptionIntoView(nextIndex);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Handle ArrowRight key (multi-column: move to next item)
|
|
200
|
+
const handleArrowRight = () => {
|
|
201
|
+
const focusedIndex = getFocusedIndex();
|
|
202
|
+
if (focusedIndex >= options.length - 1) return;
|
|
203
|
+
const nextIndex = findNextEnabledIndex(focusedIndex, 1);
|
|
204
|
+
if (nextIndex !== -1) {
|
|
205
|
+
setFocusedKey(options[nextIndex].value);
|
|
206
|
+
scrollOptionIntoView(nextIndex);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
127
210
|
// Handle Enter key
|
|
128
211
|
const handleEnter = () => {
|
|
129
212
|
const currentFocusedKey = latestFocusedKey.current;
|
|
@@ -161,6 +244,18 @@ export default function useSelectKeyboard({
|
|
|
161
244
|
event.preventDefault();
|
|
162
245
|
handleArrowDown();
|
|
163
246
|
break;
|
|
247
|
+
case 'ArrowLeft':
|
|
248
|
+
if (columns > 1) {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
handleArrowLeft();
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
case 'ArrowRight':
|
|
254
|
+
if (columns > 1) {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
handleArrowRight();
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
164
259
|
case 'Enter':
|
|
165
260
|
event.preventDefault();
|
|
166
261
|
handleEnter();
|
|
@@ -179,8 +274,10 @@ export default function useSelectKeyboard({
|
|
|
179
274
|
// Focus the first selected option, or the first enabled option
|
|
180
275
|
let focusedValue: string | number = '';
|
|
181
276
|
if (selectedKeys?.length > 0) {
|
|
182
|
-
const firstSelected = options?.find(
|
|
183
|
-
|
|
277
|
+
const firstSelected = options?.find((opt) =>
|
|
278
|
+
looseMatch
|
|
279
|
+
? String(opt.value) === String(selectedKeys[0])
|
|
280
|
+
: opt.value === selectedKeys[0],
|
|
184
281
|
);
|
|
185
282
|
if (firstSelected && !firstSelected.disabled) {
|
|
186
283
|
focusedValue = firstSelected.value;
|
|
@@ -23,6 +23,7 @@ Select component for choosing from a set of options.
|
|
|
23
23
|
<code src="./demo/tags.tsx" title="Tags" description="Tags mode Select"></code>
|
|
24
24
|
<code src="./demo/orphan.tsx" title="Orphan Value" description="Display and custom styling when the value is not in options"></code>
|
|
25
25
|
<code src="./demo/virtual.tsx" title="Virtual Scroll" description="Virtual scrolling for large datasets (thousands of options)"></code>
|
|
26
|
+
<code src="./demo/columns.tsx" title="Multi-Column" description="Dropdown panel supports multi-column grid display to save vertical space when there are many options"></code>
|
|
26
27
|
|
|
27
28
|
## API
|
|
28
29
|
|
|
@@ -53,6 +54,9 @@ Select component for choosing from a set of options.
|
|
|
53
54
|
| virtual | `boolean` | Enable virtual scrolling. Only the options in the visible window are rendered, so the DOM node count stays constant regardless of the total. Recommended for hundreds or thousands of options | `false` |
|
|
54
55
|
| itemHeight | `number` | Fixed height of each option in pixels. Must be a fixed value when `virtual` is enabled | `32` |
|
|
55
56
|
| listHeight | `number` | Max height of the dropdown popup in pixels | `200` |
|
|
57
|
+
| columns | `number` | Number of columns in the dropdown panel. Enables grid layout when greater than 1 | `1` |
|
|
58
|
+
| popupWidth | `number` | Fixed width of the dropdown popup in pixels. When set, the popup does not stretch to match the trigger width. Useful with multi-column layout | - |
|
|
59
|
+
| looseMatch | `boolean` | Enable loose value matching. When enabled, value comparison uses `String()` conversion instead of strict equality, resolving number/string type mismatch between `value` and `options`. Note: when enabled, `onChange` returns the option's value type, not the originally passed value type | `false` |
|
|
56
60
|
| data-* | `string` | - | Supports all data-* attributes, forwarded to root DOM element |
|
|
57
61
|
|
|
58
62
|
### Option
|
|
@@ -23,6 +23,7 @@ toc: 'content'
|
|
|
23
23
|
<code src="./demo/tags.tsx" title="标签模式" description="标签模式的Select"></code>
|
|
24
24
|
<code src="./demo/orphan.tsx" title="孤儿值" description="value 不在 options 中时的显示与自定义样式"></code>
|
|
25
25
|
<code src="./demo/virtual.tsx" title="虚拟滚动" description="大数据量(几千条)下的虚拟滚动"></code>
|
|
26
|
+
<code src="./demo/columns.tsx" title="多列布局" description="下拉面板支持多列网格显示,适合选项较多时节省纵向空间"></code>
|
|
26
27
|
|
|
27
28
|
## API
|
|
28
29
|
|
|
@@ -53,6 +54,9 @@ toc: 'content'
|
|
|
53
54
|
| virtual | `boolean` | 是否开启虚拟滚动。开启后仅渲染可视区内的选项,DOM 节点数与数据总量无关,适合成百上千条数据 | `false` |
|
|
54
55
|
| itemHeight | `number` | 每个选项的固定高度(px),开启 `virtual` 时必须为定值 | `32` |
|
|
55
56
|
| listHeight | `number` | 下拉框最大高度(px) | `200` |
|
|
57
|
+
| columns | `number` | 下拉面板列数,大于 1 时启用网格布局 | `1` |
|
|
58
|
+
| popupWidth | `number` | 下拉面板固定宽度(px),设置后弹出层不再跟随触发元素宽度。配合多列布局使用 | - |
|
|
59
|
+
| looseMatch | `boolean` | 是否启用宽松值匹配。开启后 value 与 options 的 value 通过 `String()` 转换后比较,解决 number/string 类型不一致的问题。注意:开启后 `onChange` 回调中的 value 类型为 option 定义的类型,而非传入的原始类型 | `false` |
|
|
56
60
|
| data-* | `string` | - | 支持所有 data-* 属性,透传到组件根 DOM 元素 |
|
|
57
61
|
|
|
58
62
|
### Option
|
package/src/Select/interface.ts
CHANGED
|
@@ -137,11 +137,27 @@ export interface SelectProps
|
|
|
137
137
|
* @default 200
|
|
138
138
|
*/
|
|
139
139
|
listHeight?: number;
|
|
140
|
+
/**
|
|
141
|
+
* Number of columns in the dropdown panel.
|
|
142
|
+
* When greater than 1, options are displayed in a grid layout.
|
|
143
|
+
* @default 1
|
|
144
|
+
*/
|
|
145
|
+
columns?: number;
|
|
146
|
+
/**
|
|
147
|
+
* Fixed width of the dropdown popup in pixels.
|
|
148
|
+
* When set, the popup will not stretch to match the trigger width.
|
|
149
|
+
* Useful with multi-column layout to provide enough horizontal space.
|
|
150
|
+
*/
|
|
151
|
+
popupWidth?: number;
|
|
152
|
+
/**
|
|
153
|
+
* Enable loose value matching. When enabled, value comparison uses
|
|
154
|
+
* `String(a) === String(b)` instead of strict equality (`===`).
|
|
155
|
+
* Resolves type mismatch when options use string values but the
|
|
156
|
+
* controlled value is a number (or vice versa).
|
|
157
|
+
* @default false
|
|
158
|
+
*/
|
|
159
|
+
looseMatch?: boolean;
|
|
140
160
|
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Props for the internal Option component (renders a single dropdown option).
|
|
144
|
-
*/
|
|
145
161
|
export interface OptionProps {
|
|
146
162
|
/**
|
|
147
163
|
* Display label
|
|
@@ -178,6 +194,7 @@ export interface OptionsContextValue {
|
|
|
178
194
|
selectedKeys: Array<string | number>;
|
|
179
195
|
focusedKey: string | number;
|
|
180
196
|
multiple: boolean;
|
|
197
|
+
looseMatch: boolean;
|
|
181
198
|
onSelect: (
|
|
182
199
|
key: string | number,
|
|
183
200
|
e?: React.MouseEvent | React.KeyboardEvent,
|
|
@@ -243,6 +260,15 @@ export interface OptionsProps extends Omit<
|
|
|
243
260
|
virtualApiRef?: React.MutableRefObject<{
|
|
244
261
|
scrollToIndex: (index: number) => void;
|
|
245
262
|
} | null>;
|
|
263
|
+
/**
|
|
264
|
+
* Number of columns in the dropdown grid layout.
|
|
265
|
+
* @default 1
|
|
266
|
+
*/
|
|
267
|
+
columns?: number;
|
|
268
|
+
/**
|
|
269
|
+
* Whether loose value matching is enabled
|
|
270
|
+
*/
|
|
271
|
+
looseMatch?: boolean;
|
|
246
272
|
/**
|
|
247
273
|
* Callback when an option is selected
|
|
248
274
|
*/
|
|
@@ -251,3 +277,4 @@ export interface OptionsProps extends Omit<
|
|
|
251
277
|
e?: React.MouseEvent | React.KeyboardEvent,
|
|
252
278
|
) => void;
|
|
253
279
|
}
|
|
280
|
+
|
package/src/Select/option.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { OptionsCtx } from './options';
|
|
|
7
7
|
|
|
8
8
|
const Option = (props: OptionProps) => {
|
|
9
9
|
const { label, value, disabled, title, dataTestId, style } = props;
|
|
10
|
-
const { prefixCls, selectedKeys, focusedKey, multiple, onSelect } =
|
|
10
|
+
const { prefixCls, selectedKeys, focusedKey, multiple, looseMatch, onSelect } =
|
|
11
11
|
useContext(OptionsCtx);
|
|
12
12
|
|
|
13
13
|
const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => {
|
|
@@ -22,13 +22,19 @@ const Option = (props: OptionProps) => {
|
|
|
22
22
|
? label
|
|
23
23
|
: undefined;
|
|
24
24
|
|
|
25
|
-
const selected =
|
|
25
|
+
const selected = looseMatch
|
|
26
|
+
? selectedKeys.some((k) => String(k) === String(value))
|
|
27
|
+
: selectedKeys.includes(value);
|
|
28
|
+
|
|
29
|
+
const focused = looseMatch
|
|
30
|
+
? String(focusedKey) === String(value)
|
|
31
|
+
: focusedKey === value;
|
|
26
32
|
|
|
27
33
|
return (
|
|
28
34
|
<div
|
|
29
35
|
className={clsx(`${prefixCls}-option`, {
|
|
30
36
|
[`${prefixCls}-option-selected`]: selected,
|
|
31
|
-
[`${prefixCls}-option-focused`]:
|
|
37
|
+
[`${prefixCls}-option-focused`]: focused,
|
|
32
38
|
[`${prefixCls}-option-disabled`]: disabled,
|
|
33
39
|
})}
|
|
34
40
|
aria-label="option"
|
package/src/Select/options.tsx
CHANGED
|
@@ -33,18 +33,23 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
33
33
|
itemHeight = 32,
|
|
34
34
|
listHeight = 200,
|
|
35
35
|
virtualApiRef,
|
|
36
|
+
columns = 1,
|
|
37
|
+
looseMatch = false,
|
|
36
38
|
onSelect,
|
|
37
39
|
...rest
|
|
38
40
|
} = props;
|
|
39
41
|
|
|
42
|
+
const multiColumn = columns > 1;
|
|
43
|
+
|
|
40
44
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
41
|
-
// Forward the popup root element to the parent (Select uses it as the
|
|
42
|
-
// keyboard-nav containerRef) while keeping a stable RefObject for the
|
|
43
|
-
// effects below.
|
|
44
45
|
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []);
|
|
45
46
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
47
|
+
// In multi-column virtual mode, each "row" contains `columns` options.
|
|
48
|
+
// The virtual list tracks rows, not individual options.
|
|
49
|
+
const rowCount = multiColumn
|
|
50
|
+
? Math.ceil(options.length / columns)
|
|
51
|
+
: options.length;
|
|
52
|
+
|
|
48
53
|
const {
|
|
49
54
|
start,
|
|
50
55
|
end,
|
|
@@ -54,37 +59,38 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
54
59
|
onScroll,
|
|
55
60
|
scrollToIndex,
|
|
56
61
|
} = useVirtualList({
|
|
57
|
-
itemCount:
|
|
62
|
+
itemCount: virtual ? rowCount : 0,
|
|
58
63
|
itemHeight,
|
|
59
64
|
});
|
|
60
65
|
|
|
61
|
-
// Expose the virtual scroll API to the parent
|
|
62
|
-
//
|
|
66
|
+
// Expose the virtual scroll API to the parent.
|
|
67
|
+
// In multi-column mode, the parent passes a flat option index, so we
|
|
68
|
+
// convert it to a row index before scrolling.
|
|
63
69
|
useEffect(() => {
|
|
64
70
|
if (!virtual || !virtualApiRef) return;
|
|
65
|
-
virtualApiRef.current = {
|
|
71
|
+
virtualApiRef.current = {
|
|
72
|
+
scrollToIndex: (flatIndex: number) => {
|
|
73
|
+
const rowIndex = multiColumn
|
|
74
|
+
? Math.floor(flatIndex / columns)
|
|
75
|
+
: flatIndex;
|
|
76
|
+
scrollToIndex(rowIndex);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
66
79
|
return () => {
|
|
67
80
|
if (virtualApiRef.current) {
|
|
68
81
|
virtualApiRef.current = null;
|
|
69
82
|
}
|
|
70
83
|
};
|
|
71
|
-
}, [virtual, virtualApiRef, scrollToIndex]);
|
|
84
|
+
}, [virtual, virtualApiRef, scrollToIndex, multiColumn, columns]);
|
|
72
85
|
|
|
73
|
-
// Reset
|
|
74
|
-
// filtering via search), otherwise the viewport may sit at an offset
|
|
75
|
-
// where none of the new results are visible.
|
|
86
|
+
// Reset scroll position when options change.
|
|
76
87
|
useEffect(() => {
|
|
77
88
|
if (virtual) {
|
|
78
89
|
scrollToIndex(0);
|
|
79
90
|
}
|
|
80
91
|
}, [options, virtual, scrollToIndex]);
|
|
81
92
|
|
|
82
|
-
// Bring the focused option into view
|
|
83
|
-
// - virtual mode: drive the virtual viewport via scrollToIndex.
|
|
84
|
-
// - default mode: scrollIntoView on the option's DOM node.
|
|
85
|
-
// Options owns this (rather than the keyboard hook) so it also covers the
|
|
86
|
-
// initial open, where the keyboard effect can fire before the popup is
|
|
87
|
-
// mounted / the virtual API ref is wired up.
|
|
93
|
+
// Bring the focused option into view.
|
|
88
94
|
useEffect(() => {
|
|
89
95
|
if (options.length === 0) return;
|
|
90
96
|
if (focusedKey === '') return;
|
|
@@ -92,7 +98,10 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
92
98
|
if (focusedIndex < 0) return;
|
|
93
99
|
|
|
94
100
|
if (virtual) {
|
|
95
|
-
|
|
101
|
+
const rowIndex = multiColumn
|
|
102
|
+
? Math.floor(focusedIndex / columns)
|
|
103
|
+
: focusedIndex;
|
|
104
|
+
scrollToIndex(rowIndex);
|
|
96
105
|
return;
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -103,9 +112,18 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
103
112
|
| HTMLElement
|
|
104
113
|
| undefined;
|
|
105
114
|
if (targetElement) {
|
|
106
|
-
|
|
115
|
+
// Use manual scroll calculation instead of scrollIntoView to avoid
|
|
116
|
+
// scrolling the entire page (the popup is rendered in a portal).
|
|
117
|
+
const containerRect = container.getBoundingClientRect();
|
|
118
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
119
|
+
if (targetRect.bottom > containerRect.bottom) {
|
|
120
|
+
container.scrollTop +=
|
|
121
|
+
targetRect.bottom - containerRect.bottom;
|
|
122
|
+
} else if (targetRect.top < containerRect.top) {
|
|
123
|
+
container.scrollTop -= containerRect.top - targetRect.top;
|
|
124
|
+
}
|
|
107
125
|
}
|
|
108
|
-
}, [focusedKey, virtual, options, scrollToIndex, rootRef]);
|
|
126
|
+
}, [focusedKey, virtual, options, scrollToIndex, rootRef, multiColumn, columns]);
|
|
109
127
|
|
|
110
128
|
const isEmpty = options.length === 0;
|
|
111
129
|
const ctxValue = {
|
|
@@ -113,10 +131,76 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
113
131
|
selectedKeys,
|
|
114
132
|
focusedKey,
|
|
115
133
|
multiple,
|
|
134
|
+
looseMatch,
|
|
116
135
|
onSelect,
|
|
117
136
|
};
|
|
118
137
|
|
|
119
|
-
// ----- Virtual
|
|
138
|
+
// ----- Virtual + multi-column: render rows of N options -----
|
|
139
|
+
if (virtual && multiColumn && !isEmpty) {
|
|
140
|
+
const visibleRows = [];
|
|
141
|
+
for (let rowIdx = start; rowIdx < end; rowIdx++) {
|
|
142
|
+
const rowOptions = [];
|
|
143
|
+
for (let col = 0; col < columns; col++) {
|
|
144
|
+
const flatIdx = rowIdx * columns + col;
|
|
145
|
+
if (flatIdx < options.length) {
|
|
146
|
+
rowOptions.push({ option: options[flatIdx], flatIdx });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
visibleRows.push({ rowIdx, rowOptions });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
{...rest}
|
|
155
|
+
className={clsx(
|
|
156
|
+
`${prefixCls}-popup`,
|
|
157
|
+
`${prefixCls}-popup-virtual`,
|
|
158
|
+
popupClassName,
|
|
159
|
+
)}
|
|
160
|
+
style={popupStyle}
|
|
161
|
+
ref={rootRef}
|
|
162
|
+
tabIndex={-1}
|
|
163
|
+
>
|
|
164
|
+
<OptionsCtx.Provider value={ctxValue}>
|
|
165
|
+
<div
|
|
166
|
+
className={`${prefixCls}-popup-viewport`}
|
|
167
|
+
ref={viewportRef}
|
|
168
|
+
onScroll={onScroll}
|
|
169
|
+
style={{ maxHeight: listHeight, overflowY: 'auto' }}
|
|
170
|
+
>
|
|
171
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
172
|
+
{visibleRows.map(({ rowIdx, rowOptions }, ri) => (
|
|
173
|
+
<div
|
|
174
|
+
key={rowIdx}
|
|
175
|
+
className={`${prefixCls}-popup-grid-row`}
|
|
176
|
+
style={{
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
top: 0,
|
|
179
|
+
left: 0,
|
|
180
|
+
right: 0,
|
|
181
|
+
height: itemHeight,
|
|
182
|
+
transform: `translateY(${offsetY + ri * itemHeight}px)`,
|
|
183
|
+
display: 'grid',
|
|
184
|
+
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{rowOptions.map(({ option, flatIdx }) => (
|
|
188
|
+
<Option
|
|
189
|
+
{...option}
|
|
190
|
+
key={option.value}
|
|
191
|
+
dataTestId={`${dataTestId}-${flatIdx}`}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</OptionsCtx.Provider>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ----- Virtual single-column: render visible window -----
|
|
120
204
|
if (virtual && !isEmpty) {
|
|
121
205
|
const visibleOptions = options.slice(start, end);
|
|
122
206
|
return (
|
|
@@ -161,12 +245,21 @@ const Options = forwardRef<HTMLDivElement, OptionsProps>(
|
|
|
161
245
|
);
|
|
162
246
|
}
|
|
163
247
|
|
|
164
|
-
// ----- Default mode
|
|
248
|
+
// ----- Default mode (non-virtual) -----
|
|
165
249
|
return (
|
|
166
250
|
<div
|
|
167
251
|
{...rest}
|
|
168
|
-
className={clsx(
|
|
169
|
-
|
|
252
|
+
className={clsx(
|
|
253
|
+
`${prefixCls}-popup`,
|
|
254
|
+
{ [`${prefixCls}-popup-grid`]: multiColumn },
|
|
255
|
+
popupClassName,
|
|
256
|
+
)}
|
|
257
|
+
style={{
|
|
258
|
+
...popupStyle,
|
|
259
|
+
...(multiColumn
|
|
260
|
+
? { '--yee-select-columns': columns } as React.CSSProperties
|
|
261
|
+
: undefined),
|
|
262
|
+
}}
|
|
170
263
|
ref={rootRef}
|
|
171
264
|
tabIndex={-1}
|
|
172
265
|
>
|
package/src/Select/select.tsx
CHANGED
|
@@ -15,6 +15,30 @@ import { Option, SelectProps } from './interface';
|
|
|
15
15
|
import Options from './options';
|
|
16
16
|
import './style/index.less';
|
|
17
17
|
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Value comparison utilities (module-level for stable references)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Compare two values: loose mode uses String() conversion, strict uses ===. */
|
|
23
|
+
function matchValue(
|
|
24
|
+
a: string | number,
|
|
25
|
+
b: string | number,
|
|
26
|
+
loose: boolean,
|
|
27
|
+
): boolean {
|
|
28
|
+
if (loose) return String(a) === String(b);
|
|
29
|
+
return a === b;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check if a value exists in a keys array with optional loose matching. */
|
|
33
|
+
function keysIncludes(
|
|
34
|
+
keys: Array<string | number>,
|
|
35
|
+
val: string | number,
|
|
36
|
+
loose: boolean,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (loose) return keys.some((k) => String(k) === String(val));
|
|
39
|
+
return keys.includes(val);
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
const Select = (baseprops: SelectProps) => {
|
|
19
43
|
const { select } = useContext(GlobalContext);
|
|
20
44
|
const props = mergeContextToProps(baseprops, select);
|
|
@@ -32,6 +56,9 @@ const Select = (baseprops: SelectProps) => {
|
|
|
32
56
|
virtual,
|
|
33
57
|
itemHeight,
|
|
34
58
|
listHeight,
|
|
59
|
+
columns = 1,
|
|
60
|
+
popupWidth,
|
|
61
|
+
looseMatch = false,
|
|
35
62
|
orphanClassName,
|
|
36
63
|
orphanStyle,
|
|
37
64
|
...rest
|
|
@@ -80,16 +107,14 @@ const Select = (baseprops: SelectProps) => {
|
|
|
80
107
|
if (keys.length === 0) {
|
|
81
108
|
return [];
|
|
82
109
|
}
|
|
83
|
-
// 按 keys 顺序回填,找不到的 key 构造 label=value 的兜底项(孤儿值),
|
|
84
|
-
// 与 Selector 的显示保持一致,避免 onChange 第二参数丢值。
|
|
85
110
|
return keys.map((key) => {
|
|
86
|
-
const opt = options.find((o) => o.value
|
|
111
|
+
const opt = options.find((o) => matchValue(o.value, key, looseMatch));
|
|
87
112
|
return opt ?? { value: key, label: String(key) };
|
|
88
113
|
});
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
return (
|
|
92
|
-
options.find((opt) => opt.value
|
|
117
|
+
options.find((opt) => matchValue(opt.value, keys, looseMatch)) ?? {
|
|
93
118
|
value: keys,
|
|
94
119
|
label: String(keys),
|
|
95
120
|
}
|
|
@@ -107,8 +132,8 @@ const Select = (baseprops: SelectProps) => {
|
|
|
107
132
|
setSearchValue('');
|
|
108
133
|
setOpen(false);
|
|
109
134
|
} else {
|
|
110
|
-
if (mergedValue
|
|
111
|
-
keys = mergedValue.filter((k: string | number) => k
|
|
135
|
+
if (keysIncludes(mergedValue, key, looseMatch)) {
|
|
136
|
+
keys = mergedValue.filter((k: string | number) => !matchValue(k, key, looseMatch));
|
|
112
137
|
} else {
|
|
113
138
|
keys = [...mergedValue, key];
|
|
114
139
|
}
|
|
@@ -128,7 +153,9 @@ const Select = (baseprops: SelectProps) => {
|
|
|
128
153
|
};
|
|
129
154
|
|
|
130
155
|
const handleRemove = (option: Option) => {
|
|
131
|
-
const keys = mergedValue.filter(
|
|
156
|
+
const keys = mergedValue.filter(
|
|
157
|
+
(k: string | number) => !matchValue(k, option.value, looseMatch),
|
|
158
|
+
);
|
|
132
159
|
const filteredOpts = getOptions(keys);
|
|
133
160
|
setMergedValue(keys);
|
|
134
161
|
const key = single ? keys[0] : keys;
|
|
@@ -162,6 +189,8 @@ const Select = (baseprops: SelectProps) => {
|
|
|
162
189
|
options: filteredOpts,
|
|
163
190
|
open,
|
|
164
191
|
selectedKeys: mergedValue,
|
|
192
|
+
columns,
|
|
193
|
+
looseMatch,
|
|
165
194
|
onSelect: handleSelect,
|
|
166
195
|
onClose: () => setOpen(false),
|
|
167
196
|
onOpenChange: handleOpenChange,
|
|
@@ -183,6 +212,8 @@ const Select = (baseprops: SelectProps) => {
|
|
|
183
212
|
itemHeight={itemHeight}
|
|
184
213
|
listHeight={listHeight}
|
|
185
214
|
virtualApiRef={virtualApiRef}
|
|
215
|
+
columns={columns}
|
|
216
|
+
looseMatch={looseMatch}
|
|
186
217
|
/>
|
|
187
218
|
);
|
|
188
219
|
|
|
@@ -191,7 +222,8 @@ const Select = (baseprops: SelectProps) => {
|
|
|
191
222
|
{...rest}
|
|
192
223
|
placement={placement}
|
|
193
224
|
popup={popup}
|
|
194
|
-
stretch=
|
|
225
|
+
stretch={popupWidth ? undefined : 'width'}
|
|
226
|
+
popupStyle={popupWidth ? { width: popupWidth } : undefined}
|
|
195
227
|
open={disabled ? false : open}
|
|
196
228
|
onOpenChange={handleOpenChange}
|
|
197
229
|
>
|
|
@@ -209,6 +241,7 @@ const Select = (baseprops: SelectProps) => {
|
|
|
209
241
|
onKeyDown={onKeyDown}
|
|
210
242
|
orphanClassName={orphanClassName}
|
|
211
243
|
orphanStyle={orphanStyle}
|
|
244
|
+
looseMatch={looseMatch}
|
|
212
245
|
/>
|
|
213
246
|
</Trigger>
|
|
214
247
|
);
|