@pushwoosh/dumb-components 1.1.145 → 1.1.146

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.
@@ -1,4 +1,25 @@
1
1
  import { type ReactElement } from 'react';
2
2
  import { type FreeComboboxProps, type SelectComboboxProps } from './types';
3
+ /**
4
+ * Free-text combobox: an input with optional sync or async suggestions. `value`
5
+ * is the string; `onChange` fires per keystroke and on pick (see `meta.source`).
6
+ *
7
+ * @example
8
+ * <Combobox value={text} onChange={setText} items={fruits} placeholder="Fruit" clearable />
9
+ */
3
10
  export declare function Combobox(props: FreeComboboxProps): ReactElement;
11
+ /**
12
+ * Strict single-select combobox: pick one of `items` (or clear to `null`).
13
+ * Pass `select` to opt in; object items also need `getKey`.
14
+ *
15
+ * @example
16
+ * <Combobox<User>
17
+ * select
18
+ * items={users}
19
+ * value={user}
20
+ * onChange={setUser}
21
+ * getLabel={(u) => u.name}
22
+ * getKey={(u) => u.id}
23
+ * />
24
+ */
4
25
  export declare function Combobox<T>(props: SelectComboboxProps<T>): ReactElement;
@@ -1,47 +1,89 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { DropdownText, GetKeyProp, ItemState, LoadItems } from '../shared/dropdownField';
3
3
  type ComboboxSource<T> = {
4
+ /** Static list of suggestions. */
4
5
  items?: T[];
5
6
  loadItems?: never;
6
7
  debounceMs?: never;
7
8
  minChars?: never;
8
9
  } | {
9
10
  items?: never;
11
+ /** Async loader; receives the query and a zero-based page index, returns one page. */
10
12
  loadItems: LoadItems<T>;
13
+ /** Debounce (ms) before calling `loadItems` after the query changes. */
11
14
  debounceMs?: number;
15
+ /** Don't call `loadItems` until the query has at least this many characters. */
12
16
  minChars?: number;
13
17
  };
18
+ /**
19
+ * Origin of a free-mode `onChange`:
20
+ * - `'input'` — the user typed (or cleared the field);
21
+ * - `'select'` — the user picked a suggestion from the dropdown.
22
+ */
14
23
  export type ComboboxChangeSource = 'input' | 'select';
24
+ /** Second argument of free-mode `onChange`, describing what caused the change. */
15
25
  export type ComboboxChangeMeta = {
16
26
  source: ComboboxChangeSource;
17
27
  };
18
28
  type CommonProps<T> = {
29
+ /** Predicate for filtering `items` by the query (default: case-insensitive label match). Sync items only. */
19
30
  filter?: (item: T, query: string) => boolean;
31
+ /** Render a custom option row; receives the item and its {@link ItemState}. */
20
32
  renderItem?: (item: T, state: ItemState) => ReactNode;
33
+ /** Content shown when no options match. A node, or `(query) => node`. */
21
34
  emptyText?: DropdownText;
35
+ /** Content shown while async items load. A node, or `(query) => node`. Defaults to "Loading…". */
22
36
  loadingText?: DropdownText;
37
+ /** Called whenever the dropdown closes for any reason (Escape, click-outside, blur, selection). */
23
38
  onClose?: () => void;
39
+ /** Focus the input and open the dropdown on mount. */
24
40
  autoFocus?: boolean;
41
+ /** Placeholder shown when the field is empty. */
25
42
  placeholder?: string;
43
+ /** Disable the field. */
26
44
  disabled?: boolean;
45
+ /** Force the loading state (on top of async loading). */
27
46
  loading?: boolean;
47
+ /** Show a clear (✕) button when there is a value. */
28
48
  clearable?: boolean;
49
+ /** Size the field to its content instead of filling the parent. */
29
50
  autosize?: boolean;
51
+ /** Render the error (red) state. */
30
52
  hasError?: boolean;
53
+ /** Fixed CSS width (e.g. `"240px"`); overrides the default parent-driven / autosize width. */
31
54
  width?: string;
55
+ /** Minimum CSS width, useful together with `autosize`. */
32
56
  minWidth?: string;
33
57
  };
58
+ /**
59
+ * Free-text Combobox: the value is the typed string and `items`/`loadItems`
60
+ * are optional suggestions. `onChange` fires on every keystroke and on picking
61
+ * a suggestion — tell them apart via `meta.source`.
62
+ */
34
63
  export type FreeComboboxProps = ComboboxSource<string> & CommonProps<string> & {
64
+ /** Free-text mode (the default). */
35
65
  select?: false;
66
+ /** Current text value (controlled). */
36
67
  value: string;
68
+ /** Fires on every change; `meta.source` is `'input'` (typed/cleared) or `'select'` (picked a suggestion). */
37
69
  onChange: (value: string, meta: ComboboxChangeMeta) => void;
70
+ /** Map a suggestion to the text inserted on pick (default: identity). */
38
71
  getLabel?: (item: string) => string;
39
72
  };
