@rainbow-oh/yee-c 0.6.4 → 0.8.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/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/Form/form-store.d.ts +2 -1
- package/dist/cjs/Form/form-store.js +32 -17
- 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/hooks/useColumns.js +1 -1
- package/dist/cjs/Table/interface.d.ts +1 -1
- package/dist/cjs/Table/style/index.less +35 -11
- package/dist/cjs/Table/table.js +57 -7
- package/dist/cjs/Table/tbody/cell.js +1 -1
- package/dist/cjs/Table/util/header.d.ts +3 -1
- package/dist/cjs/Table/util/header.js +30 -3
- 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 +3 -0
- package/dist/cjs/style/size.css +2 -1
- package/dist/cjs/style/themes.css +1 -1
- package/dist/cjs/style/variables.css +6 -2
- package/dist/cjs/utils/isEqual.js +5 -7
- 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/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/Form/form-store.d.ts +2 -1
- package/dist/esm/Form/form-store.js +40 -19
- 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/hooks/useColumns.js +1 -1
- package/dist/esm/Table/interface.d.ts +1 -1
- package/dist/esm/Table/style/index.less +35 -11
- package/dist/esm/Table/table.js +54 -2
- package/dist/esm/Table/tbody/cell.js +1 -1
- package/dist/esm/Table/util/header.d.ts +3 -1
- package/dist/esm/Table/util/header.js +30 -2
- 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 +3 -0
- package/dist/esm/style/size.css +2 -1
- package/dist/esm/style/themes.css +1 -1
- package/dist/esm/style/variables.css +6 -2
- package/dist/esm/utils/isEqual.js +5 -7
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/umd/yee-c.min.css +4 -4
- package/dist/umd/yee-c.min.js +1 -1
- package/package.json +1 -1
- package/src/Button/style/index.less +5 -4
- package/src/Drawer/drawer.tsx +1 -1
- package/src/Drawer/interface.ts +1 -0
- package/src/Drawer/style/index.less +1 -0
- package/src/Form/form-store.ts +42 -15
- 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/hooks/useColumns.ts +1 -1
- package/src/Table/interface.ts +1 -1
- package/src/Table/style/index.less +35 -11
- package/src/Table/table.tsx +60 -1
- package/src/Table/tbody/cell.tsx +1 -1
- package/src/Table/util/header.ts +48 -2
- 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 +3 -0
- package/src/style/size.css +2 -1
- package/src/utils/isEqual.ts +5 -7
- package/src/version.ts +1 -1
package/src/List/list.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import clsx from 'clsx';
|
|
2
2
|
import React, { createContext, forwardRef, useContext } from 'react';
|
|
3
3
|
import { GlobalContext } from '../Config-Provider';
|
|
4
|
+
import useVirtualList from '../hooks/useVirtualList';
|
|
4
5
|
import mergeContextToProps from '../utils/mergeContextToProps';
|
|
6
|
+
import useListKeyboard from './hooks/useListKeyboard';
|
|
5
7
|
import type { ListCtxType, ListProps } from './interface';
|
|
6
8
|
import ListItem from './list-item';
|
|
7
9
|
|
|
@@ -16,43 +18,179 @@ const List = forwardRef<HTMLUListElement, ListProps>((baseprops, ref) => {
|
|
|
16
18
|
const {
|
|
17
19
|
prefixCls = 'yee-list',
|
|
18
20
|
className,
|
|
21
|
+
style,
|
|
19
22
|
items,
|
|
20
23
|
bordered,
|
|
21
|
-
focusedKey,
|
|
24
|
+
focusedKey: controlledFocusedKey,
|
|
22
25
|
itemRender,
|
|
23
26
|
onClick,
|
|
27
|
+
onFocusChange,
|
|
28
|
+
virtual = false,
|
|
29
|
+
height,
|
|
30
|
+
itemHeight = 32,
|
|
31
|
+
columns = 1,
|
|
24
32
|
...rest
|
|
25
33
|
} = props;
|
|
26
34
|
|
|
35
|
+
const multiColumn = columns > 1;
|
|
36
|
+
const useVirtual = virtual && height !== null && height !== undefined && Array.isArray(items);
|
|
37
|
+
const safeItems = Array.isArray(items) ? items : [];
|
|
38
|
+
|
|
39
|
+
// Keyboard navigation
|
|
40
|
+
const { focusedKey, onKeyDown } = useListKeyboard({
|
|
41
|
+
items: safeItems,
|
|
42
|
+
columns,
|
|
43
|
+
focusedKey: controlledFocusedKey,
|
|
44
|
+
onFocusChange,
|
|
45
|
+
onClick,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Virtual scroll — in multi-column mode, track rows not individual items
|
|
49
|
+
const rowCount = multiColumn
|
|
50
|
+
? Math.ceil(safeItems.length / columns)
|
|
51
|
+
: safeItems.length;
|
|
52
|
+
|
|
53
|
+
const { start, end, offsetY, totalHeight, viewportRef, onScroll } =
|
|
54
|
+
useVirtualList({
|
|
55
|
+
itemCount: useVirtual ? rowCount : 0,
|
|
56
|
+
itemHeight,
|
|
57
|
+
overscan: 4,
|
|
58
|
+
});
|
|
59
|
+
|
|
27
60
|
const cls = clsx(
|
|
28
61
|
prefixCls,
|
|
29
62
|
{
|
|
30
63
|
[`${prefixCls}-bordered`]: bordered,
|
|
64
|
+
[`${prefixCls}-grid`]: multiColumn && !useVirtual,
|
|
31
65
|
},
|
|
32
66
|
className,
|
|
33
67
|
);
|
|
34
68
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
return <ListItem {...item} $key={item.key} key={item.key || index} />;
|
|
42
|
-
})
|
|
43
|
-
: null;
|
|
69
|
+
const renderItem = (item: any, index: number) => {
|
|
70
|
+
if (typeof itemRender === 'function') {
|
|
71
|
+
return itemRender(item);
|
|
72
|
+
}
|
|
73
|
+
return <ListItem {...item} $key={item.key} key={item.key || index} />;
|
|
44
74
|
};
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
// ----- Virtual + multi-column: render rows of N items -----
|
|
77
|
+
if (useVirtual && multiColumn) {
|
|
78
|
+
const visibleRows = [];
|
|
79
|
+
for (let rowIdx = start; rowIdx < end; rowIdx++) {
|
|
80
|
+
const rowItems = [];
|
|
81
|
+
for (let col = 0; col < columns; col++) {
|
|
82
|
+
const flatIdx = rowIdx * columns + col;
|
|
83
|
+
if (flatIdx < safeItems.length) {
|
|
84
|
+
rowItems.push({ item: safeItems[flatIdx], flatIdx });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
visibleRows.push({ rowIdx, rowItems });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<ul
|
|
92
|
+
ref={ref}
|
|
93
|
+
className={cls}
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
aria-label="list"
|
|
96
|
+
onKeyDown={onKeyDown}
|
|
97
|
+
{...rest}
|
|
98
|
+
>
|
|
99
|
+
<ListCtx.Provider value={{ prefixCls, focusedKey, onClick }}>
|
|
100
|
+
<div
|
|
101
|
+
ref={viewportRef}
|
|
102
|
+
onScroll={onScroll}
|
|
103
|
+
style={{ height, overflowY: 'auto' }}
|
|
104
|
+
>
|
|
105
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
106
|
+
{visibleRows.map(({ rowIdx, rowItems }, ri) => (
|
|
107
|
+
<div
|
|
108
|
+
key={rowIdx}
|
|
109
|
+
className={`${prefixCls}-grid-row`}
|
|
110
|
+
style={{
|
|
111
|
+
position: 'absolute',
|
|
112
|
+
top: 0,
|
|
113
|
+
left: 0,
|
|
114
|
+
right: 0,
|
|
115
|
+
height: itemHeight,
|
|
116
|
+
transform: `translateY(${offsetY + ri * itemHeight}px)`,
|
|
117
|
+
display: 'grid',
|
|
118
|
+
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{rowItems.map(({ item, flatIdx }) =>
|
|
122
|
+
renderItem(item, flatIdx),
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</ListCtx.Provider>
|
|
129
|
+
</ul>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ----- Virtual single-column -----
|
|
134
|
+
if (useVirtual) {
|
|
135
|
+
const slicedItems = safeItems.slice(start, end);
|
|
136
|
+
return (
|
|
137
|
+
<ul
|
|
138
|
+
ref={ref}
|
|
139
|
+
className={cls}
|
|
140
|
+
tabIndex={0}
|
|
141
|
+
aria-label="list"
|
|
142
|
+
onKeyDown={onKeyDown}
|
|
143
|
+
{...rest}
|
|
54
144
|
>
|
|
55
|
-
{
|
|
145
|
+
<ListCtx.Provider value={{ prefixCls, focusedKey, onClick }}>
|
|
146
|
+
<div
|
|
147
|
+
ref={viewportRef}
|
|
148
|
+
onScroll={onScroll}
|
|
149
|
+
style={{ height, overflowY: 'auto' }}
|
|
150
|
+
>
|
|
151
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
152
|
+
{slicedItems.map((item, i) => {
|
|
153
|
+
const element = renderItem(item, start + i);
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
key={item.key || start + i}
|
|
157
|
+
style={{
|
|
158
|
+
position: 'absolute',
|
|
159
|
+
top: 0,
|
|
160
|
+
left: 0,
|
|
161
|
+
width: '100%',
|
|
162
|
+
height: itemHeight,
|
|
163
|
+
transform: `translateY(${offsetY + i * itemHeight}px)`,
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
{element}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</ListCtx.Provider>
|
|
173
|
+
</ul>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ----- Default mode (non-virtual) -----
|
|
178
|
+
return (
|
|
179
|
+
<ul
|
|
180
|
+
ref={ref}
|
|
181
|
+
className={cls}
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
aria-label="list"
|
|
184
|
+
onKeyDown={onKeyDown}
|
|
185
|
+
style={
|
|
186
|
+
multiColumn
|
|
187
|
+
? ({ '--yee-list-columns': columns, ...style } as React.CSSProperties)
|
|
188
|
+
: style
|
|
189
|
+
}
|
|
190
|
+
{...rest}
|
|
191
|
+
>
|
|
192
|
+
<ListCtx.Provider value={{ prefixCls, focusedKey, onClick }}>
|
|
193
|
+
{safeItems.map((item, index) => renderItem(item, index))}
|
|
56
194
|
</ListCtx.Provider>
|
|
57
195
|
</ul>
|
|
58
196
|
);
|
|
@@ -6,18 +6,42 @@
|
|
|
6
6
|
list-style: none;
|
|
7
7
|
background-color: var(--yee-list-bg-color);
|
|
8
8
|
|
|
9
|
+
&:focus {
|
|
10
|
+
outline: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
&-item {
|
|
10
14
|
padding: var(--yee-list-item-padding);
|
|
11
15
|
|
|
12
16
|
&:hover:not(&-disabled) {
|
|
13
17
|
background-color: var(--yee-list-item-bg-hover-color);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&-disabled {
|
|
14
21
|
cursor: not-allowed;
|
|
22
|
+
opacity: 0.5;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&-clickable:not(&-disabled) {
|
|
26
|
+
cursor: pointer;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
&-focused {
|
|
18
30
|
background-color: var(--yee-list-item-bg-hover-color);
|
|
19
31
|
}
|
|
20
32
|
}
|
|
33
|
+
|
|
34
|
+
// Multi-column grid layout (non-virtual mode).
|
|
35
|
+
// Column count driven by CSS custom property --yee-list-columns set inline.
|
|
36
|
+
&-grid {
|
|
37
|
+
display: grid;
|
|
38
|
+
grid-template-columns: repeat(var(--yee-list-columns, 1), 1fr);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Virtual multi-column: each row is already a grid via inline style.
|
|
42
|
+
&-grid-row {
|
|
43
|
+
align-items: center;
|
|
44
|
+
}
|
|
21
45
|
}
|
|
22
46
|
|
|
23
47
|
@{pls}-bordered {
|
|
@@ -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"
|