@expofp/ui 3.39.0 → 3.39.1

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,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import './Autocomplete.scss';
3
3
  import classNames from 'classnames';
4
- import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { lazy, Suspense, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { ResizeObserver } from 'resize-observer';
7
7
  import useOnClickOutside from '../../shared/hooks/useOnClickOutside';
@@ -18,6 +18,9 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
18
18
  const refAutocomplete = useRef(null);
19
19
  const refSearchInput = useRef(null);
20
20
  const refOptions = useRef(null);
21
+ const virtuosoRef = useRef(null);
22
+ // State, not a ref: the list can only mount once its scroll parent exists.
23
+ const [scrollElement, setScrollElement] = useState(null);
21
24
  const blurTimeoutRef = useRef(null);
22
25
  const listboxId = useId();
23
26
  const getOptionId = (index) => `${listboxId}-option-${index}`;
@@ -109,10 +112,10 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
109
112
  return () => observer.disconnect();
110
113
  }, [inline, showOptionsDropdown, scrollContainer]);
111
114
  useEffect(() => {
112
- if (focusOptionIndex === null || !refOptions.current)
115
+ if (focusOptionIndex === null)
113
116
  return;
114
- const items = refOptions.current.querySelectorAll('li');
115
- items[focusOptionIndex]?.scrollIntoView({ block: 'nearest' });
117
+ // Through the list, not the DOM: a focused option far down is not rendered until scrolled to.
118
+ virtuosoRef.current?.scrollIntoView({ index: focusOptionIndex });
116
119
  }, [focusOptionIndex]);
117
120
  useOnClickOutside([refAutocomplete, refOptions], () => setShowOptionsDropdown(false), showOptionsDropdown);
118
121
  useEffect(() => {
@@ -215,12 +218,17 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
215
218
  if (searchText && filteredOptions.length === 0) {
216
219
  return _jsx("div", { className: "efp-autocomplete__empty", children: noOptionsLabel });
217
220
  }
218
- return (_jsx("ul", { role: "listbox", id: listboxId, children: currentOptions.map((option, index) => {
219
- const isFocused = index === focusOptionIndex;
220
- const isActive = filteredOptions.length === 0 && index === activeOptionIndex;
221
- const className = isFocused ? 'is-focus' : isActive ? 'is-active' : undefined;
222
- return (_jsx("li", { id: getOptionId(index), role: "option", "aria-selected": isActive, className: className, onMouseDown: (e) => e.preventDefault(), onClick: () => selectOption(option), children: getOptionLabel(option) }, getOptionKey(option)));
223
- }) }));
221
+ // Virtualized: a plan's booth list runs to thousands of options, and rendering, laying out and
222
+ // compositing every one of them froze the frame the dropdown opened in for over half a second.
223
+ if (!scrollElement)
224
+ return null;
225
+ const context = {
226
+ listboxId,
227
+ focusIndex: focusOptionIndex,
228
+ activeIndex: filteredOptions.length === 0 ? activeOptionIndex : null,
229
+ onSelect: (index) => selectOption(currentOptions[index]),
230
+ };
231
+ return (_jsx(Suspense, { fallback: null, children: _jsx(LazyVirtuoso, { ref: virtuosoRef, customScrollParent: scrollElement, data: currentOptions, context: context, computeItemKey: (_index, option) => getOptionKey(option), itemContent: (_index, option) => getOptionLabel(option), components: { List: OptionList, Item: OptionItem }, increaseViewportBy: 200 }) }));
224
232
  };
225
233
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: refAutocomplete, "data-no-drag": true, className: classNames('efp-autocomplete', {
226
234
  'is-open': showOptionsDropdown,
@@ -230,7 +238,7 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
230
238
  : undefined, onChange: onInputChange, onFocus: onFocus, onBlur: onBlur, onKeyDown: onKeyDown, value: displayValue, placeholder: placeholder, ref: refSearchInput }), showClear && showOptionsDropdown && displayValue && (_jsx("button", { type: "button", className: "efp-autocomplete__clear", "aria-label": clearLabel, onMouseDown: (e) => {
231
239
  e.preventDefault();
232
240
  clearValue(true);
233
- } }))] }), showOptionsDropdown && inline && (_jsx("div", { ref: refOptions, "data-no-drag": true, className: "efp-autocomplete-options efp-autocomplete-options--inline", style: inlineMaxHeight !== null ? { maxHeight: `${inlineMaxHeight}px` } : undefined, children: _jsx("div", { className: "efp-autocomplete-options__scroll", children: renderOptions() }) }))] }), showOptionsDropdown &&
241
+ } }))] }), showOptionsDropdown && inline && (_jsx("div", { ref: refOptions, "data-no-drag": true, className: "efp-autocomplete-options efp-autocomplete-options--inline", style: inlineMaxHeight !== null ? { maxHeight: `${inlineMaxHeight}px` } : undefined, children: _jsx("div", { className: "efp-autocomplete-options__scroll", ref: setScrollElement, children: renderOptions() }) }))] }), showOptionsDropdown &&
234
242
  !inline &&
