@synerise/ds-autocomplete 1.2.53 → 1.2.55

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
+ ## [1.2.55](https://github.com/Synerise/synerise-design/compare/@synerise/ds-autocomplete@1.2.54...@synerise/ds-autocomplete@1.2.55) (2026-08-14)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **autocomplete:** show suggestions that arrive after the request empties the list ([dbffe88](https://github.com/Synerise/synerise-design/commit/dbffe886b12ac5c00270302171c0e06320b6d64a))
11
+
12
+ ## [1.2.54](https://github.com/Synerise/synerise-design/compare/@synerise/ds-autocomplete@1.2.53...@synerise/ds-autocomplete@1.2.54) (2026-08-13)
13
+
14
+ **Note:** Version bump only for package @synerise/ds-autocomplete
15
+
6
16
  ## [1.2.53](https://github.com/Synerise/synerise-design/compare/@synerise/ds-autocomplete@1.2.52...@synerise/ds-autocomplete@1.2.53) (2026-08-11)
7
17
 
8
18
  **Note:** Version bump only for package @synerise/ds-autocomplete
package/CLAUDE.md CHANGED
@@ -29,11 +29,12 @@ src/
29
29
 
30
30
  ## Architecture (native rewrite)
31
31
 
32
- - The trigger is a styled native `<input role="combobox">` (`S.NativeInput`), autosized via `useAutosizeWidth` from `@synerise/ds-input` (hidden `<span>` sizer; `stretchToFit` via `useStretchToFit`).
33
- - The dropdown is `@synerise/ds-dropdown` (`asChild`, `size="match-trigger"`), controlled via `open`/`onOpenChange`. **`trigger={[]}`** — Autocomplete owns the open state through the input's focus/click/change handlers, so ds-dropdown is used only for positioning + outside-dismiss and does **not** toggle on trigger click (delegating the toggle to ds-dropdown as well double-fired against the focus-open and flashed the panel open→closed on re-click). The overlay (`AutocompleteDropdown`) renders the options with `SearchItems` (react-window) → `@synerise/ds-list-item` `ListItem`s inside `@synerise/ds-scrollbar`.
34
- - The component does **not** filter options — it renders `options` (or the children-derived list) as given; the consumer filters via `onSearch`.
32
+ - The trigger is a styled native `<input role="combobox">` (`S.NativeInput`), autosized via `useAutosizeWidth` from `@synerise/ds-input` (hidden `<span>` sizer).
33
+ - The dropdown is `@synerise/ds-dropdown` (`asChild`, `size="match-trigger"`), controlled via `open`/`onOpenChange`. **`trigger={[]}`** — Autocomplete owns the open state through the input's focus/click/change handlers, so ds-dropdown is used only for positioning + outside-dismiss and does **not** toggle on trigger click (delegating the toggle to ds-dropdown as well double-fired against the focus-open and flashed the panel open→closed on re-click). The overlay (`AutocompleteDropdown`) renders `@synerise/ds-list-item` `ListItem`s inside `@synerise/ds-scrollbar`.
34
+ - The dropdown is gated on having something to show (`hasDropdownContent`), but that gate must never feed back into the component's own open state — see the `dropdownOpen` note under Implementation notes.
35
+ - The component does **not** filter options by default — it renders `options` (or the children-derived list) as given and the consumer filters via `onSearch`. Opt into client-side filtering with `filterOption`.
35
36
  - Keyboard navigation is delegated to `@synerise/ds-dropdown`'s built-in floating-ui list navigation (`useListNavigation`): ArrowDown/Up move focus through the `ListItem`s and the focused item selects on Enter/click (ds-list-item's own keydown handler). Autocomplete does **not** keep a second manual highlight — that produced a duplicate active row offset from the real focused one. As antd parity for `defaultActiveFirstOption` (default `true`), an input-level Enter handler selects the first enabled option when Enter is pressed *before* arrow-navigating into the list (once arrowed, focus is on the item so that handler no longer fires).
