@rainbow-oh/yee-c 0.6.4 → 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.
Files changed (123) 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/List/demo/virtual.d.ts +3 -0
  6. package/dist/cjs/List/demo/virtual.js +53 -0
  7. package/dist/cjs/List/hooks/useListKeyboard.d.ts +28 -0
  8. package/dist/cjs/List/hooks/useListKeyboard.js +188 -0
  9. package/dist/cjs/List/interface.d.ts +29 -2
  10. package/dist/cjs/List/list.js +141 -21
  11. package/dist/cjs/List/style/index.less +24 -0
  12. package/dist/cjs/Select/demo/columns.d.ts +3 -0
  13. package/dist/cjs/Select/demo/columns.js +62 -0
  14. package/dist/cjs/Select/hooks/useSelectKeyboard.d.ts +13 -1
  15. package/dist/cjs/Select/hooks/useSelectKeyboard.js +81 -10
  16. package/dist/cjs/Select/interface.d.ts +30 -3
  17. package/dist/cjs/Select/option.js +4 -3
  18. package/dist/cjs/Select/options.js +90 -8
  19. package/dist/cjs/Select/select.js +32 -8
  20. package/dist/cjs/Select/style/index.less +14 -0
  21. package/dist/cjs/Selector/interface.d.ts +4 -0
  22. package/dist/cjs/Selector/selector.js +5 -3
  23. package/dist/cjs/Table/interface.d.ts +1 -1
  24. package/dist/cjs/Tree/demo/draggable.js +2 -0
  25. package/dist/cjs/Tree/interface.d.ts +46 -23
  26. package/dist/cjs/Tree/tree-node.d.ts +1 -1
  27. package/dist/cjs/Tree/tree-node.js +30 -23
  28. package/dist/cjs/Tree/tree.d.ts +13 -12
  29. package/dist/cjs/Tree/tree.js +452 -227
  30. package/dist/cjs/Tree/utils/tree-tools.d.ts +43 -10
  31. package/dist/cjs/Tree/utils/tree-tools.js +129 -91
  32. package/dist/cjs/locale/context.d.ts +5 -4
  33. package/dist/cjs/locale/context.js +15 -8
  34. package/dist/cjs/locale/interface.d.ts +20 -0
  35. package/dist/cjs/locale/util.d.ts +3 -3
  36. package/dist/cjs/style/color.css +1 -0
  37. package/dist/cjs/style/size.css +2 -1
  38. package/dist/cjs/style/themes.css +1 -1
  39. package/dist/cjs/style/variables.css +4 -2
  40. package/dist/cjs/utils/isEqual.js +5 -7
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/Button/style/index.less +5 -4
  44. package/dist/esm/Drawer/drawer.js +2 -1
  45. package/dist/esm/Drawer/interface.d.ts +1 -1
  46. package/dist/esm/Drawer/style/index.less +1 -0
  47. package/dist/esm/List/demo/virtual.d.ts +3 -0
  48. package/dist/esm/List/demo/virtual.js +22 -0
  49. package/dist/esm/List/hooks/useListKeyboard.d.ts +28 -0
  50. package/dist/esm/List/hooks/useListKeyboard.js +173 -0
  51. package/dist/esm/List/interface.d.ts +29 -2
  52. package/dist/esm/List/list.js +171 -14
  53. package/dist/esm/List/style/index.less +24 -0
  54. package/dist/esm/Select/demo/columns.d.ts +3 -0
  55. package/dist/esm/Select/demo/columns.js +36 -0
  56. package/dist/esm/Select/hooks/useSelectKeyboard.d.ts +13 -1
  57. package/dist/esm/Select/hooks/useSelectKeyboard.js +91 -14
  58. package/dist/esm/Select/interface.d.ts +30 -3
  59. package/dist/esm/Select/option.js +6 -2
  60. package/dist/esm/Select/options.js +114 -32
  61. package/dist/esm/Select/select.js +40 -11
  62. package/dist/esm/Select/style/index.less +14 -0
  63. package/dist/esm/Selector/interface.d.ts +4 -0
  64. package/dist/esm/Selector/selector.js +10 -4
  65. package/dist/esm/Table/interface.d.ts +1 -1
  66. package/dist/esm/Tree/demo/draggable.js +2 -0
  67. package/dist/esm/Tree/interface.d.ts +46 -23
  68. package/dist/esm/Tree/tree-node.d.ts +1 -1
  69. package/dist/esm/Tree/tree-node.js +26 -31
  70. package/dist/esm/Tree/tree.d.ts +13 -12
  71. package/dist/esm/Tree/tree.js +572 -163
  72. package/dist/esm/Tree/utils/tree-tools.d.ts +43 -10
  73. package/dist/esm/Tree/utils/tree-tools.js +219 -104
  74. package/dist/esm/locale/context.d.ts +5 -4
  75. package/dist/esm/locale/context.js +25 -9
  76. package/dist/esm/locale/interface.d.ts +20 -0
  77. package/dist/esm/locale/util.d.ts +3 -3
  78. package/dist/esm/style/color.css +1 -0
  79. package/dist/esm/style/size.css +2 -1
  80. package/dist/esm/style/themes.css +1 -1
  81. package/dist/esm/style/variables.css +4 -2
  82. package/dist/esm/utils/isEqual.js +5 -7
  83. package/dist/esm/version.d.ts +1 -1
  84. package/dist/esm/version.js +1 -1
  85. package/dist/umd/yee-c.min.css +2 -2
  86. package/dist/umd/yee-c.min.js +1 -1
  87. package/package.json +1 -1
  88. package/src/Button/style/index.less +5 -4
  89. package/src/Drawer/drawer.tsx +1 -1
  90. package/src/Drawer/interface.ts +1 -0
  91. package/src/Drawer/style/index.less +1 -0
  92. package/src/List/demo/virtual.tsx +20 -0
  93. package/src/List/hooks/useListKeyboard.ts +235 -0
  94. package/src/List/index.en-US.md +6 -0
  95. package/src/List/index.zh-CN.md +6 -0
  96. package/src/List/interface.ts +29 -2
  97. package/src/List/list.tsx +157 -19
  98. package/src/List/style/index.less +24 -0
  99. package/src/Select/demo/columns.tsx +35 -0
  100. package/src/Select/hooks/useSelectKeyboard.ts +112 -15
  101. package/src/Select/index.en-US.md +4 -0
  102. package/src/Select/index.zh-CN.md +4 -0
  103. package/src/Select/interface.ts +31 -4
  104. package/src/Select/option.tsx +9 -3
  105. package/src/Select/options.tsx +119 -26
  106. package/src/Select/select.tsx +41 -8
  107. package/src/Select/style/index.less +14 -0
  108. package/src/Selector/interface.ts +4 -0
  109. package/src/Selector/selector.tsx +8 -3
  110. package/src/Table/interface.ts +1 -1
  111. package/src/Tree/demo/draggable.tsx +2 -0
  112. package/src/Tree/interface.ts +46 -23
  113. package/src/Tree/tree-node.tsx +26 -29
  114. package/src/Tree/tree.tsx +573 -274
  115. package/src/Tree/utils/tree-tools.ts +231 -125
  116. package/src/locale/_README.md +22 -13
  117. package/src/locale/context.tsx +33 -16
  118. package/src/locale/interface.ts +24 -0
  119. package/src/locale/util.ts +6 -6
  120. package/src/style/color.css +1 -0
  121. package/src/style/size.css +2 -1
  122. package/src/utils/isEqual.ts +5 -7
  123. 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
- 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"
@@ -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
- // Hook is always called (rules of hooks). When `virtual` is off the
47
- // returned viewportRef is never attached, so the observer stays idle.
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: options.length,
62
+ itemCount: virtual ? rowCount : 0,
58
63
  itemHeight,
59
64
  });
60
65
 
61
- // Expose the virtual scroll API to the parent so keyboard navigation
62
- // can drive the virtual viewport (replaces scrollIntoView).
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 = { scrollToIndex };
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 the scroll position whenever the option set changes (e.g. after
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 on open and whenever it changes.
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
- scrollToIndex(focusedIndex);
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
- targetElement.scrollIntoView({ block: 'nearest' });
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 mode: only render the visible window -----
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: render every option (unchanged behavior) -----
248
+ // ----- Default mode (non-virtual) -----
165
249
  return (
166
250
  <div
167
251
  {...rest}
168
- className={clsx(`${prefixCls}-popup`, popupClassName)}
169
- style={popupStyle}
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
  >
@@ -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 === key);
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 === keys) ?? {
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.includes(key)) {
111
- keys = mergedValue.filter((k: string | number) => k !== key);
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((k: string | number) => k !== option.value);
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="width"
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
  );