@synerise/ds-select 2.0.0 → 2.0.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [2.0.2](https://github.com/synerise/synerise-design/compare/@synerise/ds-select@2.0.1...@synerise/ds-select@2.0.2) (2026-09-02)
7
+
8
+ **Note:** Version bump only for package @synerise/ds-select
9
+
10
+ ## [2.0.1](https://github.com/synerise/synerise-design/compare/@synerise/ds-select@2.0.0...@synerise/ds-select@2.0.1) (2026-09-01)
11
+
12
+ ### Performance Improvements
13
+
14
+ - **select:** virtualize the dropdown option list ([f95de4e](https://github.com/synerise/synerise-design/commit/f95de4e6bbe392def14e010c0556e2c56c90ddc7))
15
+
6
16
  # [2.0.0](https://github.com/synerise/synerise-design/compare/@synerise/ds-select@1.5.3...@synerise/ds-select@2.0.0) (2026-08-26)
7
17
 
8
18
  **Note:** Version bump only for package @synerise/ds-select
package/CLAUDE.md CHANGED
@@ -20,11 +20,12 @@ src/
20
20
  useSelectOptions.ts — resolve options (prop → children), client filtering, tags create-row
21
21
  useResponsiveTagCount.ts — maxTagCount="responsive": fit-to-width chip count (ResizeObserver)
22
22
  components/
23
- OptionList.tsx — dropdown overlay: loading / empty / scrollable listbox of options
23
+ OptionList.tsx — dropdown overlay: loading / empty / windowed (react-window) listbox of options
24
24
  SelectorContent.tsx — selector inner content: chips / selected label / placeholder + search input
25
25
  utils/
26
26
  getOptionsFromChildren.ts — read <Select.Option> children into SelectOption[]; findOption()
27
- helpers.ts — cx(), toArray(), defaultFilter(), DEFAULT_LIST_HEIGHT
27
+ areOptionChildrenEqual.ts — structural compare of <Select.Option> children (memo guard)
28
+ helpers.ts — cx(), toArray(), defaultFilter(), DEFAULT_LIST_HEIGHT, DEFAULT_LIST_ITEM_HEIGHT, OVERSCAN_COUNT, MAX_MEASURED_ROWS
28
29
  __specs__/
29
30
  Select.spec.tsx — Vitest + React Testing Library tests
30
31
  ```
@@ -69,7 +70,8 @@ sub-component: **`Select.Option`** (the DS `Option` marker). `Select.OptGroup` i
69
70
  | `readOnly` | `boolean` | `undefined` | Non-interactive with readable styling (white bg, `default` cursor, `grey-600` text). |
70
71
  | `disabled` | `boolean` | `undefined` | Standard disabled state; ORed with `readOnly` to block interaction. |
71
72
  | `selectorStyle` | `CSSObject` | `undefined` | Inline style object applied to the `Selector` box. |
72
- | `listHeight` | `number \| string` | `256` | Max dropdown list height (px). |
73
+ | `listHeight` | `number \| string` | `256` | Max dropdown list height (px); also the windowed viewport height. |
74
+ | `listItemHeight` | `number` | `32` | Height per option row the window is sized from; taller rows are measured. |
73
75
  | `style` | `CSSProperties` | `undefined` | Applied to `SelectWrapper` (the flex row: selector + addons). |
74
76
  | `className` | `string` | `undefined` | Added to the outer `SelectContainer`. |
75
77
  | `getPopupContainer` | `(node) => HTMLElement \| ParentNode \| null` | `defaultGetPopupContainer` (`@synerise/ds-utils`) | Container the dropdown mounts into. |
@@ -88,9 +90,9 @@ Kept so antd-era consumers need no change: `searchValue`, `onClear`, `onClick`,
88
90
  `SelectHandler` type and the `SelectStyles.Selector` styled export are re-exported for parity.
89
91
 
90
92
  `maxTagCount` (`number | 'responsive'` — collapse extra chips into a `+N` overflow chip, or fit them
91
- to the selector width on one line), `maxTagTextLength` (truncate chip labels) and `onPopupScroll` are
92
- fully implemented. `listItemHeight`, `dropdownAlign` and `defaultActiveFirstOption` are accepted for
93
- compatibility but have **no runtime effect**.
93
+ to the selector width on one line), `maxTagTextLength`, `onPopupScroll` and `listItemHeight` (the row
94
+ height the windowed list is sized from) are fully implemented. `dropdownAlign` and
95
+ `defaultActiveFirstOption` are accepted for compatibility but have **no runtime effect**.
94
96
 
95
97
  ## Usage patterns
96
98
 
@@ -145,6 +147,8 @@ Class hooks are `ds-select-*` (`.ds-select`, `.ds-select-selection-item`, `.ds-s
145
147
  - `@synerise/ds-icon` — `AngleDownS` (arrow), `Close3M` (clear), `CloseS` (chip remove).
146
148
  - `@synerise/ds-tooltip` — wraps the clear control for `clearTooltip`.
147
149
  - `@synerise/ds-utils` — default `getPopupContainer`.
150
+ - `react-window` — `VariableSizeList`, the windowing engine behind `OptionList` (already a DS-wide
151
+ dependency: `dropdown`, `context-selector`, `item-picker`, `table`, …).
148
152
  - `@synerise/ds-core` — theme tokens (peerDep). **No `antd` peerDep** — the component imports zero
149
153
  antd; the LESS that pulled `~antd/lib/select/style` was relocated to `ds-table` (see below).
150
154
 
@@ -153,6 +157,25 @@ Class hooks are `ds-select-*` (`.ds-select`, `.ds-select-selection-item`, `.ds-s
153
157
  - **Option resolution** — `useSelectOptions` returns `resolvedOptions` (from `options` prop, else
154
158
  `getOptionsFromChildren(children)`) and `displayedOptions` (after client filtering and, in `tags`
155
159
  mode, a create-row prepended for the typed text). `filterOption={false}` disables local filtering.
160
+ The children path is cached against `areOptionChildrenEqual` rather than `useMemo([children])`:
161
+ JSX hands over a fresh array of fresh elements on every parent render, so a reference-keyed memo
162
+ would rebuild the options — and invalidate every memo below them — on renders that changed nothing.
163
+ - **Windowed option list** — `OptionList` renders `displayedOptions` through react-window's
164
+ `VariableSizeList`, so only the visible rows plus `OVERSCAN_COUNT` are in the DOM. Following the
165
+ DS idiom (`item-picker`, `context-selector`): the surrounding `Scrollbar` owns the scrolling
166
+ (`overflow: unset` on the list, `onScroll` → `list.scrollTo`), which keeps `onPopupScroll` firing
167
+ from the same element as before. `listItemHeight` (default 32) is the size estimate; each mounted
168
+ row reports its real `offsetHeight` so rows with taller JSX (a flag + a wrapping label) lay out
169
+ correctly. Keyboard navigation can target a row that is not mounted, so `OptionList` scrolls by
170
+ computed offset instead of `scrollIntoView`, and a changed query resets the window to the top.
171
+ Because only a slice of the options is ever in the DOM, each row states `aria-setsize` /
172
+ `aria-posinset` — AT can no longer count the set for itself.
173
+ - **Row measurement invalidates through `resetAfterIndex`** — react-window memoises row offsets, so a
174
+ measured height only takes effect once the list is told to drop that memo. Two consequences worth
175
+ keeping in mind when touching `OptionList`: a row's own layout effect cannot reach `listRef` on the
176
+ mount commit (rows are descendants of the list, so their effects run first), and a change to
177
+ `listItemHeight` must invalidate *and* force a render — a passive `resetAfterIndex(index, false)`
178
+ leaves the stale layout on screen until something else happens to re-render the list.
156
179
  - **Row vs selector display (antd parity)** — the dropdown row renders `option.children ?? label ??
157
180
  value`, the selector renders the field named by `optionLabelProp` (`label`, `children`, `value`,
158
181
  `title`, …) and falls back to `label`. So `<Option label={compact}>{rich}</Option>` shows `rich` in
package/README.md CHANGED
@@ -104,8 +104,8 @@ select root.
104
104
  | dropdownStyle | Inline style on the dropdown overlay. | `CSSProperties` | - |
105
105
  | dropdownMatchSelectWidth | Match dropdown width to the selector; a number fixes the width (px). | `boolean \| number` | `true` |
106
106
  | dropdownRender | Wrap the rendered option menu (custom footer / scroll container). | `(menu: ReactElement) => ReactNode` | - |
107
- | listHeight | Max dropdown list height (px). | `number \| string` | `256` |
108
- | listItemHeight | Fixed height per option row (accepted; list is non-virtualised). | `number` | - |
107
+ | listHeight | Max dropdown list height (px) — also the height of the virtualised viewport. | `number \| string` | `256` |
108
+ | listItemHeight | Height per option row the virtualised window is sized from. Rows with taller content are measured and lay out at their real height. | `number` | `32` |
109
109
 
110
110
  ### Display & tags
111
111
 
@@ -191,7 +191,12 @@ import type {
191
191
  `.ds-select`, `.ds-select-wrapper`, `.ds-select-selection-item`, `.ds-select-dropdown`, etc.
192
192
  - **Remote search** — set `filterOption={false}` and update `options` from your `onSearch`
193
193
  handler; the component won't filter locally in that mode.
194
+ - **Virtualised list** — the dropdown renders only the visible rows (plus overscan) via
195
+ `react-window`, so a few hundred options cost the same as a few. `listHeight` sizes the viewport
196
+ and `listItemHeight` the row estimate.
194
197
  - **Keyboard & ARIA** — Arrow / Home / End / Enter / Escape / Space (and Backspace to drop the
195
- last chip in multiple mode), with combobox / listbox `aria-activedescendant`.
198
+ last chip in multiple mode), with combobox / listbox `aria-activedescendant`. Because the list
199
+ is virtualised, each option also carries `aria-setsize` / `aria-posinset` so assistive tech
200
+ announces its place in the whole list, not in the mounted window.
196
201
  - **Not reimplemented from antd** — `OptGroup`, `labelInValue` / `LabeledValue`,
197
202
  `autoClearSearchValue`, `firstActiveValue`, `menuItemSelectedIcon`.
package/dist/Select.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
- import { forwardRef, useState, useRef, useId, useEffect } from "react";
2
+ import { forwardRef, useState, useMemo, useRef, useId, useCallback, useEffect } from "react";
3
3
  import Dropdown from "@synerise/ds-dropdown";
4
4
  import FormField from "@synerise/ds-form-field";
5
5
  import Icon, { AngleDownS, Close3M } from "@synerise/ds-icon";
@@ -11,7 +11,7 @@ import { OptionList } from "./components/OptionList.js";
11
11
  import { SelectorContent } from "./components/SelectorContent.js";
12
12
  import { useSelectOptions } from "./hooks/useSelectOptions.js";
13
13
  import { findOption } from "./utils/getOptionsFromChildren.js";
14
- import { DEFAULT_LIST_HEIGHT, cx, toArray } from "./utils/helpers.js";
14
+ import { DEFAULT_LIST_HEIGHT, toArray, cx } from "./utils/helpers.js";
15
15
  const SelectInner = forwardRef((props, ref) => {
16
16
  const {
17
17
  value,
@@ -60,6 +60,7 @@ const SelectInner = forwardRef((props, ref) => {
60
60
  dropdownMatchSelectWidth = true,
61
61
  dropdownRender,
62
62
  listHeight = DEFAULT_LIST_HEIGHT,
63
+ listItemHeight,
63
64
  notFoundContent = "No data",
64
65
  clearIcon,
65
66
  clearTooltip,
@@ -88,7 +89,7 @@ const SelectInner = forwardRef((props, ref) => {
88
89
  const passthroughAttrs = Object.fromEntries(Object.entries(props).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
89
90
  const [internalValue, setInternalValue] = useState(defaultValue);
90
91
  const currentValue = value !== void 0 ? value : internalValue;
91
- const selectedValues = toArray(currentValue);
92
+ const selectedValues = useMemo(() => toArray(currentValue), [currentValue]);
92
93
  const [internalOpen, setInternalOpen] = useState(!!defaultOpen);
93
94
  const isControlledOpen = openProp !== void 0;
94
95
  const isOpen = isControlledOpen ? !!openProp : internalOpen;
@@ -100,7 +101,7 @@ const SelectInner = forwardRef((props, ref) => {
100
101
  const [activeIndex, setActiveIndex] = useState(-1);
101
102
  const baseId = useId();
102
103
  const listboxId = `${baseId}-listbox`;
103
- const optionDomId = (index) => `${baseId}-option-${index}`;
104
+ const optionDomId = useCallback((index) => `${baseId}-option-${index}`, [baseId]);
104
105
  const {
105
106
  resolvedOptions,
106
107
  displayedOptions
@@ -157,15 +158,6 @@ const SelectInner = forwardRef((props, ref) => {
157
158
  }
158
159
  setActiveIndex(firstEnabledIndex());
159
160
  }, [effectiveQuery]);
160
- useEffect(() => {
161
- if (!isOpen || activeIndex < 0) {
162
- return;
163
- }
164
- const activeEl = document.getElementById(optionDomId(activeIndex));
165
- activeEl?.scrollIntoView?.({
166
- block: "nearest"
167
- });
168
- }, [activeIndex, isOpen]);
169
161
  useEffect(() => {
170
162
  if (autoFocus && !hasInput) {
171
163
  selectorRef.current?.focus();
@@ -369,7 +361,7 @@ const SelectInner = forwardRef((props, ref) => {
369
361
  const hasValue = selectedValues.length > 0;
370
362
  const showClear = allowClear && hasValue && !isDisabled;
371
363
  const placeholderStr = typeof placeholder === "string" ? placeholder : void 0;
372
- const menu = /* @__PURE__ */ jsx(OptionList, { loading, options: displayedOptions, notFoundContent, listHeight, isMultiple, listboxId, selectedValues, activeIndex, rowKey, optionDomId, onOptionActivate: setActiveIndex, onOptionSelect: handleSelect, onPopupScroll });
364
+ const menu = /* @__PURE__ */ jsx(OptionList, { loading, options: displayedOptions, notFoundContent, listHeight, listItemHeight, isMultiple, listboxId, selectedValues, activeIndex, searchQuery: effectiveQuery, rowKey, optionDomId, onOptionActivate: setActiveIndex, onOptionSelect: handleSelect, onPopupScroll });
373
365
  const handleSearchKeyDown = (event) => {
374
366
  onInputKeyDown?.(event);
375
367
  onKeyDown?.(event);
@@ -29,7 +29,25 @@ export declare const Arrow: import('styled-components').StyledComponent<"span",
29
29
  export declare const ClearWrapper: import('styled-components').StyledComponent<"span", any, {}, never>;
30
30
  export declare const DropdownWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
31
31
  export declare const ScrollList: import('styled-components').StyledComponent<({ children, onClick, maxToShowItems, texts, ...htmlAttributes }: import('@synerise/ds-list-item').ListWrapperProps) => React.JSX.Element, any, {}, never>;
32
- export declare const Inner: import('styled-components').StyledComponent<"div", any, {}, never>;
32
+ /**
33
+ * The listbox. Its only child is the windowed list, styled from here (rather than
34
+ * via `styled(VariableSizeList)`, which would erase react-window's `itemData`
35
+ * generic): `height` is a react-window *prop* sizing the render window, not the
36
+ * box, so the CSS height is handed back to the content — the dropdown shrinks to
37
+ * fit a short list — while `overflow: unset` leaves scrolling to the surrounding
38
+ * `Scrollbar`.
39
+ *
40
+ * `max-height` does not clip (overflow is visible by design); it keeps the *box*
41
+ * at `listHeight` so nothing downstream measures a list of 500 rows as a
42
+ * 16000px-tall element. The rows below it overflow into the `Scrollbar`, whose
43
+ * scrollable region covers them — which holds only as long as no element between
44
+ * this one and that scroll node introduces its own `overflow: hidden`.
45
+ */
46
+ export declare const Inner: import('styled-components').StyledComponent<"div", any, {
47
+ $maxHeight?: number;
48
+ }, never>;
49
+ /** Row wrapper carrying react-window's absolute offset; height follows content. */
50
+ export declare const VirtualRow: import('styled-components').StyledComponent<"div", any, {}, never>;
33
51
  export declare const NotFound: import('styled-components').StyledComponent<"div", any, {}, never>;
34
52
  export declare const Loading: import('styled-components').StyledComponent<"div", any, {}, never>;
35
53
  export declare const OptionItem: StyledListItem;
@@ -55,42 +55,46 @@ const ScrollList = /* @__PURE__ */ styled(ListWrapper).withConfig({
55
55
  const Inner = /* @__PURE__ */ styled.div.withConfig({
56
56
  displayName: "Selectstyles__Inner",
57
57
  componentId: "sc-n9lk0v-11"
58
- })(["padding-right:8px;"]);
58
+ })(["padding-right:8px;> .ds-select-option-list{overflow-x:unset;overflow-y:unset;height:auto !important;", "}"], (props) => props.$maxHeight !== void 0 && css(["max-height:", "px;"], props.$maxHeight));
59
+ const VirtualRow = /* @__PURE__ */ styled.div.withConfig({
60
+ displayName: "Selectstyles__VirtualRow",
61
+ componentId: "sc-n9lk0v-12"
62
+ })(["box-sizing:border-box;"]);
59
63
  const NotFound = /* @__PURE__ */ styled.div.withConfig({
60
64
  displayName: "Selectstyles__NotFound",
61
- componentId: "sc-n9lk0v-12"
65
+ componentId: "sc-n9lk0v-13"
62
66
  })(["display:flex;align-items:center;justify-content:center;padding:8px 12px;color:", ";font-weight:normal;"], (props) => props.theme.palette["grey-600"]);
63
67
  const Loading = /* @__PURE__ */ styled.div.withConfig({
64
68
  displayName: "Selectstyles__Loading",
65
- componentId: "sc-n9lk0v-13"
69
+ componentId: "sc-n9lk0v-14"
66
70
  })(["display:flex;align-items:center;justify-content:center;padding:16px;"]);
67
71
  const OptionItem = /* @__PURE__ */ styled(DSListItem).withConfig({
68
72
  displayName: "Selectstyles__OptionItem",
69
- componentId: "sc-n9lk0v-14"
73
+ componentId: "sc-n9lk0v-15"
70
74
  })(["min-width:auto;font-weight:normal;&&:hover:not(.ds-list-item-disabled),&&.ds-select-item-option-active:not(.ds-list-item-disabled){background-color:", ";}"], (props) => props.theme.palette["blue-050"]);
71
75
  const SearchInputEl = /* @__PURE__ */ styled.input.withConfig({
72
76
  displayName: "Selectstyles__SearchInputEl",
73
- componentId: "sc-n9lk0v-15"
77
+ componentId: "sc-n9lk0v-16"
74
78
  })(["flex:1 1 30px;min-width:30px;width:100%;border:none;outline:none;background:transparent;padding:0;margin:0;font-family:inherit;font-size:13px;color:", ";cursor:inherit;&::placeholder{color:", ";}&:disabled{cursor:not-allowed;}", ""], (props) => props.theme.palette["grey-700"], (props) => props.theme.palette["grey-500"], (props) => props.$overlay && css(["position:absolute;top:0;left:0;width:100%;height:100%;flex:none;box-sizing:border-box;padding:0 30px 0 12px;"]));
75
79
  const MultiValueArea = /* @__PURE__ */ styled.div.withConfig({
76
80
  displayName: "Selectstyles__MultiValueArea",
77
- componentId: "sc-n9lk0v-16"
81
+ componentId: "sc-n9lk0v-17"
78
82
  })(["display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;min-width:0;", ""], (props) => props.$responsive && css(["position:relative;flex-wrap:nowrap;overflow:hidden;> .ds-select-selection-item,> .ds-select-selection-overflow{flex:0 0 auto;}> .ds-select-search{flex:1 1 0;min-width:0;}"]));
79
83
  const TagMeasureRow = /* @__PURE__ */ styled.div.withConfig({
80
84
  displayName: "Selectstyles__TagMeasureRow",
81
- componentId: "sc-n9lk0v-17"
85
+ componentId: "sc-n9lk0v-18"
82
86
  })(["position:absolute;top:0;left:0;width:100%;height:0;display:flex;flex-wrap:nowrap;gap:4px;overflow:hidden;visibility:hidden;pointer-events:none;> *{flex:0 0 auto;}"]);
83
87
  const Chip = /* @__PURE__ */ styled.span.withConfig({
84
88
  displayName: "Selectstyles__Chip",
85
- componentId: "sc-n9lk0v-18"
89
+ componentId: "sc-n9lk0v-19"
86
90
  })(["position:relative;display:inline-flex;align-items:center;box-sizing:border-box;width:max-content;max-width:100%;min-height:24px;padding:0 8px;background:", ";border:none;border-radius:3px;font-size:13px;line-height:1;color:", ";&:hover{background:", ";color:", ";}&:hover .ds-select-selection-item-label{max-width:calc(100% - 24px);}&:hover .ds-select-selection-item-remove{display:inline-flex;}"], (props) => props.theme.palette["grey-200"], (props) => props.theme.palette["grey-600"], (props) => props.theme.palette["grey-300"], (props) => props.theme.palette["grey-800"]);
87
91
  const ChipLabel = /* @__PURE__ */ styled.span.withConfig({
88
92
  displayName: "Selectstyles__ChipLabel",
89
- componentId: "sc-n9lk0v-19"
93
+ componentId: "sc-n9lk0v-20"
90
94
  })(["max-width:100%;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;"]);
91
95
  const ChipRemove = /* @__PURE__ */ styled.span.withConfig({
92
96
  displayName: "Selectstyles__ChipRemove",
93
- componentId: "sc-n9lk0v-20"
97
+ componentId: "sc-n9lk0v-21"
94
98
  })(["position:absolute;right:4px;top:50%;transform:translateY(-50%);display:none;align-items:center;justify-content:center;width:24px;height:24px;cursor:pointer;color:", ";"], (props) => props.theme.palette["red-600"]);
95
99
  export {
96
100
  Arrow,
@@ -113,5 +117,6 @@ export {
113
117
  SelectionItem,
114
118
  Selector,
115
119
  SuffixWrapper,
116
- TagMeasureRow
120
+ TagMeasureRow,
121
+ VirtualRow
117
122
  };
@@ -120,7 +120,11 @@ export type SelectProps<VT extends SelectValue = SelectValue> = {
120
120
  dropdownRender?: (menu: ReactElement) => ReactNode;
121
121
  /** Max dropdown list height (px). */
122
122
  listHeight?: number | string;
123
- /** antd parity: fixed height per option row (px). Accepted for compatibility (non-virtualised list). */
123
+ /**
124
+ * Height (px) of a single option row, used to window the list. Rows that render
125
+ * taller content are measured and lay out at their real height, so this is the
126
+ * estimate the window starts from rather than a hard cap. Defaults to 32.
127
+ */
124
128
  listItemHeight?: number;
125
129
  notFoundContent?: ReactNode;
126
130
  clearIcon?: ReactNode;
@@ -6,11 +6,15 @@ type OptionListProps = {
6
6
  options: SelectOption[];
7
7
  notFoundContent?: ReactNode;
8
8
  listHeight?: number | string;
9
+ /** Estimated height of one option row; measured rows override it. */
10
+ listItemHeight?: number;
9
11
  isMultiple: boolean;
10
12
  listboxId: string;
11
13
  selectedValues: RawValueType[];
12
14
  /** Keyboard-highlighted option index. */
13
15
  activeIndex: number;
16
+ /** Current query — a change scrolls the window back to the top. */
17
+ searchQuery: string;
14
18
  rowKey?: (option: SelectOption) => Key;
15
19
  optionDomId: (index: number) => string;
16
20
  onOptionActivate: (index: number) => void;
@@ -18,6 +22,6 @@ type OptionListProps = {
18
22
  /** antd parity: fired as the option list scrolls (e.g. to page in more options). */
19
23
  onPopupScroll?: (event: UIEvent<HTMLDivElement>) => void;
20
24
  };
21
- /** The dropdown overlay: loading / empty / the scrollable listbox of options. */
22
- export declare const OptionList: ({ loading, options, notFoundContent, listHeight, isMultiple, listboxId, selectedValues, activeIndex, rowKey, optionDomId, onOptionActivate, onOptionSelect, onPopupScroll, }: OptionListProps) => ReactElement;
25
+ /** The dropdown overlay: loading / empty / the windowed listbox of options. */
26
+ export declare const OptionList: ({ loading, options, notFoundContent, listHeight, listItemHeight, isMultiple, listboxId, selectedValues, activeIndex, searchQuery, rowKey, optionDomId, onOptionActivate, onOptionSelect, onPopupScroll, }: OptionListProps) => ReactElement;
23
27
  export {};
@@ -1,50 +1,223 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
+ import { forwardRef, memo, useRef, useLayoutEffect, useCallback, useEffect, useMemo } from "react";
3
+ import { VariableSizeList } from "react-window";
2
4
  import Loader from "@synerise/ds-loader";
3
5
  import Scrollbar from "@synerise/ds-scrollbar";
4
- import { DropdownWrapper, Loading, NotFound, ScrollList, Inner, OptionItem } from "../Select.styles.js";
5
- import { DEFAULT_LIST_HEIGHT, cx } from "../utils/helpers.js";
6
+ import { VirtualRow, OptionItem, DropdownWrapper, Loading, NotFound, ScrollList, Inner } from "../Select.styles.js";
7
+ import { cx, DEFAULT_LIST_ITEM_HEIGHT, MAX_MEASURED_ROWS, DEFAULT_LIST_HEIGHT, OVERSCAN_COUNT } from "../utils/helpers.js";
8
+ const LIST_STYLE = {
9
+ overflowX: "unset",
10
+ overflowY: "unset"
11
+ };
12
+ const PresentationDiv = forwardRef((props, ref) => /* @__PURE__ */ jsx("div", { ref, role: "presentation", ...props }));
13
+ PresentationDiv.displayName = "SelectListWrapper";
14
+ const OptionRow = memo(({
15
+ index,
16
+ style,
17
+ data
18
+ }) => {
19
+ const {
20
+ options,
21
+ selectedValues,
22
+ activeIndex,
23
+ optionDomId,
24
+ onOptionActivate,
25
+ onOptionSelect,
26
+ measureRow
27
+ } = data;
28
+ const option = options[index];
29
+ const rowRef = useRef(null);
30
+ useLayoutEffect(() => {
31
+ const height = rowRef.current?.offsetHeight ?? 0;
32
+ measureRow(index, height);
33
+ });
34
+ useLayoutEffect(() => {
35
+ const node = rowRef.current;
36
+ if (!node || typeof ResizeObserver === "undefined") {
37
+ return void 0;
38
+ }
39
+ const observer = new ResizeObserver(() => measureRow(index, node.offsetHeight));
40
+ observer.observe(node);
41
+ return () => observer.disconnect();
42
+ }, [index, measureRow]);
43
+ const isSelected = selectedValues.includes(option.value);
44
+ const optionAttrs = Object.fromEntries(Object.entries(option).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
45
+ return /* @__PURE__ */ jsx(
46
+ VirtualRow,
47
+ {
48
+ ref: rowRef,
49
+ role: "presentation",
50
+ style: {
51
+ ...style,
52
+ height: "auto",
53
+ minHeight: style.height
54
+ },
55
+ children: /* @__PURE__ */ jsx(
56
+ OptionItem,
57
+ {
58
+ id: optionDomId(index),
59
+ role: "option",
60
+ className: cx("ds-select-item-option", isSelected && "ds-select-item-option-selected", index === activeIndex && "ds-select-item-option-active"),
61
+ selected: isSelected,
62
+ "aria-selected": isSelected,
63
+ "aria-setsize": options.length,
64
+ "aria-posinset": index + 1,
65
+ "data-testid": "select-option",
66
+ title: typeof option.title === "string" ? option.title : void 0,
67
+ text: option.children ?? option.label ?? option.value,
68
+ style: option.style,
69
+ disabled: option.disabled,
70
+ onMouseEnter: () => onOptionActivate(index),
71
+ onClick: () => onOptionSelect(option),
72
+ ...optionAttrs
73
+ }
74
+ )
75
+ }
76
+ );
77
+ });
78
+ OptionRow.displayName = "SelectOptionRow";
6
79
  const OptionList = ({
7
80
  loading,
8
81
  options,
9
82
  notFoundContent,
10
83
  listHeight,
84
+ listItemHeight,
11
85
  isMultiple,
12
86
  listboxId,
13
87
  selectedValues,
14
88
  activeIndex,
89
+ searchQuery,
15
90
  rowKey,
16
91
  optionDomId,
17
92
  onOptionActivate,
18
93
  onOptionSelect,
19
94
  onPopupScroll
20
- }) => /* @__PURE__ */ jsx(DropdownWrapper, { onMouseDown: (event) => {
21
- const target = event.target;
22
- if (!target.closest('input, textarea, [contenteditable="true"]')) {
23
- event.preventDefault();
24
- }
25
- }, children: loading ? /* @__PURE__ */ jsx(Loading, { className: "ds-select-loading", children: /* @__PURE__ */ jsx(Loader, { size: "M" }) }) : options.length === 0 ? /* @__PURE__ */ jsx(NotFound, { className: "ds-select-empty", children: notFoundContent }) : /* @__PURE__ */ jsx(ScrollList, { children: /* @__PURE__ */ jsx(Scrollbar, { absolute: true, maxHeight: Number(listHeight) || DEFAULT_LIST_HEIGHT, onScroll: onPopupScroll ? (event) => onPopupScroll(event) : void 0, children: /* @__PURE__ */ jsx(Inner, { role: "listbox", id: listboxId, "aria-multiselectable": isMultiple || void 0, children: options.map((option, index) => {
26
- const isSelected = selectedValues.includes(option.value);
27
- const optionAttrs = Object.fromEntries(Object.entries(option).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
28
- return /* @__PURE__ */ jsx(
29
- OptionItem,
95
+ }) => {
96
+ const listRef = useRef(null);
97
+ const scrollRef = useRef(null);
98
+ const scrollOffsetRef = useRef(0);
99
+ const sizeCacheRef = useRef(/* @__PURE__ */ new Map());
100
+ const windowHeight = Number(listHeight) || DEFAULT_LIST_HEIGHT;
101
+ const rowHeight = listItemHeight && listItemHeight > 0 ? listItemHeight : DEFAULT_LIST_ITEM_HEIGHT;
102
+ const keyFor = useCallback((option) => rowKey ? rowKey(option) : option.key ?? option.value, [rowKey]);
103
+ const getItemSize = useCallback((index) => {
104
+ const option = options[index];
105
+ if (!option) {
106
+ return rowHeight;
107
+ }
108
+ return sizeCacheRef.current.get(keyFor(option)) ?? rowHeight;
109
+ }, [options, keyFor, rowHeight]);
110
+ const measureRow = useCallback((index, height) => {
111
+ const option = options[index];
112
+ if (!option || !height) {
113
+ return;
114
+ }
115
+ const key = keyFor(option);
116
+ if (sizeCacheRef.current.get(key) === height) {
117
+ return;
118
+ }
119
+ sizeCacheRef.current.set(key, height);
120
+ listRef.current?.resetAfterIndex(index);
121
+ }, [options, keyFor]);
122
+ const prevRowHeightRef = useRef(rowHeight);
123
+ useLayoutEffect(() => {
124
+ if (prevRowHeightRef.current === rowHeight) {
125
+ return;
126
+ }
127
+ prevRowHeightRef.current = rowHeight;
128
+ sizeCacheRef.current.clear();
129
+ listRef.current?.resetAfterIndex(0);
130
+ }, [rowHeight]);
131
+ useEffect(() => {
132
+ const cache = sizeCacheRef.current;
133
+ if (cache.size > MAX_MEASURED_ROWS) {
134
+ const live = new Set(options.map(keyFor));
135
+ cache.forEach((_height, key) => {
136
+ if (!live.has(key)) {
137
+ cache.delete(key);
138
+ }
139
+ });
140
+ }
141
+ listRef.current?.resetAfterIndex(0, false);
142
+ }, [options, keyFor]);
143
+ const scrollTo = useCallback((offset) => {
144
+ scrollOffsetRef.current = offset;
145
+ if (scrollRef.current) {
146
+ scrollRef.current.scrollTop = offset;
147
+ }
148
+ listRef.current?.scrollTo(offset);
149
+ }, []);
150
+ const isFirstQuery = useRef(true);
151
+ useEffect(() => {
152
+ if (isFirstQuery.current) {
153
+ isFirstQuery.current = false;
154
+ return;
155
+ }
156
+ scrollTo(0);
157
+ }, [searchQuery, scrollTo]);
158
+ useEffect(() => {
159
+ if (activeIndex < 0 || activeIndex >= options.length) {
160
+ return;
161
+ }
162
+ let top = 0;
163
+ for (let index = 0; index < activeIndex; index += 1) {
164
+ top += getItemSize(index);
165
+ }
166
+ const bottom = top + getItemSize(activeIndex);
167
+ const viewport = scrollRef.current?.clientHeight || windowHeight;
168
+ const current = scrollOffsetRef.current;
169
+ let next = current;
170
+ if (top < current) {
171
+ next = top;
172
+ } else if (bottom > current + viewport) {
173
+ next = bottom - viewport;
174
+ }
175
+ if (next !== current) {
176
+ scrollTo(Math.max(0, next));
177
+ }
178
+ }, [activeIndex, options, getItemSize, windowHeight, scrollTo]);
179
+ const handleScroll = useCallback((event) => {
180
+ const {
181
+ scrollTop
182
+ } = event.currentTarget;
183
+ scrollOffsetRef.current = scrollTop;
184
+ listRef.current?.scrollTo(scrollTop);
185
+ onPopupScroll?.(event);
186
+ }, [onPopupScroll]);
187
+ const itemData = useMemo(() => ({
188
+ options,
189
+ selectedValues,
190
+ activeIndex,
191
+ optionDomId,
192
+ onOptionActivate,
193
+ onOptionSelect,
194
+ measureRow
195
+ }), [options, selectedValues, activeIndex, optionDomId, onOptionActivate, onOptionSelect, measureRow]);
196
+ return /* @__PURE__ */ jsx(DropdownWrapper, { onMouseDown: (event) => {
197
+ const target = event.target;
198
+ if (!target.closest('input, textarea, [contenteditable="true"]')) {
199
+ event.preventDefault();
200
+ }
201
+ }, children: loading ? /* @__PURE__ */ jsx(Loading, { className: "ds-select-loading", children: /* @__PURE__ */ jsx(Loader, { size: "M" }) }) : options.length === 0 ? /* @__PURE__ */ jsx(NotFound, { className: "ds-select-empty", children: notFoundContent }) : /* @__PURE__ */ jsx(ScrollList, { children: /* @__PURE__ */ jsx(Scrollbar, { absolute: true, maxHeight: windowHeight, onScroll: handleScroll, ref: scrollRef, children: /* @__PURE__ */ jsx(Inner, { role: "listbox", id: listboxId, "aria-multiselectable": isMultiple || void 0, $maxHeight: windowHeight, children: /* @__PURE__ */ jsx(
202
+ VariableSizeList,
30
203
  {
31
- id: optionDomId(index),
32
- role: "option",
33
- className: cx("ds-select-item-option", isSelected && "ds-select-item-option-selected", index === activeIndex && "ds-select-item-option-active"),
34
- selected: isSelected,
35
- "aria-selected": isSelected,
36
- "data-testid": "select-option",
37
- title: typeof option.title === "string" ? option.title : void 0,
38
- text: option.children ?? option.label ?? option.value,
39
- style: option.style,
40
- disabled: option.disabled,
41
- onMouseEnter: () => onOptionActivate(index),
42
- onClick: () => onOptionSelect(option),
43
- ...optionAttrs
44
- },
45
- rowKey ? rowKey(option) : option.value
46
- );
47
- }) }) }) }) });
204
+ ref: listRef,
205
+ className: "ds-select-option-list",
206
+ width: "100%",
207
+ height: windowHeight,
208
+ itemCount: options.length,
209
+ itemSize: getItemSize,
210
+ estimatedItemSize: rowHeight,
211
+ itemData,
212
+ itemKey: (index, data) => keyFor(data.options[index]),
213
+ overscanCount: OVERSCAN_COUNT,
214
+ outerElementType: PresentationDiv,
215
+ innerElementType: PresentationDiv,
216
+ style: LIST_STYLE,
217
+ children: OptionRow
218
+ }
219
+ ) }) }) }) });
220
+ };
48
221
  export {
49
222
  OptionList
50
223
  };
@@ -1,6 +1,20 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useRef } from "react";
2
+ import { areOptionChildrenEqual } from "../utils/areOptionChildrenEqual.js";
2
3
  import { getOptionsFromChildren } from "../utils/getOptionsFromChildren.js";
3
4
  import { defaultFilter } from "../utils/helpers.js";
5
+ const useOptionsFromChildren = (children) => {
6
+ const cacheRef = useRef(null);
7
+ const cached = cacheRef.current;
8
+ if (cached && areOptionChildrenEqual(cached.children, children)) {
9
+ return cached.options;
10
+ }
11
+ const options = getOptionsFromChildren(children);
12
+ cacheRef.current = {
13
+ children,
14
+ options
15
+ };
16
+ return options;
17
+ };
4
18
  const useSelectOptions = ({
5
19
  options,
6
20
  children,
@@ -10,12 +24,9 @@ const useSelectOptions = ({
10
24
  isTags,
11
25
  selectedValues
12
26
  }) => {
13
- const resolvedOptions = useMemo(() => {
14
- if (options && options.length > 0) {
15
- return options;
16
- }
17
- return getOptionsFromChildren(children);
18
- }, [options, children]);
27
+ const hasOptionsProp = Boolean(options && options.length > 0);
28
+ const childrenOptions = useOptionsFromChildren(hasOptionsProp ? null : children);
29
+ const resolvedOptions = hasOptionsProp ? options : childrenOptions;
19
30
  const displayedOptions = useMemo(() => {
20
31
  let list = resolvedOptions;
21
32
  if (filterOption !== false && effectiveQuery) {
@@ -0,0 +1,7 @@
1
+ import { ReactNode } from 'react';
2
+ /**
3
+ * True when two `children` node lists describe the same `<Select.Option>` set.
4
+ * Lets the option list keep its identity across a parent re-render that did not
5
+ * actually change the options (see `useSelectOptions`).
6
+ */
7
+ export declare const areOptionChildrenEqual: (a: ReactNode, b: ReactNode) => boolean;
@@ -0,0 +1,31 @@
1
+ import { Children, isValidElement } from "react";
2
+ const MAX_DEPTH = 6;
3
+ const isPlainObject = (value) => typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
4
+ const isEqualValue = (a, b, depth) => {
5
+ if (Object.is(a, b)) {
6
+ return true;
7
+ }
8
+ if (depth >= MAX_DEPTH) {
9
+ return false;
10
+ }
11
+ if (Array.isArray(a) || Array.isArray(b)) {
12
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => isEqualValue(item, b[index], depth + 1));
13
+ }
14
+ if (isValidElement(a) || isValidElement(b)) {
15
+ return isValidElement(a) && isValidElement(b) && a.type === b.type && a.key === b.key && isEqualValue(a.props, b.props, depth + 1);
16
+ }
17
+ if (isPlainObject(a) && isPlainObject(b)) {
18
+ const keysA = Object.keys(a);
19
+ const keysB = Object.keys(b);
20
+ return keysA.length === keysB.length && keysA.every((key) => Object.prototype.hasOwnProperty.call(b, key) && isEqualValue(a[key], b[key], depth + 1));
21
+ }
22
+ return false;
23
+ };
24
+ const areOptionChildrenEqual = (a, b) => {
25
+ const listA = Children.toArray(a);
26
+ const listB = Children.toArray(b);
27
+ return listA.length === listB.length && listA.every((child, index) => isEqualValue(child, listB[index], 0));
28
+ };
29
+ export {
30
+ areOptionChildrenEqual
31
+ };
@@ -1,6 +1,20 @@
1
1
  import { FilterOptionFn, RawValueType, SelectValue } from '../Select.types';
2
2
  /** Default max height (px) of the option list. */
3
3
  export declare const DEFAULT_LIST_HEIGHT = 256;
4
+ /**
5
+ * Default height (px) of a single option row — `ds-list-item`'s `default` size.
6
+ * Used as the windowing estimate until a row reports its real height.
7
+ */
8
+ export declare const DEFAULT_LIST_ITEM_HEIGHT = 32;
9
+ /** Rows rendered above/below the visible window so scrolling never shows a gap. */
10
+ export declare const OVERSCAN_COUNT = 8;
11
+ /**
12
+ * Upper bound on remembered row measurements. Heights are cached per option (not
13
+ * per index) so filtering a list down and back keeps them usable — which means a
14
+ * remote-search select accumulates an entry for every option it has ever shown.
15
+ * Past this many, the cache is pruned to the options currently in the list.
16
+ */
17
+ export declare const MAX_MEASURED_ROWS = 1000;
4
18
  /** Join truthy class names into a single string. */
5
19
  export declare const cx: (...classes: (string | false | undefined)[]) => string;
6
20
  /** Normalise a `SelectValue` to an array of raw values. */
@@ -1,4 +1,7 @@
1
1
  const DEFAULT_LIST_HEIGHT = 256;
2
+ const DEFAULT_LIST_ITEM_HEIGHT = 32;
3
+ const OVERSCAN_COUNT = 8;
4
+ const MAX_MEASURED_ROWS = 1e3;
2
5
  const cx = (...classes) => classes.filter(Boolean).join(" ");
3
6
  const toArray = (value) => {
4
7
  if (value === void 0 || value === null) {
@@ -13,6 +16,9 @@ const defaultFilter = (optionFilterProp) => (input, option) => {
13
16
  };
14
17
  export {
15
18
  DEFAULT_LIST_HEIGHT,
19
+ DEFAULT_LIST_ITEM_HEIGHT,
20
+ MAX_MEASURED_ROWS,
21
+ OVERSCAN_COUNT,
16
22
  cx,
17
23
  defaultFilter,
18
24
  toArray
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synerise/ds-select",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Select UI Component for the Synerise Design System",
5
5
  "license": "ISC",
6
6
  "repository": "synerise/synerise-design",
@@ -42,15 +42,16 @@
42
42
  ],
43
43
  "types": "dist/index.d.ts",
44
44
  "dependencies": {
45
- "@synerise/ds-dropdown": "^2.0.0",
46
- "@synerise/ds-form-field": "^2.0.0",
47
- "@synerise/ds-icon": "^2.0.0",
48
- "@synerise/ds-list-item": "^2.0.0",
45
+ "@synerise/ds-dropdown": "^2.0.1",
46
+ "@synerise/ds-form-field": "^2.0.1",
47
+ "@synerise/ds-icon": "^2.1.0",
48
+ "@synerise/ds-list-item": "^2.0.1",
49
49
  "@synerise/ds-loader": "^2.0.0",
50
- "@synerise/ds-scrollbar": "^2.0.0",
51
- "@synerise/ds-tooltip": "^2.0.0",
50
+ "@synerise/ds-scrollbar": "^2.0.1",
51
+ "@synerise/ds-tooltip": "^2.0.1",
52
52
  "@synerise/ds-utils": "^2.0.0",
53
- "classnames": "^2.5.1"
53
+ "classnames": "^2.5.1",
54
+ "react-window": "^1.8.11"
54
55
  },
55
56
  "peerDependencies": {
56
57
  "@synerise/ds-core": "*",
@@ -58,5 +59,5 @@
58
59
  "styled-components": "^5.3.3",
59
60
  "vitest": "4"
60
61
  },
61
- "gitHead": "033854a18f038079e9a16b0ff250f8401a2287c2"
62
+ "gitHead": "1dc97a2bae9f1d20c3f35e6672d059d502c90d1a"
62
63
  }