@synerise/ds-context-selector 1.3.45 → 1.3.46

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,12 @@
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
+ ## [1.3.46](https://github.com/Synerise/synerise-design/compare/@synerise/ds-context-selector@1.3.45...@synerise/ds-context-selector@1.3.46) (2026-08-11)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **context-selector:** size virtual list window to visible content height ([7b2ca64](https://github.com/Synerise/synerise-design/commit/7b2ca641e418d396a7e48e21541e045f736e5181))
11
+
6
12
  ## [1.3.45](https://github.com/Synerise/synerise-design/compare/@synerise/ds-context-selector@1.3.44...@synerise/ds-context-selector@1.3.45) (2026-07-24)
7
13
 
8
14
  **Note:** Version bump only for package @synerise/ds-context-selector
package/CLAUDE.md CHANGED
@@ -102,6 +102,7 @@ src/
102
102
  | `defaultGroup` | `boolean` | No | Marks which tab is active on open |
103
103
  | `icon` | `ReactNode` | No | Icon in sub-group list row |
104
104
  | `subGroups` | `ContextGroup[]` | No | Enables sub-group navigation within a tab |
105
+ | `topSection` | `ReactNode` | No | Custom content rendered above the list for this tab; scrolls with the list and is hidden during search and sub-group navigation |
105
106
  | `description` | `ReactNode` | No | Row description |
106
107
  | `tooltip` | `string` | No | Tooltip on the group row |
107
108
  | `itemType` / `customSuffix` / `useCustomIcon` | — | No | Same semantics as `ContextItem` |
package/README.md CHANGED
@@ -120,6 +120,7 @@ import ContextSelector from '@synerise/ds-context-selector'
120
120
  | itemType | Type of items | string | - |
121
121
  | name | Name of group | string | - |
122
122
  | subGroups | Subgroups of groups | ContextGroup[] | - |
123
+ | topSection | Custom content rendered above the list for this tab; scrolls with the list and is hidden during search and sub-group navigation | React.ReactNode | - |
123
124
  | tooltip | Tooltip text | string | - |
124
125
  | useCustomIcon | Use custom icon instead of wrapping it inside Icon component | boolean | false |
