@rowsncolumns/ui 16.0.3 → 16.0.5

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 +1 @@
1
- {"version":3,"file":"use-dropdown.d.ts","sourceRoot":"","sources":["../../../hooks/use-dropdown.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAK/B,OAAO,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,KAAK,gBAAgB,CAAC,CAAC,IAAI;IACzB,KAAK,EAAE,CAAC,EAAE,GAAG,SAAS,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,OAAO,CAAC;IACrB,uBAAuB,EAAE,MAAM,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,CAAC,EAAE,CAAC;IACrB,YAAY,CAAC,IAAI,EAAE,CAAC,GAAG,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC;CACnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,UAAU,EACnD,GAAG,KAAK,CAAC,CAAC,CAAC,EACX,GAAG,KAAK,CAAC,CAAC,CAAC,eAQZ,CAAC;AAuDF,eAAO,MAAM,qBAAqB,GAAI,OAAO,MAAM,WACtB,CAAC;AAE9B,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,UAAU,EAAE,2JAY/C,gBAAgB,CAAC,CAAC,CAAC;;;mBAgGd,KAAK,CAAC,aAAa,CAAC,cAAc,CAAC;sBAd/B,CAAC;;;sBAoGgC,MAAM;;;;CAoBlD,CAAC"}
1
+ {"version":3,"file":"use-dropdown.d.ts","sourceRoot":"","sources":["../../../hooks/use-dropdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;AAExD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAGxD,KAAK,gBAAgB,CAAC,CAAC,IAAI;IACzB,KAAK,EAAE,CAAC,EAAE,GAAG,SAAS,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB,EAAE,MAAM,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,CAAC,EAAE,CAAC;IACrB,gFAAgF;IAChF,YAAY,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC;CACnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,UAAU,EACnD,GAAG,KAAK,CAAC,CAAC,CAAC,EACX,GAAG,KAAK,CAAC,CAAC,CAAC,eAQZ,CAAC;AAuDF,eAAO,MAAM,qBAAqB,GAAI,OAAO,MAAM,WACtB,CAAC;AAK9B,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,UAAU,EAAE,gIAU/C,gBAAgB,CAAC,CAAC,CAAC;;;mBA8Jd,KAAK,CAAC,aAAa,CAAC,cAAc,CAAC;sBAd/B,CAAC;;;sBA2EgC,MAAM;;;;CAoBlD,CAAC"}
@@ -1,6 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts";
3
1
  import { matchSorter } from "match-sorter";
2
+ import { useCallback, useMemo, useRef, useState } from "react";
3
+ import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.ts";
4
4
  export const sortByUnderscore = (a, b) => {
5
5
  const aUnderscore = String(a?.title)?.startsWith("_");
6
6
  const bUnderscore = String(b?.title)?.startsWith("_");
@@ -58,34 +58,50 @@ const reorderByPriority = (list) => {
58
58
  ];
59
59
  };
60
60
  export const sanitizeDropdownQuery = (value) => value.replace(/["'`]/g, "");
61
- export const useDropdown = ({ initialOpen, initialHighlightedIndex, initialQuery, onChange, items = [], isNewValue, itemToString, limit = 10, showAllIfEmpty, itemsWhenEmpty, isItemSelected, }) => {
62
- const [isOpen, setIsOpen] = useState(initialOpen);
61
+ const areSameItems = (a, b) => a === b || (a.length === b.length && a.every((item, i) => item === b[i]));
62
+ export const useDropdown = ({ initialHighlightedIndex, initialQuery, onChange, items = [], isNewValue, limit = 10, showAllIfEmpty, itemsWhenEmpty, isItemSelected, }) => {
63
63
  const [query, setQuery] = useState(initialQuery ?? "");
64
64
  const [highlightedIndex, setHighlightedIndex] = useState(initialHighlightedIndex);
65
- const openMenu = useCallback(() => setIsOpen(true), []);
66
- const closeMenu = useCallback(() => setIsOpen(false), []);
65
+ // The item set the menu was dismissed (Escape) for. The menu is open exactly
66
+ // when it has items and was not dismissed for that set; any change to the
67
+ // set (a keystroke re-filtering it) or an explicit `openMenu` lifts the
68
+ // dismissal.
69
+ const [dismissedItems, setDismissedItems] = useState(null);
67
70
  // Refs
68
71
  const isDirty = useRef(isNewValue);
69
- const initialQueryRef = useRef(undefined);
72
+ const initialQueryRef = useRef(initialQuery ?? "");
70
73
  const highlightedIndexRef = useRef(undefined);
71
74
  const queryRef = useRef(undefined);
72
- const filteredItemsRef = useRef(undefined);
73
75
  useIsomorphicLayoutEffect(() => {
74
76
  queryRef.current = query;
75
77
  }, [query]);
76
78
  useIsomorphicLayoutEffect(() => {
77
79
  highlightedIndexRef.current = highlightedIndex;
78
80
  }, [highlightedIndex]);
79
- useIsomorphicLayoutEffect(() => {
81
+ // Re-seed for a new editing session. The hosting editor stays mounted
82
+ // (hidden) between sessions, so the query must follow `initialQuery` when a
83
+ // new session starts — otherwise the previous session's filter narrows the
84
+ // next dropdown open.
85
+ //
86
+ // Derived during render (React re-runs the component before committing),
87
+ // deliberately NOT from an effect. The in-cell editor passes the live cell
88
+ // text as `initialQuery`, so an effect here ran after every keystroke and
89
+ // scheduled a second render from the commit phase; together with the
90
+ // open/close effect below that left a render pending at the tail of every
91
+ // commit, which in a typing burst never let React's nested-update counter
92
+ // reset and threw "Maximum update depth exceeded" (Minified React error
93
+ // #185) — dropping the keystroke whose update threw.
94
+ const [seed, setSeed] = useState(() => ({ initialQuery, isNewValue }));
95
+ if (seed.initialQuery !== initialQuery || seed.isNewValue !== isNewValue) {
96
+ setSeed({ initialQuery, isNewValue });
80
97
  initialQueryRef.current = initialQuery ?? "";
81
98
  isDirty.current = Boolean(isNewValue);
82
- // The hosting editor stays mounted (hidden) between editing sessions, so
83
- // the query must be re-seeded when a new session starts — otherwise the
84
- // previous session's filter narrows the next dropdown open.
85
- setQuery(initialQuery ?? "");
86
- }, [initialQuery, isNewValue]);
99
+ if (query !== (initialQuery ?? "")) {
100
+ setQuery(initialQuery ?? "");
101
+ }
102
+ }
87
103
  // Filtered items
88
- const filteredItems = useMemo(() => {
104
+ const computedFilteredItems = useMemo(() => {
89
105
  if (!query) {
90
106
  if (itemsWhenEmpty?.length) {
91
107
  return reorderByPriority([...itemsWhenEmpty]);
@@ -121,10 +137,43 @@ export const useDropdown = ({ initialOpen, initialHighlightedIndex, initialQuery
121
137
  baseSort: dropdownBaseSort,
122
138
  });
123
139
  return reorderByPriority(sorted).slice(0, limit);
124
- }, [items, query, limit, itemToString, showAllIfEmpty, itemsWhenEmpty]);
125
- useIsomorphicLayoutEffect(() => {
126
- filteredItemsRef.current = filteredItems;
127
- }, [filteredItems]);
140
+ }, [items, query, limit, showAllIfEmpty, itemsWhenEmpty]);
141
+ // Identity follows content: the open state and the highlight bookkeeping
142
+ // below key off "the item set changed", and a caller re-creating `items`
143
+ // per render must not read as a change (it would reopen a dismissed menu
144
+ // on every render and never let the render-phase derivations settle).
145
+ const filteredItemsRef = useRef(computedFilteredItems);
146
+ const filteredItems = areSameItems(filteredItemsRef.current, computedFilteredItems)
147
+ ? filteredItemsRef.current
148
+ : computedFilteredItems;
149
+ filteredItemsRef.current = filteredItems;
150
+ const isOpen = filteredItems.length > 0 && dismissedItems !== filteredItems;
151
+ const openMenu = useCallback(() => setDismissedItems(null), []);
152
+ const closeMenu = useCallback(() => setDismissedItems(filteredItemsRef.current), []);
153
+ // Highlight bookkeeping for a changed query / item set, derived during
154
+ // render for the same reason as the re-seed above (an effect-scheduled
155
+ // setState per keystroke): clearing the query drops the highlight, and a
156
+ // pristine session pre-selects the item matching the current value (e.g. a
157
+ // data-validation dropdown highlighting the cell's current entry — Excel
158
+ // behavior); once the user types, the filter owns the highlight. The
159
+ // pre-select wins over the clear, as the former effect ordering did.
160
+ const [highlightSync, setHighlightSync] = useState(null);
161
+ if (highlightSync === null ||
162
+ highlightSync.query !== query ||
163
+ highlightSync.filteredItems !== filteredItems ||
164
+ highlightSync.isItemSelected !== isItemSelected) {
165
+ setHighlightSync({ query, filteredItems, isItemSelected });
166
+ let nextHighlightedIndex = highlightedIndex;
167
+ if (!query) {
168
+ nextHighlightedIndex = -1;
169
+ }
170
+ if (isItemSelected && !isDirty.current) {
171
+ nextHighlightedIndex = filteredItems.findIndex(isItemSelected);
172
+ }
173
+ if (nextHighlightedIndex !== highlightedIndex) {
174
+ setHighlightedIndex(nextHighlightedIndex);
175
+ }
176
+ }
128
177
  // User selects a value from the dropdown
129
178
  const onSelect = useCallback((value) => {
130
179
  onChange?.(value);
@@ -190,30 +239,6 @@ export const useDropdown = ({ initialOpen, initialHighlightedIndex, initialQuery
190
239
  }
191
240
  }
192
241
  }, [isOpen, openMenu, onSelect, closeMenu]);
193
- useEffect(() => {
194
- if (filteredItems.length) {
195
- openMenu();
196
- }
197
- else {
198
- closeMenu();
199
- }
200
- // If user clears the
201
- if (!query) {
202
- setHighlightedIndex(-1);
203
- }
204
- }, [query, filteredItems, closeMenu, openMenu]);
205
- // Pre-select the item matching the current value (e.g. a data-validation
206
- // dropdown highlighting the cell's current entry — Excel behavior). Runs
207
- // only while the session is pristine (`isDirty` flips on the first typed
208
- // change, handing the highlight to the filter). Declared AFTER the reset
209
- // effect above so the seed wins the mount-time ordering when the query is
210
- // empty (a blank cell selecting the list's empty/text-prefix entry).
211
- useEffect(() => {
212
- if (!isItemSelected || isDirty.current) {
213
- return;
214
- }
215
- setHighlightedIndex(filteredItems.findIndex(isItemSelected));
216
- }, [filteredItems, isItemSelected]);
217
242
  const handleSetQuery = useCallback((value) => {
218
243
  setQuery(value);
219
244
  setHighlightedIndex(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rowsncolumns/ui",
3
- "version": "16.0.3",
3
+ "version": "16.0.5",
4
4
  "author": "Vinay M",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "main": "./dist/cjs/index.js",
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "dependencies": {
34
34
  "@radix-ui/react-dialog": "^1.1.14",
35
- "@radix-ui/react-dropdown-menu": "^2.1.15",
35
+ "@radix-ui/react-dropdown-menu": "2.1.16",
36
36
  "@radix-ui/react-popover": "^1.1.14",
37
37
  "@radix-ui/react-progress": "^1.1.7",
38
38
  "@radix-ui/react-select": "^2.2.5",
@@ -63,7 +63,7 @@
63
63
  "@rowsncolumns/functions": ">=15.0.2",
64
64
  "@rowsncolumns/grid": ">=13.2.35",
65
65
  "@rowsncolumns/icons": ">=7.2.33",
66
- "@rowsncolumns/utils": ">=9.5.1",
66
+ "@rowsncolumns/utils": ">=9.5.2",
67
67
  "@types/lodash": "^4.17.17",
68
68
  "react": "^19.1.0",
69
69
  "react-dom": "^19.1.0"