73
+ /**
74
+ * Strict single-select Combobox: the value is one of `items` (or `null`).
75
+ * Typing only filters — arbitrary text cannot be committed.
76
+ */
40
77
  export type SelectComboboxProps<T> = ComboboxSource<T> & GetKeyProp<T> & CommonProps<T> & {
78
+ /** Strict single-select mode. */
41
79
  select: true;
80
+ /** Currently selected item, or `null`. */
42
81
  value: T | null;
82
+ /** Fires when the selection changes (an item, or `null` when cleared). */
43
83
  onChange: (value: T | null) => void;
84
+ /** Map an item to its display label (required). */
44
85
  getLabel: (item: T) => string;
45
86
  };
87
+ /** Props of {@link Combobox} — free-text by default, strict single-select with `select`. */
46
88
  export type ComboboxProps<T> = FreeComboboxProps | SelectComboboxProps<T>;
47
89
  export {};
@@ -1,3 +1,16 @@
1
1
  import { type ReactElement } from 'react';
2
2
  import { type ComboboxMultiProps } from './types';
3
+ /**
4
+ * Multi-select combobox: pick several items shown as removable chips, with
5
+ * optional creation of new values. Sync `items` or async `loadItems`.
6
+ *
7
+ * @example
8
+ * <ComboboxMulti<string>
9
+ * items={fruits}
10
+ * value={selected}
11
+ * onChange={setSelected}
12
+ * getLabel={(s) => s}
13
+ * creatable
14
+ * />
15
+ */
3
16
  export declare function ComboboxMulti<T>({ items, loadItems, debounceMs, minChars, value, onChange, onClose, autoFocus, getLabel, getKey: getKeyProp, filter, renderItem, emptyText, loadingText, creatable, onCreate, canCreate, renderCreateLabel, maxValues, placeholder, disabled, loading, clearable, autosize, hasError, width, minWidth, }: ComboboxMultiProps<T>): ReactElement;
@@ -8,6 +8,19 @@ function defaultCanCreate(query, items, getLabel) {
8
8
  const lower = trimmed.toLowerCase();
9
9
  return !items.some(item => getLabel(item).toLowerCase() === lower);
10
10
  }