235
243
  container &&
236
244
  createPortal(_jsx("div", { ref: refOptions, className: "efp-autocomplete-options", style: {
@@ -241,6 +249,40 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
241
249
  left: `${dropdownPosition.left}px`,
242
250
  width: `${dropdownPosition.width}px`,
243
251
  maxHeight: `${dropdownPosition.maxHeight}px`,
244
- }, children: _jsx("div", { className: "efp-autocomplete-options__scroll", children: renderOptions() }) }), container)] }));
252
+ }, children: _jsx("div", { className: "efp-autocomplete-options__scroll", ref: setScrollElement, children: renderOptions() }) }), container)] }));
245
253
  };
254
+ /**
255
+ * The listbox itself; Virtuoso positions it inside the scroll parent.
256
+ * @param props - Virtuoso's list props, plus the autocomplete's context.
257
+ * @returns The `ul`.
258
+ */
259
+ function OptionList({ context, ref, ...props }) {
260
+ // Virtuoso types its list element as a `div`; the listbox is a `ul`, which it handles the same.
261
+ return (_jsx("ul", { ...props, ref: ref, role: "listbox", id: context.listboxId }));
262
+ }
263
+ /**
264
+ * One option; Virtuoso renders only the ones in and near view.
265
+ * @param props - Virtuoso's item props, plus the autocomplete's context.
266
+ * @returns The `li`.
267
+ */
268
+ function OptionItem({ context, item: _item, ...props }) {
269
+ const index = props['data-index'];
270
+ const isFocused = index === context.focusIndex;
271
+ const isActive = !isFocused && index === context.activeIndex;
272
+ return (_jsx("li", { ...props, id: `${context.listboxId}-option-${index}`, role: "option", "aria-selected": index === context.activeIndex, className: isFocused ? 'is-focus' : isActive ? 'is-active' : undefined, onMouseDown: (e) => e.preventDefault(), onClick: () => context.onSelect(index) }));
273
+ }
274
+ /**
275
+ * Loads Virtuoso on first open rather than with the SDK's main chunk, as the floorplan's own lists
276
+ * do; the retry covers a chunk request lost to a flaky network.
277
+ * @returns The module, shaped for `lazy`.
278
+ */
279
+ function importVirtuoso() {
280
+ return import('react-virtuoso')
281
+ .then((mod) => ({ default: mod.Virtuoso }))
282
+ .catch(() => new Promise((resolve) => {
283
+ setTimeout(() => resolve(importVirtuoso()), 3000);
284
+ }));
285
+ }
286
+ // Typed as the generic component it resolves to; `lazy` alone erases the data and context types.
287
+ const LazyVirtuoso = lazy(importVirtuoso);
246
288
  export default Autocomplete;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/ui",
3
- "version": "3.39.0",
3
+ "version": "3.39.1",
4
4
  "type": "module",
5
5
  "description": "ExpoFP SDK internal: shared pure React UI components",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "classnames": "^2.2.6",
34
34
  "copy-to-clipboard": "^3.2.0",
35
+ "react-virtuoso": "^4.18.7",
35
36
  "react-zoom-pan-pinch-sr": "^0.2.0",
36
37
  "resize-observer": "^1.0.4",
37
38
  "swiper": "^9.4.1",