125
126
  | description | Set menu item description ( recommended to use with 'large' size | React.ReactNode | - |
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
- import { useState, useEffect, useCallback, useMemo } from "react";
2
+ import { useRef, useState, useEffect, useCallback, useMemo } from "react";
3
3
  import Button from "@synerise/ds-button";
4
4
  import Dropdown from "@synerise/ds-dropdown";
5
5
  import Icon, { AngleDownS, Add3M } from "@synerise/ds-icon";
@@ -45,6 +45,7 @@ const ContextSelector = ({
45
45
  dropdownDimensionsConfig
46
46
  }) => {
47
47
  const allTexts = useTexts(texts);
48
+ const noListNavigationRef = useRef([]);
48
49
  const [dropdownVisible, setDropdownVisible] = useState(defaultDropdownVisibility ?? false);
49
50
  const dimensionsConfig = {
50
51
  defaultHeight: DROPDOWN_HEIGHT,
@@ -138,6 +139,12 @@ const ContextSelector = ({
138
139
  onDeactivate?.();
139
140
  }, trigger, open: dropdownVisible, size: "medium", ...dropdownProps, popoverProps: {
140
141
  testId: "context-selector",
142
+ listNavigationConfig: {
143
+ enabled: false,
144
+ activeIndex: null,
145
+ listRef: noListNavigationRef
146
+ },
147
+ closeOnFocusOut: false,
141
148
  ...dropdownProps?.popoverProps,
142
149
  dismissConfig: {
143
150
  outsidePress: (event) => getClosest(event.target, ".ds-info-card") === null
@@ -43,6 +43,7 @@ export type ContextGroup = {
43
43
  tooltip?: string;
44
44
  subGroups?: ContextGroup[];
45
45
  useCustomIcon?: boolean;
46
+ topSection?: ReactNode;
46
47
  };
47
48
  export declare const isContextItemsInSubGroup: (item: ContextGroup | ContextItemsInSubGroup) => item is ContextItemsInSubGroup;
48
49
  export type ContextItemsInSubGroup = ContextItem & {
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { useMemo, useRef, useState, useCallback, useEffect } from "react";
2
+ import { useMemo, useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
3
3
  import { VariableSizeList } from "react-window";
4
4
  import { v4 } from "uuid";
5
5
  import { theme } from "@synerise/ds-core";
@@ -13,18 +13,30 @@ import Tabs from "@synerise/ds-tabs";
13
13
  import { getActiveTabGroup, getGroupName, useSearchResults, focusWithArrowKeys } from "@synerise/ds-utils";
14
14
  import { ShowMoreItem, TabsWrapper, Skeleton, ItemsList, Title } from "../ContextSelector.styles.js";
15
15
  import { isContextItemsInSubGroup } from "../ContextSelector.types.js";
16
- import { NO_GROUP_NAME, DROPDOWN_HEIGHT, TABS_HEIGHT, SUBGROUP_HEADER_HEIGHT, SEARCH_HEIGHT } from "../constants.js";
16
+ import { NO_GROUP_NAME, DROPDOWN_HEIGHT } from "../constants.js";
17
17
  import ContextSelectorDropdownItem from "./ContextSelectorDropdownItem.js";
18
- import { isGroup, isListTitle } from "./utils.js";
18
+ import { isGroup, getDropdownContentHeight, getListWindowHeight, isListTitle } from "./utils.js";
19
19
  const ITEM_SIZE = {
20
20
  [itemSizes.LARGE]: 50,
21
21
  [itemSizes.DEFAULT]: 32,
22
22
  title: 32,
23
23
  divider: 16
24
24
  };
25
+ const FOCUSABLE_SELECTOR = 'button, [role="button"], a[href], input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
25
26
  function isDivider(element) {
26
27
  return element.type === "divider";
27
28
  }
29
+ const VirtualizedRow = ({
30
+ index,
31
+ style,
32
+ data
33
+ }) => {
34
+ const item = data.items[index];
35
+ if (item && isDivider(item)) {
36
+ return /* @__PURE__ */ jsx("div", { style, children: /* @__PURE__ */ jsx(Divider, { marginTop: 8, marginBottom: 8 }) });
37
+ }
38
+ return item && isListTitle(item) ? /* @__PURE__ */ jsx(Title, { style, children: item.title }) : /* @__PURE__ */ jsx(ContextSelectorDropdownItem, { style, ...item });
39
+ };
28
40
  const ContextSelectorDropdown = ({
29
41
  texts,
30
42
  setSelected,
@@ -57,11 +69,13 @@ const ContextSelectorDropdown = ({
57
69
  const listRef = useRef(null);
58
70
  const overlayRef = useRef(null);
59
71
  const scrollBarRef = useRef(null);
72
+ const topSectionRef = useRef(null);
60
73
  const [searchInputHandle, setSearchInputHandle] = useState();
61
74
  const [searchQuery, setSearchQuery] = useState("");
62
75
  const [activeTab, setActiveTab] = useState(defaultTab);
63
76
  const [activeGroup, setActiveGroup] = useState(void 0);
64
77
  const [searchInputCanBeFocused, setSearchInputFocus] = useState(true);
78
+ const [topSectionHeight, setTopSectionHeight] = useState(0);
65
79
  const classNames = useMemo(() => {
66
80
  return `ds-context-item ds-context-item-${v4()}`;
67
81
  }, []);
@@ -92,6 +106,22 @@ const ContextSelectorDropdown = ({
92
106
  const currentTabItems = useMemo(() => {
93
107
  return groups ? getActiveTabGroup(activeTab, groups) : void 0;
94
108
  }, [groups, activeTab]);
109
+ const activeTopSection = !searchQuery && !activeGroup ? currentTabItems?.topSection : void 0;
110
+ useLayoutEffect(() => {
111
+ const node = topSectionRef.current;
112
+ if (!node) {
113
+ setTopSectionHeight(0);
114
+ return void 0;
115
+ }
116
+ const measure = () => setTopSectionHeight(node.offsetHeight);
117
+ measure();
118
+ if (typeof ResizeObserver === "undefined") {
119
+ return void 0;
120
+ }
121
+ const observer = new ResizeObserver(measure);
122
+ observer.observe(node);
123
+ return () => observer.disconnect();
124
+ }, [activeTopSection, activeTab]);
95
125
  const groupByGroupName = useCallback((dropdownItems, maxItemsInGroup) => {
96
126
  const itemsNumber = dropdownItems?.length;
97
127
  const groupedItems = {};
@@ -200,6 +230,9 @@ const ContextSelectorDropdown = ({
200
230
  useEffect(() => {
201
231
  listRef.current?.resetAfterIndex(0, false);
202
232
  }, [activeItems, listRef]);
233
+ const itemData = useMemo(() => ({
234
+ items: activeItems
235
+ }), [activeItems]);
203
236
  const handleSearch = useCallback((val) => {
204
237
  setSearchQuery(val);
205
238
  resetList();
@@ -219,7 +252,7 @@ const ContextSelectorDropdown = ({
219
252
  scrollTop
220
253
  } = currentTarget;
221
254
  if (listRef.current !== null) {
222
- listRef.current.scrollTo(scrollTop);
255
+ listRef.current.scrollTo(Math.max(0, scrollTop - topSectionHeight));
223
256
  }
224
257
  };
225
258
  const getItemSize = (index) => {
@@ -233,7 +266,12 @@ const ContextSelectorDropdown = ({
233
266
  return menuItemHeight ? ITEM_SIZE[menuItemHeight] : ITEM_SIZE[itemSizes.DEFAULT];
234
267
  };
235
268
  const dropdownContentHeight = useMemo(() => {
236
- return outerHeight - (hasTabs && !searchQuery ? TABS_HEIGHT : 0) - (activeGroup ? SUBGROUP_HEADER_HEIGHT : 0) - SEARCH_HEIGHT;
269
+ return getDropdownContentHeight({
270
+ outerHeight,
271
+ hasTabs,
272
+ hasSearchQuery: Boolean(searchQuery),
273
+ hasActiveGroup: Boolean(activeGroup)
274
+ });
237
275
  }, [activeGroup, hasTabs, outerHeight, searchQuery]);
238
276
  useEffect(() => {
239
277
  if (scrollBarRef.current && listRef.current) {
@@ -243,11 +281,96 @@ const ContextSelectorDropdown = ({
243
281
  listRef.current.resetAfterIndex(0);
244
282
  }
245
283
  }, [searchQuery, activeGroup, activeTab]);
246
- return /* @__PURE__ */ jsxs(Dropdown.Wrapper, { style: dropdownWrapperStyles, ref: overlayRef, onKeyDown: (event) => {
247
- if (document?.activeElement === searchInputHandle?.current) {
284
+ const previousTabRef = useRef({
285
+ activeTab,
286
+ activeGroup
287
+ });
288
+ useEffect(() => {
289
+ const previous = previousTabRef.current;
290
+ if (previous.activeTab !== activeTab || previous.activeGroup !== activeGroup) {
291
+ previousTabRef.current = {
292
+ activeTab,
293
+ activeGroup
294
+ };
295
+ searchInputHandle?.current?.focus();
296
+ }
297
+ }, [activeTab, activeGroup, searchInputHandle]);
298
+ useEffect(() => {
299
+ if (!visible) {
300
+ return void 0;
301
+ }
302
+ const recoverLostFocus = (event) => {
303
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
304
+ return;
305
+ }
306
+ if (document.activeElement !== document.body) {
307
+ return;
308
+ }
309
+ const overlay = overlayRef.current;
310
+ if (!overlay) {
311
+ return;
312
+ }
313
+ const navClass = classNames.split(" ")[1];
314
+ const firstRenderedRow = overlay.querySelector(`.${navClass}`);
315
+ const target = firstRenderedRow ?? searchInputHandle?.current ?? null;
316
+ if (target) {
317
+ event.preventDefault();
318
+ target.focus();
319
+ }
320
+ };
321
+ document.addEventListener("keydown", recoverLostFocus);
322
+ return () => document.removeEventListener("keydown", recoverLostFocus);
323
+ }, [visible, classNames, searchInputHandle]);
324
+ return /* @__PURE__ */ jsxs(Dropdown.Wrapper, { style: dropdownWrapperStyles, ref: overlayRef, "data-testid": "context-selector-dropdown", onKeyDown: (event) => {
325
+ const searchInput = searchInputHandle?.current ?? null;
326
+ if (document?.activeElement === searchInput) {
248
327
  setSearchInputFocus(false);
249
328
  }
250
- searchQuery && focusWithArrowKeys(event, classNames.split(" ")[1], () => {
329
+ if (event.key === "ArrowUp" && document.activeElement === searchInput) {
330
+ event.preventDefault();
331
+ return;
332
+ }
333
+ const navClass = classNames.split(" ")[1];
334
+ const active = document.activeElement;
335
+ const topSectionItems = topSectionRef.current ? Array.from(topSectionRef.current.querySelectorAll(FOCUSABLE_SELECTOR)) : [];
336
+ const firstListItem = overlayRef.current?.querySelector(`.${navClass}`) ?? null;
337
+ const topIndex = active ? topSectionItems.indexOf(active) : -1;
338
+ const activeIsListItem = Boolean(active?.classList?.contains(navClass));
339
+ if (topIndex !== -1) {
340
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
341
+ event.preventDefault();
342
+ const nextIndex = event.key === "ArrowRight" ? Math.min(topIndex + 1, topSectionItems.length - 1) : Math.max(topIndex - 1, 0);
343
+ topSectionItems[nextIndex].focus();
344
+ return;
345
+ }
346
+ if (event.key === "ArrowUp") {
347
+ event.preventDefault();
348
+ setSearchInputFocus(true);
349
+ searchInput?.focus();
350
+ return;
351
+ }
352
+ if (event.key === "Enter") {
353
+ event.preventDefault();
354
+ active?.click();
355
+ return;
356
+ }
357
+ }
358
+ if (event.key === "ArrowDown" && topIndex === -1 && !activeIsListItem && topSectionItems.length) {
359
+ event.preventDefault();
360
+ topSectionItems[0].focus();
361
+ return;
362
+ }
363
+ if (event.key === "ArrowUp" && active === firstListItem) {
364
+ event.preventDefault();
365
+ if (topSectionItems.length) {
366
+ topSectionItems[0].focus();
367
+ } else {
368
+ setSearchInputFocus(true);
369
+ searchInput?.focus();
370
+ }
371
+ return;
372
+ }
373
+ focusWithArrowKeys(event, navClass, () => {
251
374
  setSearchInputFocus(true);
252
375
  });
253
376
  }, children: [
@@ -262,18 +385,12 @@ const ContextSelectorDropdown = ({
262
385
  resetList();
263
386
  }, visible }) }),
264
387
  activeGroup && /* @__PURE__ */ jsx(Dropdown.BackAction, { label: activeGroup.name, onClick: () => setActiveGroup(void 0) }),
265
- loading ? /* @__PURE__ */ jsx(Skeleton, { contentHeight: dropdownContentHeight, size: "M", numberOfSkeletons: 3 }) : /* @__PURE__ */ jsx(ItemsList, { contentHeight: dropdownContentHeight, children: activeItems?.length ? /* @__PURE__ */ jsx(Scrollbar, { absolute: true, style: {
388
+ loading ? /* @__PURE__ */ jsx(Skeleton, { contentHeight: dropdownContentHeight, size: "M", numberOfSkeletons: 3 }) : /* @__PURE__ */ jsx(ItemsList, { contentHeight: dropdownContentHeight, children: activeItems?.length ? /* @__PURE__ */ jsxs(Scrollbar, { absolute: true, style: {
266
389
  padding: 8
267
- }, loading, hasMore: hasMoreItems, onYReachEnd: onFetchData, onScroll: handleScroll, ref: scrollBarRef, children: /* @__PURE__ */ jsx(ListContextProvider, { popoverDelay, children: /* @__PURE__ */ jsx(VariableSizeList, { className: "ds-context-selector-list", width: "100%", height: 300, itemCount: activeItems.length, itemSize: getItemSize, style: listStyle, ref: listRef, children: ({
268
- index,
269
- style
270
- }) => {
271
- const item = activeItems[index];
272
- if (item && isDivider(item)) {
273
- return /* @__PURE__ */ jsx("div", { style, children: /* @__PURE__ */ jsx(Divider, { marginTop: 8, marginBottom: 8 }) });
274
- }
275
- return item && isListTitle(item) ? /* @__PURE__ */ jsx(Title, { style, children: item.title }) : /* @__PURE__ */ jsx(ContextSelectorDropdownItem, { style, ...item });
276
- } }, `list-${activeGroup}-${activeTab}`) }) }) : getNoResultContainer })
390
+ }, loading, hasMore: hasMoreItems, onYReachEnd: onFetchData, onScroll: handleScroll, ref: scrollBarRef, children: [
391
+ activeTopSection && /* @__PURE__ */ jsx("div", { ref: topSectionRef, children: activeTopSection }),
392
+ /* @__PURE__ */ jsx(ListContextProvider, { popoverDelay, children: /* @__PURE__ */ jsx(VariableSizeList, { className: "ds-context-selector-list", width: "100%", height: getListWindowHeight(dropdownContentHeight), itemCount: activeItems.length, itemSize: getItemSize, style: listStyle, ref: listRef, overscanCount: 8, itemData, children: VirtualizedRow }, `list-${activeGroup}-${activeTab}`) })
393
+ ] }) : getNoResultContainer })
277
394
  ] });
278
395
  };
279
396
  export {
@@ -0,0 +1,6 @@
1
+ export type DropdownContentHeightArgs = {
2
+ outerHeight: number;
3
+ hasTabs: boolean;
4
+ hasSearchQuery: boolean;
5
+ hasActiveGroup: boolean;
6
+ };
@@ -1,3 +1,6 @@
1
1
  import { ContextGroup, ContextItem, DropdownItemProps, ListTitle } from '../ContextSelector.types';
2
+ import { DropdownContentHeightArgs } from './ContextSelectorDropdown.types';
3
+ export declare const getDropdownContentHeight: ({ outerHeight, hasTabs, hasSearchQuery, hasActiveGroup, }: DropdownContentHeightArgs) => number;
4
+ export declare const getListWindowHeight: (dropdownContentHeight: number) => number;
2
5
  export declare const isListTitle: (element: DropdownItemProps) => element is ListTitle;
3
6
  export declare const isGroup: (item: ContextItem | ContextGroup) => item is ContextGroup;
@@ -1,3 +1,11 @@
1
+ import { TABS_HEIGHT, SUBGROUP_HEADER_HEIGHT, SEARCH_HEIGHT, MIN_LIST_WINDOW_HEIGHT } from "../constants.js";
2
+ const getDropdownContentHeight = ({
3
+ outerHeight,
4
+ hasTabs,
5
+ hasSearchQuery,
6
+ hasActiveGroup
7
+ }) => outerHeight - (hasTabs && !hasSearchQuery ? TABS_HEIGHT : 0) - (hasActiveGroup ? SUBGROUP_HEADER_HEIGHT : 0) - SEARCH_HEIGHT;
8
+ const getListWindowHeight = (dropdownContentHeight) => Math.max(dropdownContentHeight, MIN_LIST_WINDOW_HEIGHT);
1
9
  const isListTitle = (element) => {
2
10
  return element.title !== void 0;
3
11
  };
@@ -5,6 +13,8 @@ const isGroup = (item) => {
5
13
  return "isGroup" in item;
6
14
  };
7
15
  export {
16
+ getDropdownContentHeight,
17
+ getListWindowHeight,
8
18
  isGroup,
9
19
  isListTitle
10
20
  };
@@ -10,3 +10,4 @@ export declare const DROPDOWN_HEIGHT_THRESHOLD = 900;
10
10
  export declare const SEARCH_HEIGHT = 53;
11
11
  export declare const TABS_HEIGHT = 50;
12
12
  export declare const SUBGROUP_HEADER_HEIGHT = 53;
13
+ export declare const MIN_LIST_WINDOW_HEIGHT = 300;
package/dist/constants.js CHANGED
@@ -11,11 +11,13 @@ const DROPDOWN_HEIGHT_THRESHOLD = 900;
11
11
  const SEARCH_HEIGHT = 53;
12
12
  const TABS_HEIGHT = 50;
13
13
  const SUBGROUP_HEADER_HEIGHT = 53;
14
+ const MIN_LIST_WINDOW_HEIGHT = 300;
14
15
  export {
15
16
  DROPDOWN_HEIGHT,
16
17
  DROPDOWN_HEIGHT_BELOW_THRESHOLD,
17
18
  DROPDOWN_HEIGHT_THRESHOLD,
18
19
  ITEM_SIZE,
20
+ MIN_LIST_WINDOW_HEIGHT,
19
21
  NO_GROUP_NAME,
20
22
  SEARCH_HEIGHT,
21
23
  SUBGROUP_HEADER_HEIGHT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synerise/ds-context-selector",
3
- "version": "1.3.45",
3
+ "version": "1.3.46",
4
4
  "description": "ContextSelector UI Component for the Synerise Design System",
5
5
  "license": "ISC",
6
6
  "repository": "Synerise/synerise-design",
@@ -36,25 +36,25 @@
36
36
  "check:circular-dependencies": "madge --circular --extensions ts,tsx,js,jsx --ts-config tsconfig.json src/ --exclude '/dist/'",
37
37
  "upgrade:ds": "ncu -f \"@synerise/ds-*\" -u"
38
38
  },
39
- "gitHead": "018414e0d7ffd102a07f8aa35fa2e2d3d4ed19e2",
39
+ "gitHead": "6df24ed12cd5f276d8eccde730591807982e3385",
40
40
  "sideEffects": [
41
41
  "dist/style/*",
42
42
  "*.less"
43
43
  ],
44
44
  "types": "dist/index.d.ts",
45
45
  "dependencies": {
46
- "@synerise/ds-button": "^1.5.35",
47
- "@synerise/ds-divider": "^1.3.16",
48
- "@synerise/ds-dropdown": "^1.3.21",
49
- "@synerise/ds-factors": "^1.12.12",
46
+ "@synerise/ds-button": "^1.5.36",
47
+ "@synerise/ds-divider": "^1.3.17",
48
+ "@synerise/ds-dropdown": "^1.3.22",
49
+ "@synerise/ds-factors": "^1.12.13",
50
50
  "@synerise/ds-icon": "^1.18.5",
51
- "@synerise/ds-information-card": "^1.7.9",
52
- "@synerise/ds-list-item": "^1.6.2",
53
- "@synerise/ds-popover": "^1.6.2",
54
- "@synerise/ds-result": "^1.0.66",
51
+ "@synerise/ds-information-card": "^1.7.10",
52
+ "@synerise/ds-list-item": "^1.6.3",
53
+ "@synerise/ds-popover": "^1.6.3",
54
+ "@synerise/ds-result": "^1.0.67",
55
55
  "@synerise/ds-scrollbar": "^1.5.2",
56
- "@synerise/ds-skeleton": "^1.0.60",
57
- "@synerise/ds-tabs": "^1.2.7",
56
+ "@synerise/ds-skeleton": "^1.0.61",
57
+ "@synerise/ds-tabs": "^1.2.8",
58
58
  "@synerise/ds-utils": "^1.10.2",
59
59
  "react-intl": "^6.8.7",
60
60
  "react-window": "1.8.11",