11
+ /**
12
+ * Multi-select combobox: pick several items shown as removable chips, with
13
+ * optional creation of new values. Sync `items` or async `loadItems`.
14
+ *
15
+ * @example
16
+ * <ComboboxMulti<string>
17
+ * items={fruits}
18
+ * value={selected}
19
+ * onChange={setSelected}
20
+ * getLabel={(s) => s}
21
+ * creatable
22
+ * />
23
+ */
11
24
  export function ComboboxMulti({
12
25
  items,
13
26
  loadItems,
@@ -1,5 +1,10 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { DropdownText, GetKeyProp, ItemState, ItemsSource } from '../shared/dropdownField';
3
+ /**
4
+ * Whether new values can be created from the typed query. For `string` items
5
+ * `onCreate` is optional (the trimmed query is used); for object items, enabling
6
+ * `creatable` requires `onCreate` to build the item.
7
+ */
3
8
  type CreatableProp<T> = [T] extends [string] ? {
4
9
  creatable?: boolean;
5
10
  onCreate?: (query: string) => T;
@@ -10,26 +15,47 @@ type CreatableProp<T> = [T] extends [string] ? {
10
15
  creatable: true;
11
16
  onCreate: (query: string) => T;
12
17
  };
18
+ /** Props of {@link ComboboxMulti} — multi-select with chips and optional create. */
13
19
  export type ComboboxMultiProps<T> = ItemsSource<T> & GetKeyProp<T> & CreatableProp<T> & {
20
+ /** Selected items (controlled), rendered as removable chips. */
14
21
  value: T[];
22
+ /** Fires with the new selection whenever an item is added or removed. */
15
23
  onChange: (value: T[]) => void;
24
+ /** Called whenever the dropdown closes for any reason (Escape, click-outside, blur). */
16
25
  onClose?: () => void;
26
+ /** Focus the input and open the dropdown on mount. */
17
27
  autoFocus?: boolean;
28
+ /** Map an item to its chip/label text (required). */
18
29
  getLabel: (item: T) => string;
30
+ /** Predicate for filtering `items` by the query (default: case-insensitive label match). Sync items only. */
19
31
  filter?: (item: T, query: string) => boolean;
32
+ /** Render a custom option row; receives the item and its {@link ItemState}. */
20
33
  renderItem?: (item: T, state: ItemState) => ReactNode;
34
+ /** Content shown when no options match. A node, or `(query) => node`. */
21
35
  emptyText?: DropdownText;
36
+ /** Content shown while async items load. A node, or `(query) => node`. Defaults to "Loading…". */
22
37
  loadingText?: DropdownText;
38
+ /** Override whether the current query can be created (default: non-empty and not a duplicate label). */
23
39
  canCreate?: (query: string, items: T[]) => boolean;
40
+ /** Render the label of the "create" row (default: `Create "query"`). */
24
41
  renderCreateLabel?: (query: string) => ReactNode;
42
+ /** Maximum number of selected items. */
25
43
  maxValues?: number;
44
+ /** Placeholder shown when there are no chips. */
26
45
  placeholder?: string;
46
+ /** Disable the field. */
27
47
  disabled?: boolean;
48
+ /** Force the loading state (on top of async loading). */
28
49
  loading?: boolean;
50
+ /** Show a "clear all" (✕) button when there are chips. */
29
51
  clearable?: boolean;
52
+ /** Size the field to its content instead of filling the parent. */
30
53
  autosize?: boolean;
54
+ /** Render the error (red) state. */
31
55
  hasError?: boolean;
56
+ /** Fixed CSS width (e.g. `"240px"`); overrides the default parent-driven / autosize width. */
32
57
  width?: string;
58
+ /** Minimum CSS width, useful together with `autosize`. */
33
59
  minWidth?: string;
34
60
  };
35
61
  export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { render } from '@testing-library/react';
2
+ import { createElement, Fragment } from 'react';
3
+ import { useLayer } from '../useLayer';
4
+ const GLOBAL_KEY = '__pushwoosh_dumb_components_layer_state__';
5
+ function resetLayerState() {
6
+ delete window[GLOBAL_KEY];
7
+ }
8
+ function Layer({
9
+ index,
10
+ zIndex
11
+ }) {
12
+ const resolved = useLayer({
13
+ isOpen: true,
14
+ zIndex
15
+ });
16
+ return createElement('div', {
17
+ 'data-testid': `layer-${index}`,
18
+ 'data-z': resolved.zIndex
19
+ });
20
+ }
21
+ function layersTree(zIndexes) {
22
+ const children = zIndexes.map((zIndex, index) => createElement(Layer, {
23
+ // eslint-disable-next-line react/no-array-index-key
24
+ key: index,
25
+ index,
26
+ zIndex
27
+ }));
28
+ return createElement(Fragment, null, ...children);
29
+ }
30
+ function readZIndexes(getByTestId, count) {
31
+ return Array.from({
32
+ length: count
33
+ }, (_, index) => Number(getByTestId(`layer-${index}`).getAttribute('data-z')));
34
+ }
35
+ function renderZIndexes(zIndexes) {
36
+ const {
37
+ getByTestId
38
+ } = render(layersTree(zIndexes));
39
+ return readZIndexes(getByTestId, zIndexes.length);
40
+ }
41
+ describe('useLayer z-index resolution', () => {
42
+ beforeEach(resetLayerState);
43
+ afterEach(resetLayerState);
44
+ it('puts a lone layer at the base z-index', () => {
45
+ expect(renderZIndexes([undefined])).toEqual([10000]);
46
+ });
47
+ it('stacks plain layers in registration order', () => {
48
+ expect(renderZIndexes([undefined, undefined])).toEqual([10000, 10100]);
49
+ });
50
+ it('keeps a layer opened on top of an explicit base above it', () => {
51
+ expect(renderZIndexes([100000, undefined])).toEqual([100000, 100100]);
52
+ });
53
+ it('propagates the base to every layer stacked above it', () => {
54
+ expect(renderZIndexes([100000, undefined, undefined])).toEqual([100000, 100100, 100200]);
55
+ });
56
+ it('renders a lone explicit base exactly at its value', () => {
57
+ expect(renderZIndexes([100000])).toEqual([100000]);
58
+ });
59
+ it('honors a higher base set by a layer above', () => {
60
+ expect(renderZIndexes([undefined, 999999])).toEqual([10000, 999999]);
61
+ });
62
+ it('re-resolves stacked layers when an open layer changes its base', () => {
63
+ const {
64
+ rerender,
65
+ getByTestId
66
+ } = render(layersTree([100000, undefined]));
67
+ expect(readZIndexes(getByTestId, 2)).toEqual([100000, 100100]);
68
+ rerender(layersTree([200000, undefined]));
69
+ expect(readZIndexes(getByTestId, 2)).toEqual([200000, 200100]);
70
+ });
71
+ });
package/hooks/useLayer.js CHANGED
@@ -67,8 +67,19 @@ function unregisterLayer(id) {
67
67
  teardownKeydownListener();
68
68
  notify();
69
69
  }
70
- function getDepth(id) {
71
- return getState().stack.findIndex(entry => entry.id === id);
70
+ function resolveZIndex(id, fallbackZIndex) {
71
+ const {
72
+ stack
73
+ } = getState();
74
+ let zIndexBelow = BASE_Z_INDEX - Z_INDEX_STEP;
75
+ for (const entry of stack) {
76
+ const layerZIndex = Math.max(entry.baseZIndex ?? 0, zIndexBelow + Z_INDEX_STEP);
77
+ if (entry.id === id) {
78
+ return layerZIndex;
79
+ }
80
+ zIndexBelow = layerZIndex;
81
+ }
82
+ return fallbackZIndex ?? BASE_Z_INDEX;
72
83
  }
73
84
  export function useLayer({
74
85
  isOpen,
@@ -78,6 +89,8 @@ export function useLayer({
78
89
  const id = useId();
79
90
  const onCloseRef = useRef(onClose);
80
91
  onCloseRef.current = onClose;
92
+ const zIndexRef = useRef(zIndex);
93
+ zIndexRef.current = zIndex;
81
94
  const [, forceRender] = useState(0);
82
95
  useEffect(() => {
83
96
  const subscriber = () => forceRender(value => value + 1);
@@ -95,13 +108,20 @@ export function useLayer({
95
108
  close: () => {
96
109
  var _onCloseRef$current;
97
110
  return (_onCloseRef$current = onCloseRef.current) === null || _onCloseRef$current === void 0 ? void 0 : _onCloseRef$current.call(onCloseRef);
98
- }
111
+ },
112
+ baseZIndex: zIndexRef.current
99
113
  });
100
114
  forceRender(value => value + 1);
101
115
  return () => unregisterLayer(id);
102
116
  }, [isOpen, id]);
103
- const depth = getDepth(id);
104
- const resolvedZIndex = zIndex ?? BASE_Z_INDEX + Math.max(depth, 0) * Z_INDEX_STEP;
117
+ useLayoutEffect(() => {
118
+ const entry = getState().stack.find(item => item.id === id);
119
+ if (entry && entry.baseZIndex !== zIndex) {
120
+ entry.baseZIndex = zIndex;
121
+ notify();
122
+ }
123
+ }, [zIndex, id]);
124
+ const resolvedZIndex = resolveZIndex(id, zIndex);
105
125
  return {
106
126
  zIndex: resolvedZIndex
107
127
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "1.1.145",
3
+ "version": "1.1.146",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -1,28 +1,57 @@
1
1
  import type { ReactNode } from 'react';
2
+ /**
3
+ * Dropdown text slot (e.g. `emptyText`, `loadingText`): either a static node,
4
+ * or a function of the current query so the message can include the search term.
5
+ */
2
6
  export type DropdownText = ReactNode | ((query: string) => ReactNode);
7
+ /** State of an option row, passed to a custom `renderItem`. */
3
8
  export type ItemState = {
9
+ /** The row is selected (its key matches the current value). */
4
10
  selected: boolean;
11
+ /** The row is the active, keyboard-highlighted one. */
5
12
  active: boolean;
6
13
  };
14
+ /**
15
+ * How to derive a stable key for an item. Optional for `string`/`number` items
16
+ * (the item is its own key); required for object items.
17
+ */
7
18
  export type GetKeyProp<T> = [T] extends [string | number] ? {
8
19
  getKey?: (item: T) => string | number;
9
20
  } : {
10
21
  getKey: (item: T) => string | number;
11
22
  };
23
+ /** One page returned by an async {@link LoadItems} loader. */
12
24
  export type LoadItemsResult<T> = {
25
+ /** Items for the requested page. */
13
26
  items: T[];
27
+ /**
28
+ * Whether more pages are available. Defaults to `false` when omitted, so
29
+ * paging continues only while a page explicitly returns `true`.
30
+ */
14
31
  hasMore?: boolean;
15
32
  };
33
+ /**
34
+ * Async item loader. Called with the current query and a zero-based page index;
35
+ * the component owns paging and accumulates results across pages.
36
+ */
16
37
  export type LoadItems<T> = (query: string, page: number) => Promise<LoadItemsResult<T>>;
38
+ /**
39
+ * Where options come from: a static `items` array, or an async `loadItems`
40
+ * loader — mutually exclusive.
41
+ */
17
42
  export type ItemsSource<T> = {
43
+ /** Static list of options. */
18
44
  items: T[];
19
45
  loadItems?: never;
20
46
  debounceMs?: never;
21
47
  minChars?: never;
22
48
  } | {
23
49
  items?: never;
50
+ /** Async loader; receives the query and page index, returns one page. */
24
51
  loadItems: LoadItems<T>;
52
+ /** Debounce (ms) before calling `loadItems` after the query changes. */
25
53
  debounceMs?: number;
54
+ /** Don't call `loadItems` until the query has at least this many characters. */
26
55
  minChars?: number;
27
56
  };
28
57
  export type DropdownItemDescriptor = {