@jsenv/navi 0.27.83 → 0.27.85

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.
@@ -6788,10 +6788,7 @@ const TYPO_PROPS = {
6788
6788
  // combo from MDN's own text-box example, most useful for compact things
6789
6789
  // like buttons/badges/labels. Use textBox/textBoxTrim/textBoxEdge
6790
6790
  // directly for any other combination.
6791
- textBoxCrop: applyToCssPropWhenTruthy(
6792
- "textBox",
6793
- "trim-both cap alphabetic",
6794
- ),
6791
+ textBoxCrop: applyToCssPropWhenTruthy("textBox", "trim-both cap alphabetic"),
6795
6792
  };
6796
6793
  const VISUAL_PROPS = {
6797
6794
  outline: PASS_THROUGH,
@@ -6827,6 +6824,8 @@ const VISUAL_PROPS = {
6827
6824
  overflowY: PASS_THROUGH,
6828
6825
  objectFit: PASS_THROUGH,
6829
6826
  accentColor: PASS_THROUGH,
6827
+ scrollbarWidth: PASS_THROUGH,
6828
+ scrollbarGutter: PASS_THROUGH,
6830
6829
  };
6831
6830
  const CONTENT_PROPS = {
6832
6831
  align: applyOnTwoProps("alignX", "alignY"),
@@ -40526,7 +40525,11 @@ const createItemTracker = (onChange) => {
40526
40525
  }
40527
40526
 
40528
40527
  // Build allItems and visibleItems in a single pass over allOrderedKeys.
40529
- // Visible items are those without data.hidden — same relative order as orderedKeys.
40528
+ // Visible items are those without data.hidden or data.filtered — same
40529
+ // relative order as orderedKeys (syncItem already excludes both from
40530
+ // orderedKeys; this must match or consumers relying on visibleCountSignal
40531
+ // for virtual-scroll accounting, e.g. list.jsx's filler sizing, would
40532
+ // count filtered-out items as if they still took up space).
40530
40533
  const prevAllItems = itemsSignal.peek();
40531
40534
  const prevVisibleItems = visibleItemsSignal.peek();
40532
40535
  let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
@@ -40545,7 +40548,7 @@ const createItemTracker = (onChange) => {
40545
40548
  if (item.match === false) {
40546
40549
  newNoMatchCount++;
40547
40550
  }
40548
- if (!item.hidden) {
40551
+ if (!item.hidden && !item.filtered) {
40549
40552
  const visibleIdx = visibleItems.length;
40550
40553
  visibleItems.push(item);
40551
40554
  if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
@@ -41269,10 +41272,9 @@ const ListItemSelectable = props => {
41269
41272
  const {
41270
41273
  index,
41271
41274
  id = defaultId,
41272
- highlight,
41275
+ matchInfo,
41273
41276
  hidden,
41274
41277
  filtered,
41275
- matchScore,
41276
41278
  defaultSelected,
41277
41279
  selected,
41278
41280
  pointed,
@@ -41314,10 +41316,9 @@ const ListItemSelectable = props => {
41314
41316
  return jsxs(Next, {
41315
41317
  id: id,
41316
41318
  index: index,
41317
- highlight: highlight,
41319
+ matchInfo: matchInfo,
41318
41320
  filtered: filtered,
41319
41321
  hidden: hidden,
41320
- matchScore: matchScore,
41321
41322
  "aria-selected": checked,
41322
41323
  selected: checked,
41323
41324
  "navi-selectable": "",
@@ -41631,6 +41632,7 @@ const css$m = /* css */`
41631
41632
  flex-wrap: inherit;
41632
41633
  overflow: auto;
41633
41634
  overscroll-behavior: inherit; /* inherit select behavior */
41635
+ scrollbar-width: inherit;
41634
41636
  }
41635
41637
 
41636
41638
  &[data-expand-x] {
@@ -41922,7 +41924,7 @@ const ListUI = props => {
41922
41924
  import.meta.css = [css$m, "@jsenv/navi/src/control/list/list.jsx"];
41923
41925
  const {
41924
41926
  ref,
41925
- renderBudget = RENDER_BUDGET_DEFAULT,
41927
+ renderBudget: renderBudgetProp = RENDER_BUDGET_DEFAULT,
41926
41928
  renderBudgetSkipCheck,
41927
41929
  role,
41928
41930
  fallback,
@@ -41943,6 +41945,15 @@ const ListUI = props => {
41943
41945
  spacing,
41944
41946
  ...rest
41945
41947
  } = props;
41948
+ // Accept a string (e.g. from an HTML attribute: renderBudget="50") the
41949
+ // same way a bare number would work — arithmetic below (renderBudget / 2,
41950
+ // start + renderBudget, etc.) would silently misbehave on a raw string
41951
+ // ("+" concatenates instead of adding).
41952
+ let renderBudget = renderBudgetProp;
41953
+ if (typeof renderBudget === "string") {
41954
+ const parsed = Number(renderBudget);
41955
+ renderBudget = Number.isFinite(parsed) ? parsed : RENDER_BUDGET_DEFAULT;
41956
+ }
41946
41957
  if (renderBudget < 30 && !renderBudgetSkipCheck) {
41947
41958
  console.warn(`List: renderBudget=${renderBudget} is too low. A renderBudget below 30 is not supported: on large screens or when the list grows, items outside the window would appear as blank space instead of rendered content. Use a value of at least 30, or omit the prop to use the default (${RENDER_BUDGET_DEFAULT}).`);
41948
41959
  }
@@ -42072,7 +42083,7 @@ const ListFirstResolver = props => {
42072
42083
  * action?: (value: any) => void,
42073
42084
  * uiAction?: (value: any) => void,
42074
42085
  * popover?: boolean,
42075
- * renderBudget?: number,
42086
+ * renderBudget?: number | string,
42076
42087
  * virtualItemSize?: number,
42077
42088
  * fallback?: import("ignore:preact").ComponentChildren,
42078
42089
  * searchFallback?: import("ignore:preact").ComponentChildren,
@@ -42333,6 +42344,7 @@ const useListScrollSync = ({
42333
42344
  // (pour l'instant pas grave car on travaille pour le mode select qui fermera le dialog au select)
42334
42345
  const savedScrollRef = useRef(null);
42335
42346
  const topMatchScoresKeyRef = useRef("");
42347
+ const restoreScrollRafRef = useRef(null);
42336
42348
  useLayoutEffect(() => {
42337
42349
  const listScrollContainerEl = ref.current?.querySelector(`.navi_list_scroll_container`);
42338
42350
  if (!listScrollContainerEl) {
@@ -42349,7 +42361,20 @@ const useListScrollSync = ({
42349
42361
  savedScrollRef.current = null;
42350
42362
  debugScroll("Restoring scroll to", savedScroll);
42351
42363
  updateRenderWindow(savedScroll.renderWindow.start, savedScroll.renderWindow.end, "restore scroll window");
42352
- const raf = requestAnimationFrame(() => {
42364
+ // Tracked in a ref rather than cancelled via this effect's own cleanup:
42365
+ // updateRenderWindow above triggers a re-render, which re-runs this
42366
+ // effect (it has no dependency array — it needs to reactively poll
42367
+ // tracker state on every render) *before* the RAF below fires. That
42368
+ // second invocation sees savedScrollRef.current already nulled and
42369
+ // bails out early — if the RAF were tied to this invocation's cleanup,
42370
+ // it would get cancelled right there with nothing to replace it,
42371
+ // silently dropping the scroll restore (renderWindow ends up correct,
42372
+ // but scrollTop stays wherever it was, showing blank filler space).
42373
+ if (restoreScrollRafRef.current) {
42374
+ cancelAnimationFrame(restoreScrollRafRef.current);
42375
+ }
42376
+ restoreScrollRafRef.current = requestAnimationFrame(() => {
42377
+ restoreScrollRafRef.current = null;
42353
42378
  const left = savedScroll.left;
42354
42379
  const top = savedScroll.top;
42355
42380
  // use scrollTo to respect eventual css scroll-behavior: smooth;
@@ -42372,13 +42397,11 @@ const useListScrollSync = ({
42372
42397
  event: new CustomEvent("navi_scroll_restore")
42373
42398
  });
42374
42399
  });
42375
- return () => {
42376
- cancelAnimationFrame(raf);
42377
- };
42400
+ return undefined;
42378
42401
  }
42379
42402
  const visibleItems = tracker.visibleItemsSignal.peek();
42380
42403
  const topItems = visibleItems.slice(0, renderBudget);
42381
- const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchScore ?? ""}`).join(",");
42404
+ const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchInfo?.matchScore ?? ""}`).join(",");
42382
42405
  const currentTopMatchScore = topMatchScoresKeyRef.current;
42383
42406
  if (topMatchScoresKey === currentTopMatchScore) {
42384
42407
  // no changes in top matches -> no need to scroll
@@ -42487,7 +42510,10 @@ const getScrollInfo = (scrollValues, listScrollContainerEl, tracker, virtualItem
42487
42510
  break;
42488
42511
  }
42489
42512
  }
42490
- if (hitFiller) {
42513
+ // Shared by the "hit a filler" and "hit nothing at all" cases below: both
42514
+ // mean we don't know the real on-screen index, only the scroll position,
42515
+ // so estimate from it rather than assume nothing changed.
42516
+ const estimateFromScrollPos = reasonPrefix => {
42491
42517
  const virtualItemSize = virtualItemSizeSignal.peek();
42492
42518
  if (virtualItemSize === 0) {
42493
42519
  return null;
@@ -42497,8 +42523,11 @@ const getScrollInfo = (scrollValues, listScrollContainerEl, tracker, virtualItem
42497
42523
  return {
42498
42524
  item: items[index],
42499
42525
  index,
42500
- reason: `hit filler, estimated at ${index} (${items[index]?.value})`
42526
+ reason: `${reasonPrefix}, estimated at ${index} (${items[index]?.value})`
42501
42527
  };
42528
+ };
42529
+ if (hitFiller) {
42530
+ return estimateFromScrollPos("hit filler");
42502
42531
  }
42503
42532
  if (hitEl) {
42504
42533
  const hitId = hitEl.id;
@@ -42512,11 +42541,22 @@ const getScrollInfo = (scrollValues, listScrollContainerEl, tracker, virtualItem
42512
42541
  reason: `hit item at ${index} (${items[index].value})`
42513
42542
  };
42514
42543
  }
42544
+ // Neither a real item nor a filler was hit within listEl — e.g. part of
42545
+ // the scan range fell outside the page's actually reachable viewport
42546
+ // (docked devtools shrinks it, for one). Keeping the stale renderWindow
42547
+ // here (as this used to do) means the DOM never gets asked to catch up
42548
+ // with a scrollTop that may have jumped far away — the user ends up
42549
+ // staring at filler space. Same estimate as the hitFiller case is a safe
42550
+ // fallback: it only needs the scroll position, not a successful hit-test.
42551
+ const estimated = estimateFromScrollPos("no hit");
42552
+ if (estimated) {
42553
+ return estimated;
42554
+ }
42515
42555
  const fallbackIndex = renderWindowRef.current.start;
42516
42556
  return {
42517
42557
  item: items[fallbackIndex],
42518
42558
  index: fallbackIndex,
42519
- reason: "no hit"
42559
+ reason: "no hit, no virtualItemSize yet"
42520
42560
  };
42521
42561
  };
42522
42562
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal) => {
@@ -42793,9 +42833,13 @@ const ListItemUI = props => {
42793
42833
  const renderWindow = useContext(RenderWindowContext);
42794
42834
  const tracker = useContext(ListItemTrackerContext);
42795
42835
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
42796
- // Derive filtered/hidden/nonMatching from the `match` prop + searchNoMatchMode context.
42797
- // The `match` prop replaces the older `filtered`/`hidden` per-item props.
42798
- if (props.match === false) {
42836
+ // There is no standalone match/matchScore/highlight prop participation
42837
+ // in a matching system (search, filter…) only goes through `matchInfo`
42838
+ // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
42839
+ // matchRanges }), so there is exactly one way to wire it up.
42840
+ const matchInfo = props.matchInfo;
42841
+ // Derive filtered/hidden/muted from matchInfo.match + searchNoMatchMode context.
42842
+ if (matchInfo?.match === false) {
42799
42843
  if (searchNoMatchMode === "remove") {
42800
42844
  props.filtered = true;
42801
42845
  } else if (searchNoMatchMode === "invisible_and_inert") {
@@ -42864,7 +42908,7 @@ const ListItemReal = props => {
42864
42908
  id,
42865
42909
  hidden,
42866
42910
  muted,
42867
- highlight,
42911
+ matchInfo,
42868
42912
  children,
42869
42913
  ...rest
42870
42914
  } = props;
@@ -42882,8 +42926,9 @@ const ListItemReal = props => {
42882
42926
  pendingScroll.resolve(itemEl);
42883
42927
  }, [needScrollOnMount]);
42884
42928
 
42885
- // CSS Highlight API: mark matching text ranges when highlight prop is set.
42886
- useSearchHighlight(ref, highlight, [children, hidden]);
42929
+ // CSS Highlight API: mark matching text ranges from matchInfo.matchRanges,
42930
+ // if any (there is no standalone highlight prop — see ListItem's own doc).
42931
+ useSearchHighlight(ref, matchInfo?.matchRanges, [children, hidden]);
42887
42932
  const columnsOverrideProps = useListItemColumnsOverrideProps(rest.style);
42888
42933
  return jsx(Box, {
42889
42934
  as: "li",
@@ -42896,9 +42941,7 @@ const ListItemReal = props => {
42896
42941
  ...rest,
42897
42942
  ...columnsOverrideProps,
42898
42943
  index: undefined,
42899
- selected: undefined,
42900
- matchScore: undefined,
42901
- match: undefined
42944
+ selected: undefined
42902
42945
  // We use aria-hidden and not hidden because hidden would be forced to
42903
42946
  // display: none while here we want to keep it in the DOM to avoid layout shift
42904
42947
  // but visually hidden
@@ -42977,7 +43020,21 @@ const LIST_ITEM_PSEUDO_ELEMENTS = ["::highlight"];
42977
43020
  * filtered — when true, item is excluded from visible count and removed from DOM entirely
42978
43021
  * hidden — when true, item is excluded from visible count (no virtual scroll height)
42979
43022
  * but stays in DOM with the native HTML hidden attribute
42980
- * highlightarray of [start, end] ranges to highlight via CSS Highlight API
43023
+ * matchInfoparticipation in a matching system (search, filter…): the
43024
+ * object useSearchText's getItemMatchInfo(item) returns
43025
+ * (or any object shaped the same way):
43026
+ * <ListItem matchInfo={getItemMatchInfo(item)} />
43027
+ * There is no standalone match/matchScore/highlight prop —
43028
+ * matchInfo is the only way to wire this up:
43029
+ * match — false is interpreted per the List's own
43030
+ * searchNoMatchMode ("remove" -> filtered,
43031
+ * "invisible_and_inert" -> hidden,
43032
+ * "muted" -> muted).
43033
+ * matchScore — this item's search relevance score (higher =
43034
+ * more relevant). Only read for search-driven
43035
+ * scroll-to-top-match behavior.
43036
+ * matchRanges — array of [start, end] ranges to highlight via
43037
+ * CSS Highlight API.
42981
43038
  * ...rest — forwarded to the rendered <li> element
42982
43039
  */
42983
43040
  const ListItem = createComponentResolver([ListItemFirstResolver, ListItemSelectableResolver, ListItemHeaderOrFooterResolver, ListItemPresentationResolver, ListItemUI]);
@@ -44637,7 +44694,8 @@ const mergeRanges = (ranges) => {
44637
44694
  *
44638
44695
  * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
44639
44696
  * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
44640
- * // Pass it to <ListItem highlight={matchRanges} /> — ListItem handles the object format.
44697
+ * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
44698
+ * // — ListItem handles the per-selector object format for matchRanges.
44641
44699
  * ```
44642
44700
  *
44643
44701
  * Each field config:
@@ -44677,7 +44735,9 @@ const createSearch = (fields) => {
44677
44735
  * followed by non-matched items in their natural order. No item is hidden.
44678
44736
  * Returns [orderedItems, getItemMatchInfo].
44679
44737
  * - orderedItems: all items, reordered
44680
- * - getItemMatchInfo(item): { match, score, ranges }
44738
+ * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
44739
+ * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
44740
+ * there is no need to destructure the three fields by hand.
44681
44741
  *
44682
44742
  * When searchText is empty, natural order is preserved and all items match with score 0.
44683
44743
  *
@@ -44730,8 +44790,8 @@ const buildMatchInfo = (searchText, items, matchFn) => {
44730
44790
  if (!result.match) {
44731
44791
  nonMatched.push({
44732
44792
  item,
44733
- score: result.matchScore,
44734
- ranges: result.matchRanges,
44793
+ matchScore: result.matchScore,
44794
+ matchRanges: result.matchRanges,
44735
44795
  });
44736
44796
  continue;
44737
44797
  }
@@ -44751,23 +44811,23 @@ const buildMatchInfo = (searchText, items, matchFn) => {
44751
44811
  }
44752
44812
  }
44753
44813
  if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
44754
- scoreEntries[lo][1].push({ item, ranges: result.matchRanges });
44814
+ scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
44755
44815
  } else {
44756
44816
  scoreEntries.splice(lo, 0, [
44757
44817
  score,
44758
- [{ item, ranges: result.matchRanges }],
44818
+ [{ item, matchRanges: result.matchRanges }],
44759
44819
  ]);
44760
44820
  }
44761
44821
  }
44762
44822
 
44763
44823
  const matchInfoMap = new Map();
44764
44824
  for (const [score, bucket] of scoreEntries) {
44765
- for (const { item, ranges } of bucket) {
44766
- matchInfoMap.set(item, { match: true, score, ranges });
44825
+ for (const { item, matchRanges } of bucket) {
44826
+ matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
44767
44827
  }
44768
44828
  }
44769
- for (const { item, score, ranges } of nonMatched) {
44770
- matchInfoMap.set(item, { match: false, score, ranges });
44829
+ for (const { item, matchScore, matchRanges } of nonMatched) {
44830
+ matchInfoMap.set(item, { match: false, matchScore, matchRanges });
44771
44831
  }
44772
44832
 
44773
44833
  return { scoreEntries, nonMatched, matchInfoMap };