@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.
Files changed (145) hide show
  1. package/dist/cjs/Button/style/index.less +5 -4
  2. package/dist/cjs/Drawer/drawer.js +1 -1
  3. package/dist/cjs/Drawer/interface.d.ts +1 -1
  4. package/dist/cjs/Drawer/style/index.less +1 -0
  5. package/dist/cjs/Form/form-store.d.ts +2 -1
  6. package/dist/cjs/Form/form-store.js +32 -17
  7. package/dist/cjs/List/demo/virtual.d.ts +3 -0
  8. package/dist/cjs/List/demo/virtual.js +53 -0
  9. package/dist/cjs/List/hooks/useListKeyboard.d.ts +28 -0
  10. package/dist/cjs/List/hooks/useListKeyboard.js +188 -0
  11. package/dist/cjs/List/interface.d.ts +29 -2
  12. package/dist/cjs/List/list.js +141 -21
  13. package/dist/cjs/List/style/index.less +24 -0
  14. package/dist/cjs/Select/demo/columns.d.ts +3 -0
  15. package/dist/cjs/Select/demo/columns.js +62 -0
  16. package/dist/cjs/Select/hooks/useSelectKeyboard.d.ts +13 -1
  17. package/dist/cjs/Select/hooks/useSelectKeyboard.js +81 -10
  18. package/dist/cjs/Select/interface.d.ts +30 -3
  19. package/dist/cjs/Select/option.js +4 -3
  20. package/dist/cjs/Select/options.js +90 -8
  21. package/dist/cjs/Select/select.js +32 -8
  22. package/dist/cjs/Select/style/index.less +14 -0
  23. package/dist/cjs/Selector/interface.d.ts +4 -0
  24. package/dist/cjs/Selector/selector.js +5 -3
  25. package/dist/cjs/Table/hooks/useColumns.js +1 -1
  26. package/dist/cjs/Table/interface.d.ts +1 -1
  27. package/dist/cjs/Table/style/index.less +35 -11
  28. package/dist/cjs/Table/table.js +57 -7
  29. package/dist/cjs/Table/tbody/cell.js +1 -1
  30. package/dist/cjs/Table/util/header.d.ts +3 -1
  31. package/dist/cjs/Table/util/header.js +30 -3
  32. package/dist/cjs/Tree/demo/draggable.js +2 -0
  33. package/dist/cjs/Tree/interface.d.ts +46 -23
  34. package/dist/cjs/Tree/tree-node.d.ts +1 -1
  35. package/dist/cjs/Tree/tree-node.js +30 -23
  36. package/dist/cjs/Tree/tree.d.ts +13 -12
  37. package/dist/cjs/Tree/tree.js +452 -227
  38. package/dist/cjs/Tree/utils/tree-tools.d.ts +43 -10
  39. package/dist/cjs/Tree/utils/tree-tools.js +129 -91
  40. package/dist/cjs/locale/context.d.ts +5 -4
  41. package/dist/cjs/locale/context.js +15 -8
  42. package/dist/cjs/locale/interface.d.ts +20 -0
  43. package/dist/cjs/locale/util.d.ts +3 -3
  44. package/dist/cjs/style/color.css +3 -0
  45. package/dist/cjs/style/size.css +2 -1
  46. package/dist/cjs/style/themes.css +1 -1
  47. package/dist/cjs/style/variables.css +6 -2
  48. package/dist/cjs/utils/isEqual.js +5 -7
  49. package/dist/cjs/version.d.ts +1 -1
  50. package/dist/cjs/version.js +1 -1
  51. package/dist/esm/Button/style/index.less +5 -4
  52. package/dist/esm/Drawer/drawer.js +2 -1
  53. package/dist/esm/Drawer/interface.d.ts +1 -1
  54. package/dist/esm/Drawer/style/index.less +1 -0
  55. package/dist/esm/Form/form-store.d.ts +2 -1
  56. package/dist/esm/Form/form-store.js +40 -19
  57. package/dist/esm/List/demo/virtual.d.ts +3 -0
  58. package/dist/esm/List/demo/virtual.js +22 -0
  59. package/dist/esm/List/hooks/useListKeyboard.d.ts +28 -0
  60. package/dist/esm/List/hooks/useListKeyboard.js +173 -0
  61. package/dist/esm/List/interface.d.ts +29 -2
  62. package/dist/esm/List/list.js +171 -14
  63. package/dist/esm/List/style/index.less +24 -0
  64. package/dist/esm/Select/demo/columns.d.ts +3 -0
  65. package/dist/esm/Select/demo/columns.js +36 -0
  66. package/dist/esm/Select/hooks/useSelectKeyboard.d.ts +13 -1
  67. package/dist/esm/Select/hooks/useSelectKeyboard.js +91 -14
  68. package/dist/esm/Select/interface.d.ts +30 -3
  69. package/dist/esm/Select/option.js +6 -2
  70. package/dist/esm/Select/options.js +114 -32
  71. package/dist/esm/Select/select.js +40 -11
  72. package/dist/esm/Select/style/index.less +14 -0
  73. package/dist/esm/Selector/interface.d.ts +4 -0
  74. package/dist/esm/Selector/selector.js +10 -4
  75. package/dist/esm/Table/hooks/useColumns.js +1 -1
  76. package/dist/esm/Table/interface.d.ts +1 -1
  77. package/dist/esm/Table/style/index.less +35 -11
  78. package/dist/esm/Table/table.js +54 -2
  79. package/dist/esm/Table/tbody/cell.js +1 -1
  80. package/dist/esm/Table/util/header.d.ts +3 -1
  81. package/dist/esm/Table/util/header.js +30 -2
  82. package/dist/esm/Tree/demo/draggable.js +2 -0
  83. package/dist/esm/Tree/interface.d.ts +46 -23
  84. package/dist/esm/Tree/tree-node.d.ts +1 -1
  85. package/dist/esm/Tree/tree-node.js +26 -31
  86. package/dist/esm/Tree/tree.d.ts +13 -12
  87. package/dist/esm/Tree/tree.js +572 -163
  88. package/dist/esm/Tree/utils/tree-tools.d.ts +43 -10
  89. package/dist/esm/Tree/utils/tree-tools.js +219 -104
  90. package/dist/esm/locale/context.d.ts +5 -4
  91. package/dist/esm/locale/context.js +25 -9
  92. package/dist/esm/locale/interface.d.ts +20 -0
  93. package/dist/esm/locale/util.d.ts +3 -3
  94. package/dist/esm/style/color.css +3 -0
  95. package/dist/esm/style/size.css +2 -1
  96. package/dist/esm/style/themes.css +1 -1
  97. package/dist/esm/style/variables.css +6 -2
  98. package/dist/esm/utils/isEqual.js +5 -7
  99. package/dist/esm/version.d.ts +1 -1
  100. package/dist/esm/version.js +1 -1
  101. package/dist/umd/yee-c.min.css +4 -4
  102. package/dist/umd/yee-c.min.js +1 -1
  103. package/package.json +1 -1
  104. package/src/Button/style/index.less +5 -4
  105. package/src/Drawer/drawer.tsx +1 -1
  106. package/src/Drawer/interface.ts +1 -0
  107. package/src/Drawer/style/index.less +1 -0
  108. package/src/Form/form-store.ts +42 -15
  109. package/src/List/demo/virtual.tsx +20 -0
  110. package/src/List/hooks/useListKeyboard.ts +235 -0
  111. package/src/List/index.en-US.md +6 -0
  112. package/src/List/index.zh-CN.md +6 -0
  113. package/src/List/interface.ts +29 -2
  114. package/src/List/list.tsx +157 -19
  115. package/src/List/style/index.less +24 -0
  116. package/src/Select/demo/columns.tsx +35 -0
  117. package/src/Select/hooks/useSelectKeyboard.ts +112 -15
  118. package/src/Select/index.en-US.md +4 -0
  119. package/src/Select/index.zh-CN.md +4 -0
  120. package/src/Select/interface.ts +31 -4
  121. package/src/Select/option.tsx +9 -3
  122. package/src/Select/options.tsx +119 -26
  123. package/src/Select/select.tsx +41 -8
  124. package/src/Select/style/index.less +14 -0
  125. package/src/Selector/interface.ts +4 -0
  126. package/src/Selector/selector.tsx +8 -3
  127. package/src/Table/hooks/useColumns.ts +1 -1
  128. package/src/Table/interface.ts +1 -1
  129. package/src/Table/style/index.less +35 -11
  130. package/src/Table/table.tsx +60 -1
  131. package/src/Table/tbody/cell.tsx +1 -1
  132. package/src/Table/util/header.ts +48 -2
  133. package/src/Tree/demo/draggable.tsx +2 -0
  134. package/src/Tree/interface.ts +46 -23
  135. package/src/Tree/tree-node.tsx +26 -29
  136. package/src/Tree/tree.tsx +573 -274
  137. package/src/Tree/utils/tree-tools.ts +231 -125
  138. package/src/locale/_README.md +22 -13
  139. package/src/locale/context.tsx +33 -16
  140. package/src/locale/interface.ts +24 -0
  141. package/src/locale/util.ts +6 -6
  142. package/src/style/color.css +3 -0
  143. package/src/style/size.css +2 -1
  144. package/src/utils/isEqual.ts +5 -7
  145. 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 itemRenders = () => {
36
- return Array.isArray(items)
37
- ? items.map((item, index) => {
38
- if (typeof itemRender === 'function') {
39
- return itemRender(item);
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
- return (
47
- <ul ref={ref} className={cls} tabIndex={0} aria-label="list" {...rest}>
48
- <ListCtx.Provider
49
- value={{
50
- prefixCls,
51
- focusedKey,
52
- onClick: onClick,
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
- {itemRenders()}
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
- targetElement.scrollIntoView({
83
- block: 'nearest',
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 (focusedIndex === -1) {
94
- // No focus yet, focus the last enabled option
95
- nextIndex = findNextEnabledIndex(options.length - 1, -1);
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
- // Find the previous enabled option
98
- nextIndex = findNextEnabledIndex(focusedIndex, -1);
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 (focusedIndex === -1) {
114
- // No focus yet, focus the first enabled option
115
- nextIndex = findNextEnabledIndex(0, 1);
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
- // Find the next enabled option
118
- nextIndex = findNextEnabledIndex(focusedIndex, 1);
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
- (opt) => opt.value === selectedKeys[0],
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
@@ -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
+
@@ -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 = selectedKeys.includes(value);
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`]: focusedKey === value,
37
+ [`${prefixCls}-option-focused`]: focused,
32
38
  [`${prefixCls}-option-disabled`]: disabled,
33
39
  })}
34
40
  aria-label="option"