36
- - `Autocomplete.Option` is a marker that renders `null`; `getOptionsFromChildren` reads `value`/`children`(→`label`) off direct `Option` children when `options` is not provided.
37
+ - `Autocomplete.Option` is a marker that renders `null`; `getOptionsFromChildren` reads `value`/`children`(→`label`) off `Option` children when `options` is not provided. Recognition does **not** rely on reference equality — see Implementation notes.
37
38
 
38
39
  ## Public exports
39
40
 
@@ -167,13 +168,14 @@ The dropdown overlay keeps class `ds-autocomplete-dropdown ps__child--consume` (
167
168
  ## Key dependencies
168
169
 
169
170
  - `@synerise/ds-dropdown` — floating dropdown wrapper (`open`/`onOpenChange`/`placement`/`overlay`/`asChild`/`trigger`/`getPopupContainer`/`size="match-trigger"`)
170
- - `@synerise/ds-search` — `SearchItems` (react-window virtualised list) renders the option rows
171
171
  - `@synerise/ds-list-item` — `ListItem` rows + `ListItemProps`
172
172
  - `@synerise/ds-scrollbar` — `Scrollbar` wrapping the option list
173
173
  - `@synerise/ds-form-field` — provides label, description, error, tooltip layout
174
- - `@synerise/ds-input` — provides `useAutosizeWidth`, `useStretchToFit`, `SIZER_STYLE`, `autoresizeConfObjToCss`
174
+ - `@synerise/ds-input` — provides `useAutosizeWidth`, `SIZER_STYLE`, `autoresizeConfObjToCss`
175
175
  - `@synerise/ds-tooltip` — wraps `icon1`/`icon2` when tooltip props are provided
176
- - `@synerise/ds-utils` — used transitively (no direct import in the rewrite)
176
+
177
+ > `@synerise/ds-search` and `@synerise/ds-utils` are still declared in `package.json` but no longer
178
+ > imported — the rewrite dropped the `SearchItems`/react-window overlay in favour of plain `ListItem`s.
177
179
 
178
180
  > **No `antd` dependency** — antd was removed from `peerDependencies` and from all `src` imports/`.ant-*` selectors as part of the antd-removal effort.
179
181
 
@@ -183,7 +185,10 @@ The dropdown overlay keeps class `ds-autocomplete-dropdown ps__child--consume` (
183
185
  - **Icon count adjusts input padding** — When icons are present, `NativeInput` adds right-padding `getIconsWidth(iconCount)` so the value never overlaps the icons. Clicking the icons focuses the input (`handleIconsClick`).
184
186
  - **Controlled visibility** — `open` + `onDropdownVisibleChange` map directly to `ds-dropdown`'s `open`/`onOpenChange`. When `open` is omitted, internal state drives it; focus/typing/click **opens** (never toggles), select/Escape/outside-click closes. Opening is idempotent (each handler only calls `setOpen(true)` when closed), so no single interaction can open-then-close.
185
187
  - **Keyboard nav** — delegated to `@synerise/ds-dropdown`'s floating-ui list navigation (not a local `activeIndex`). ArrowDown/Up move DOM focus through the `ListItem`s; the focused item selects on Enter/click via ds-list-item's own keydown. `defaultActiveFirstOption` (default `true`) adds an input-level Enter handler that selects the first enabled option when Enter is pressed before arrow-navigating (once focus is on an item, that handler no longer fires — no double-select).
186
- - **Options precedence** — the `options` prop wins; only when it's empty/absent are `<Autocomplete.Option>` children parsed (`getOptionsFromChildren`).
188
+ - **Options precedence** — the `options` prop wins; only when it's empty/absent are `<Autocomplete.Option>` children parsed (`getOptionsFromChildren`). Note that an explicit `options={[]}` therefore falls *through* to children; several internal callers (`ds-form` `EditableList`, `ds-factors` `FactorValue/Text`) spread an opaque props bag alongside children, so this precedence is deliberate.
189
+ - **Option children are matched structurally, not by reference** — `getOptionsFromChildren` accepts a child whose type is `Option`, carries the static `isAutocompleteOption` flag, or has `displayName === 'Autocomplete.Option'`, and it descends into Fragments and nested arrays. Reference equality alone silently dropped every option whenever the consumer resolved a second copy of the package, mocked the module, or wrapped `Option` in `memo`. An option declared with only a `key` falls back to that key as its `value` (antd/rc-select parity).
190
+ - **The overlay height is measured, never assumed** — `AutocompleteDropdown` caps the scroll area at the summed height of the first `visibleRows` *rendered* rows, and applies no cap at all when there are fewer options than that. A row is content-sized (`label` accepts any `ReactNode`, so an avatar or a second line makes it far taller than a plain text row), so the previous fixed `rowHeight * count` cap clipped any list whose options rendered taller than 32px — a single match showed as a sliced row.
191
+ - **The content gate must not write back into `isOpen`** — the dropdown is opened with `dropdownOpen = isOpen && hasDropdownContent`, but `onOpenChange` goes to `handleDropdownOpenChange`, which drops any callback carrying the value we just passed down. `ds-dropdown`'s `useDropdownVisibility` echoes a controlled `open` change straight back through `onOpenChange`; honouring that echo latched the panel shut on server-side search (the request empties the options, the echo closes the component, and the arriving response can no longer reopen it). Only callbacks that *disagree* with `dropdownOpen` — i.e. genuine dismissals — reach `setOpen`.
187
192
  - **`handleInputRef` is native now** — receives `MutableRefObject<AutocompleteInputHandle | null>` where `AutocompleteInputHandle = { focus, blur, input }`, replacing the antd `RefSelectProps`.
188
193
  - **`autoResize`** — `useAutosizeWidth` writes the content-box width onto the `<input>`; `useStretchToFit` clamps `max-width` to the wrapper when `stretchToFit` is set (capturing/restoring `scrollLeft`). `AUTOSIZE_EXTRA_WIDTH = 27` (+ icon width) is the `extraWidth`.
189
194
  - **`getPopupContainer` defaults to parent node** — `getParentNode` mounts the dropdown as a sibling of the trigger (not `document.body`), avoiding z-index stacking issues.
@@ -5,6 +5,7 @@ declare const Autocomplete: {
5
5
  Option: {
6
6
  (_props: import('./Option').OptionProps): null;
7
7
  displayName: string;
8
+ isAutocompleteOption: boolean;
8
9
  };
9
10
  };
10
11
  export default Autocomplete;
@@ -179,11 +179,18 @@ const Autocomplete = ({
179
179
  }, [handleIconsClick, icon1, icon2, icon1Tooltip, icon2Tooltip]);
180
180
  const showClear = !!allowClear && !isDisabled && currentValue !== "";
181
181
  const hasDropdownContent = displayedOptions.length > 0 || notFoundContent !== void 0 && notFoundContent !== null;
182
+ const dropdownOpen = isOpen && hasDropdownContent;
183
+ const handleDropdownOpenChange = useCallback((nextOpen) => {
184
+ if (nextOpen === dropdownOpen) {
185
+ return;
186
+ }
187
+ setOpen(nextOpen);
188
+ }, [dropdownOpen, setOpen]);
182
189
  return /* @__PURE__ */ jsx(AutocompleteWrapper, { autoResize, style, className: `ds-autocomplete ${className || ""}`, children: /* @__PURE__ */ jsx(FormField, { label, tooltip, tooltipConfig, description, errorText, id, children: /* @__PURE__ */ jsx(ComponentWrapper, { readOnly, error: !!errorText || error, iconCount, children: /* @__PURE__ */ jsx(
183
190
  Dropdown,
184
191
  {
185
- open: isOpen && hasDropdownContent,
186
- onOpenChange: setOpen,
192
+ open: dropdownOpen,
193
+ onOpenChange: handleDropdownOpenChange,
187
194
  disabled: isDisabled,
188
195
  placement,
189
196
  size: "match-trigger",
@@ -1,4 +1,4 @@
1
1
  import { default as React } from 'react';
2
2
  import { AutocompleteDropdownProps } from './AutocompleteDropdown.types';
3
- declare const AutocompleteDropdown: ({ options, notFoundContent, visibleRows, rowHeight, onSelect, }: AutocompleteDropdownProps) => React.JSX.Element;
3
+ declare const AutocompleteDropdown: ({ options, notFoundContent, visibleRows, onSelect, }: AutocompleteDropdownProps) => React.JSX.Element;
4
4
  export default AutocompleteDropdown;
@@ -1,20 +1,25 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
+ import { useRef, useState, useLayoutEffect } from "react";
2
3
  import Scrollbar from "@synerise/ds-scrollbar";
3
4
  import { DropdownWrapper, NotFound, ScrollList, Inner, ListItem } from "./AutocompleteDropdown.style.js";
4
- const DEFAULT_ROW_HEIGHT = 32;
5
5
  const DEFAULT_VISIBLE_ROWS = 10;
6
6
  const AutocompleteDropdown = ({
7
7
  options,
8
8
  notFoundContent,
9
9
  visibleRows = DEFAULT_VISIBLE_ROWS,
10
- rowHeight = DEFAULT_ROW_HEIGHT,
11
10
  onSelect
12
11
  }) => {
12
+ const innerRef = useRef(null);
13
+ const [maxHeight, setMaxHeight] = useState(void 0);
14
+ useLayoutEffect(() => {
15
+ const rows = Array.from(innerRef.current?.children ?? []);
16
+ const next = rows.length > visibleRows ? rows.slice(0, visibleRows).reduce((total, row) => total + row.offsetHeight, 0) : void 0;
17
+ setMaxHeight((current) => current === next ? current : next);
18
+ }, [options, visibleRows]);
13
19
  if (options.length === 0) {
14
20
  return /* @__PURE__ */ jsx(DropdownWrapper, { children: /* @__PURE__ */ jsx(NotFound, { "data-testid": "autocomplete-not-found", children: notFoundContent }) });
15
21
  }
16
- const maxHeight = Math.min(visibleRows, options.length) * rowHeight;
17
- return /* @__PURE__ */ jsx(DropdownWrapper, { children: /* @__PURE__ */ jsx(ScrollList, { children: /* @__PURE__ */ jsx(Scrollbar, { absolute: true, maxHeight, children: /* @__PURE__ */ jsx(Inner, { children: options.map((option) => /* @__PURE__ */ jsx(ListItem, { role: "option", "data-testid": "autocomplete-option", text: option.label ?? option.value, disabled: option.disabled, onClick: () => {
22
+ return /* @__PURE__ */ jsx(DropdownWrapper, { children: /* @__PURE__ */ jsx(ScrollList, { children: /* @__PURE__ */ jsx(Scrollbar, { absolute: true, maxHeight, children: /* @__PURE__ */ jsx(Inner, { ref: innerRef, children: options.map((option) => /* @__PURE__ */ jsx(ListItem, { role: "option", "data-testid": "autocomplete-option", text: option.label ?? option.value, disabled: option.disabled, onClick: () => {
18
23
  if (!option.disabled) {
19
24
  onSelect(option.value);
20
25
  }
@@ -4,6 +4,5 @@ export type AutocompleteDropdownProps = {
4
4
  options: AutocompleteOption[];
5
5
  notFoundContent?: ReactNode;
6
6
  visibleRows?: number;
7
- rowHeight?: number;
8
7
  onSelect: (value: string) => void;
9
8
  };
package/dist/Option.d.ts CHANGED
@@ -12,4 +12,5 @@ export type OptionProps = {
12
12
  export declare const Option: {
13
13
  (_props: OptionProps): null;
14
14
  displayName: string;
15
+ isAutocompleteOption: boolean;
15
16
  };
package/dist/Option.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const Option = (_props) => null;
2
2
  Option.displayName = "Autocomplete.Option";
3
+ Option.isAutocompleteOption = true;
3
4
  export {
4
5
  Option
5
6
  };
@@ -1,7 +1,7 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { AutocompleteOption } from '../Autocomplete.types';
3
3
  /**
4
- * Maps declarative `<Autocomplete.Option>` children to the internal option
5
- * shape. Only direct `Option` children are considered; anything else is ignored.
4
+ * Maps declarative `<Autocomplete.Option>` children to the internal option shape.
5
+ * Fragments and nested arrays are traversed; anything else is ignored.
6
6
  */
7
7
  export declare const getOptionsFromChildren: (children: ReactNode) => AutocompleteOption[];
@@ -1,22 +1,42 @@
1
- import { Children, isValidElement } from "react";
1
+ import { Children, isValidElement, Fragment } from "react";
2
2
  import { Option } from "../Option.js";
3
+ const isOptionElement = (child) => {
4
+ if (child.type === Option) {
5
+ return true;
6
+ }
7
+ const marker = child.type;
8
+ return marker?.isAutocompleteOption === true || marker?.displayName === Option.displayName;
9
+ };
3
10
  const getOptionsFromChildren = (children) => {
4
11
  const options = [];
5
- Children.forEach(children, (child) => {
6
- if (!isValidElement(child) || child.type !== Option) {
7
- return;
8
- }
9
- const {
10
- value,
11
- disabled,
12
- children: label
13
- } = child.props;
14
- options.push({
15
- value,
16
- disabled,
17
- label
12
+ const collect = (nodes) => {
13
+ Children.forEach(nodes, (child) => {
14
+ if (!isValidElement(child)) {
15
+ return;
16
+ }
17
+ if (child.type === Fragment) {
18
+ collect(child.props.children);
19
+ return;
20
+ }
21
+ if (!isOptionElement(child)) {
22
+ return;
23
+ }
24
+ const {
25
+ value,
26
+ disabled,
27
+ children: label
28
+ } = child.props;
29
+ if (value === void 0 && child.key === null) {
30
+ return;
31
+ }
32
+ options.push({
33
+ value: value !== void 0 ? value : String(child.key),
34
+ disabled,
35
+ label
36
+ });
18
37
  });
19
- });
38
+ };
39
+ collect(children);
20
40
  return options;
21
41
  };
22
42
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synerise/ds-autocomplete",
3
- "version": "1.2.53",
3
+ "version": "1.2.55",
4
4
  "description": "Autocomplete UI Component for the Synerise Design System",
5
5
  "license": "ISC",
6
6
  "repository": "Synerise/synerise-design",
@@ -42,12 +42,12 @@
42
42
  ],
43
43
  "types": "dist/index.d.ts",
44
44
  "dependencies": {
45
- "@synerise/ds-dropdown": "^1.3.22",
45
+ "@synerise/ds-dropdown": "^1.3.23",
46
46
  "@synerise/ds-form-field": "^1.3.25",
47
- "@synerise/ds-input": "^1.8.0",
48
- "@synerise/ds-list-item": "^1.6.3",
47
+ "@synerise/ds-input": "^1.8.1",
48
+ "@synerise/ds-list-item": "^1.6.4",
49
49
  "@synerise/ds-scrollbar": "^1.5.2",
50
- "@synerise/ds-search": "^1.5.35",
50
+ "@synerise/ds-search": "^1.5.37",
51
51
  "@synerise/ds-tooltip": "^1.5.5",
52
52
  "@synerise/ds-utils": "^1.10.2"
53
53
  },
@@ -57,5 +57,5 @@
57
57
  "styled-components": "^5.3.3",
58
58
  "vitest": "4"
59
59
  },
60
- "gitHead": "6df24ed12cd5f276d8eccde730591807982e3385"
60
+ "gitHead": "fbaec1b62812804ecab17db27553c2071c7bad49"
61
61
  }