@jsenv/navi 0.29.343 → 0.29.345

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.
@@ -64746,452 +64746,666 @@ const cssVars = vars => {
64746
64746
  };
64747
64747
  const lengthValue = value => typeof value === "number" ? `${value}px` : value;
64748
64748
 
64749
- /*
64750
- * useItemTracker() — hook that creates a stable item tracker for the lifetime
64751
- * of the host component.
64752
- *
64753
- * USAGE:
64754
- * ```jsx
64755
- * function ListControlled({ items }) {
64756
- * const tracker = useItemTracker({
64757
- * onChange: () => console.log("items changed"),
64758
- * });
64759
- *
64760
- * return (
64761
- * <ul>
64762
- * {items.map((item, i) => (
64763
- * <Row key={item.id} id={item.id} index={i} hidden={item.hidden} value={item.value} tracker={tracker} />
64764
- * ))}
64765
- * <Count tracker={tracker} />
64766
- * </ul>
64767
- * );
64768
- * }
64769
- *
64770
- * function Row({ id, index, hidden, value, tracker }) {
64771
- * const visibleIndex = tracker.useTrackItem({ id, index, hidden, value });
64772
- * if (visibleIndex === -1) return null;
64773
- * return <li>{value}</li>;
64774
- * }
64775
- *
64776
- * function Count({ tracker }) {
64777
- * const count = tracker.visibleCountSignal.value; // re-renders only when count changes
64778
- * return <span>{count} items</span>;
64779
- * }
64780
- * ```
64781
- *
64782
- * INTERNALS:
64783
- * - registrations: Map key → data, contains only visible items
64784
- * - idToKey: Map id → key, stable across renders
64785
- * - orderedKeys: number[] of visible item keys sorted by explicit order
64786
- * - keyToOrderedIndex: Map key → orderedKeys index, gives O(1) indexOf equivalent
64787
- * - keyToExplicitOrder: Map key → explicitly passed index, used to maintain sort order
64788
- * - allItemsSignal: signal(array), all items including hidden, ordered by explicit index
64789
- * - visibleItemsSignal: signal(array), non-hidden items only
64790
- * - countSignal: signal(number), count of all items including hidden
64791
- * - visibleCountSignal: signal(number), updated in microtask batch, only when count changes
64792
- * - propSignals: Map propName → signal(array), updated in microtask batch with element equality
64793
- * - onChangeRef: holds the latest onChange callback, called once per microtask batch
64794
- *
64795
- * useTrackItem(id, data, index): registers the item with an explicitly provided index
64796
- * that determines its position among siblings. The caller (e.g. items.map) knows the
64797
- * correct order and passes it directly — no render-sequence deduction needed.
64798
- * Returns the visible rank (position among non-hidden items), or -1 when hidden.
64799
- * Signals and onChange are deferred to a microtask so multiple items updating
64800
- * in one commit cause only one notification.
64801
- *
64802
- * getTrackedItemByIndex(index): synchronous O(1) lookup of a visible item by
64803
- * its visible rank. Returns undefined when index is out of range.
64804
- *
64805
- * peekItems(): the items as they stand right now, without waiting for the
64806
- * deferred notification — what a sibling rendering after the items must read
64807
- * to paint them in the same commit.
64808
- */
64809
-
64810
- const useItemTracker = ({ onChange } = {}) => {
64811
- const onChangeRef = useRef(onChange);
64812
- onChangeRef.current = onChange;
64813
- const trackerRef = useRef(null);
64814
- let tracker = trackerRef.current;
64815
- if (!tracker) {
64816
- trackerRef.current = tracker = createItemTracker((items) => {
64817
- onChangeRef.current?.(items);
64818
- });
64749
+ const ListItemHeaderOrFooterResolver = props => {
64750
+ const Next = useNextResolver();
64751
+ if (props.header) {
64752
+ return renderResolver(ListItemHeader, props);
64819
64753
  }
64820
- // When code in useLayoutEffect of the caller wants to run the tracker must be in sync
64821
- // without this layout effect the tracker might not have been synced yet and preact would call layout effect
64822
- // before we had time to sync
64823
- useLayoutEffect(() => {
64824
- tracker._flushSync();
64754
+ if (props.footer) {
64755
+ return renderResolver(ListItemFooter, props);
64756
+ }
64757
+ return jsx(Next, {
64758
+ ...props
64759
+ });
64760
+ };
64761
+ const ListItemHeader = props => {
64762
+ const Next = useNextResolver();
64763
+ const {
64764
+ ref
64765
+ } = props;
64766
+ useDisplayedLayoutEffect(ref, headerEl => {
64767
+ const listContainerEl = headerEl.closest(".navi_list_container");
64768
+ const rect = headerEl.getBoundingClientRect();
64769
+ listContainerEl.style.setProperty("--list-header-height", `${rect.height}px`);
64770
+ listContainerEl.style.setProperty("--list-header-width", `${rect.width}px`);
64771
+ }, []);
64772
+ return jsx(Next, {
64773
+ ...props,
64774
+ header: undefined,
64775
+ role: "presentation",
64776
+ baseClassName: "navi_list_item_header"
64777
+ });
64778
+ };
64779
+ const ListItemFooter = props => {
64780
+ const Next = useNextResolver();
64781
+ const {
64782
+ ref
64783
+ } = props;
64784
+ useDisplayedLayoutEffect(ref, footerEl => {
64785
+ const listContainerEl = footerEl.closest(".navi_list_container");
64786
+ const rect = footerEl.getBoundingClientRect();
64787
+ listContainerEl.style.setProperty("--list-footer-height", `${rect.height}px`);
64788
+ listContainerEl.style.setProperty("--list-footer-width", `${rect.width}px`);
64789
+ }, []);
64790
+ return jsx(Next, {
64791
+ ...props,
64792
+ footer: undefined,
64793
+ role: "presentation",
64794
+ baseClassName: "navi_list_item_footer"
64825
64795
  });
64826
- return tracker;
64827
64796
  };
64828
64797
 
64829
- const createItemTracker = (onChange) => {
64830
- const registrations = new Map(); // key data (visible items only)
64831
- const idToKey = new Map(); // id insertion key (stable, auto-incremented)
64832
- let keyCounter = 0;
64833
- // orderedKeys: visible item keys sorted by their explicitly provided index.
64834
- const orderedKeys = []; // number[]
64835
- // keyToOrderedIndex: O(1) equivalent of orderedKeys.indexOf(key).
64836
- const keyToOrderedIndex = new Map(); // key index in orderedKeys
64837
- const allKeys = new Set(); // all registered keys including hidden
64838
- const keyToExplicitOrder = new Map(); // key explicitly passed index
64839
-
64840
- const allRegistrations = new Map(); // key data (all items including hidden)
64841
- const allOrderedKeys = []; // all item keys sorted by explicit order
64842
- const keyToAllOrderedIndex = new Map(); // key index in allOrderedKeys
64843
-
64844
- const itemsSignal = signal([]);
64845
- const visibleItemsSignal = signal([]);
64846
- const countSignal = signal(0);
64847
- const visibleCountSignal = signal(0);
64848
- const noMatchCountSignal = signal(0);
64849
-
64850
- let notifyScheduled = false;
64851
- const runNotify = () => {
64852
- batch(() => {
64853
- let someChange = false;
64798
+ // Everything the list knows about its rows, in one place: how many the
64799
+ // collection has and where each child's rows start (the places), which rows
64800
+ // are drawn and which of those mount and show (the rows), and what each says
64801
+ // about itself (the items). Two clocks. Places and "is anything standing
64802
+ // above me" answer synchronously, in the middle of a render pass — a row asks
64803
+ // about the rows before it, and those have rendered already. The items and
64804
+ // the counts settle once per frame (a microtask): many rows change in one
64805
+ // commit, and what reads them wants one notification.
64806
+ //
64807
+ // Why places are read off the walk and not off the renders: a child knows how
64808
+ // many rows it stands for but not what was declared before it, and it cannot
64809
+ // deduce that from when it renders a render is free to skip it. A child that
64810
+ // draws from signals and whose props are all referentially === the previous
64811
+ // ones does not render again (@preact/signals installs a shouldComponentUpdate
64812
+ // that says so), which is what any child nobody rebuilt this frame is — and
64813
+ // children numbered as they render would then slide up into the place of the
64814
+ // one that was skipped. So the list names a slot for each of its children and
64815
+ // declares them here, in order, before any of them renders (see
64816
+ // ListDeclaredChildren). A child then takes its place BY SLOT, and the place
64817
+ // is a signal: it moves when what stands before it changes — a row filtered
64818
+ // out, a run taking in rows, a slot added or moved — and the child follows,
64819
+ // rendered again for it whether or not anything else would have rendered it.
64820
+ //
64821
+ // Why "first" is a signal too: only the row itself knows, once it renders,
64822
+ // that it renders nothing (filtered out by a search), and the rows after it
64823
+ // may have been handed back unchanged. The first mounted row of a scope is
64824
+ // kept as a signal each of them reads; when it leaves, they are rendered again.
64854
64825
 
64855
- const newCount = allKeys.size;
64856
- const countModified = countSignal.peek() !== newCount;
64857
- if (countModified) {
64858
- countSignal.value = newCount;
64859
- someChange = true;
64860
- }
64826
+ const UNGROUPED = Symbol("ungrouped");
64861
64827
 
64862
- // Build allItems and visibleItems in a single pass over allOrderedKeys.
64863
- // Visible items are those without data.hidden or data.filtered — same
64864
- // relative order as orderedKeys (syncItem already excludes both from
64865
- // orderedKeys; this must match or consumers relying on visibleCountSignal
64866
- // for virtual-scroll accounting, e.g. list.jsx's filler sizing, would
64867
- // count filtered-out items as if they still took up space).
64868
- const prevAllItems = itemsSignal.peek();
64869
- const prevVisibleItems = visibleItemsSignal.peek();
64870
- let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
64871
- let visibleItemsChanged = false;
64872
- const allItems = [];
64873
- const visibleItems = [];
64874
- let newNoMatchCount = 0;
64875
- for (let i = 0; i < allOrderedKeys.length; i++) {
64876
- const key = allOrderedKeys[i];
64877
- const item = allRegistrations.get(key);
64878
- allItems.push(item);
64879
- // Compare by reference: catches any prop change (id, selected, disabled, …)
64880
- if (!allItemsChanged && item !== prevAllItems[i]) {
64881
- allItemsChanged = true;
64882
- }
64883
- if (item.match === false) {
64884
- newNoMatchCount++;
64885
- }
64886
- if (!item.hidden && !item.filtered) {
64887
- const visibleIdx = visibleItems.length;
64888
- visibleItems.push(item);
64889
- if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
64890
- visibleItemsChanged = true;
64891
- }
64892
- }
64893
- }
64828
+ const createListRows = () => {
64829
+ const totalSignal = signal(0);
64830
+ // Bumped whenever a run takes in rows. The list itself has to hear about it:
64831
+ // rows arriving outside the render window change nothing it can see (nothing
64832
+ // registers, nothing is drawn), and yet they are what it may have been
64833
+ // waiting for the row it was told to open on, for one.
64834
+ const pagesSignal = signal(0);
64835
+ // How many runs are re-reading rows they already show. The list wears it as
64836
+ // an attribute: what is drawn is from before, and the app may want to say so
64837
+ // without taking anything away.
64838
+ const refreshingSignal = signal(0);
64839
+ // The slots each walk declared, in order, by the slot the walk stands in
64840
+ // (null for the list's own children). Together they are a tree: a group's
64841
+ // rows live inside the group's slot.
64842
+ const slotIdsByParent = new Map();
64843
+ // Who took a place — a row, or a run of rows — and how many rows of the
64844
+ // collection it stands for. The place itself is a signal, see take.
64845
+ const ownerById = new Map();
64846
+ // The owners standing in each slot, in the order they took their place.
64847
+ // One, as a rule; a child that renders several rows keeps them in the order
64848
+ // they first rendered, which is all it can be told.
64849
+ const ownerIdsBySlot = new Map();
64850
+ const locatorByOwner = new Map();
64851
+ // The slots as the tree reads, first to last, and where each stands in it.
64852
+ // Rebuilt once a walk has changed the tree, read to place the owners.
64853
+ const slotWalk = [];
64854
+ const rankBySlot = new Map();
64855
+ let rowTotal = 0;
64856
+ // Owners have left and the others have not been moved up yet. Done on the
64857
+ // next ask rather than on the spot: rows leave many at a time (a search, a
64858
+ // list unmounting), and moving the others up once is enough.
64859
+ let placesStale = false;
64860
+ // Where the last slot holding an owner stands: an owner arriving at or after
64861
+ // it is placed at the end without going over the others — a whole first
64862
+ // render, rows arriving in order, costs each row nothing but itself.
64863
+ let rankOwnedLast = -1;
64894
64864
 
64895
- const newVisibleCount = visibleItems.length;
64896
- const visibleCountModified =
64897
- visibleCountSignal.peek() !== newVisibleCount;
64898
- if (visibleCountModified) {
64899
- visibleCountSignal.value = newVisibleCount;
64900
- someChange = true;
64901
- }
64902
- if (allItemsChanged) {
64903
- itemsSignal.value = allItems;
64904
- someChange = true;
64905
- }
64906
- if (visibleItemsChanged) {
64907
- visibleItemsSignal.value = visibleItems;
64908
- someChange = true;
64909
- }
64910
- const noMatchCountModified =
64911
- noMatchCountSignal.peek() !== newNoMatchCount;
64912
- if (noMatchCountModified) {
64913
- noMatchCountSignal.value = newNoMatchCount;
64914
- someChange = true;
64865
+ const rebuildWalk = () => {
64866
+ slotWalk.length = 0;
64867
+ rankBySlot.clear();
64868
+ const visit = (parentSlotId) => {
64869
+ const slotIds = slotIdsByParent.get(parentSlotId);
64870
+ if (!slotIds) {
64871
+ return;
64915
64872
  }
64916
- if (someChange) {
64917
- onChange?.();
64873
+ for (const slotId of slotIds) {
64874
+ rankBySlot.set(slotId, slotWalk.length);
64875
+ slotWalk.push(slotId);
64876
+ visit(slotId);
64918
64877
  }
64919
- });
64878
+ };
64879
+ visit(null);
64920
64880
  };
64921
-
64922
- const notify = () => {
64923
- if (notifyScheduled) {
64924
- return;
64925
- }
64926
- notifyScheduled = true;
64927
- queueMicrotask(() => {
64928
- if (!notifyScheduled) {
64929
- return; // was already flushed synchronously
64881
+ // Every place, in one go: a place is the sum of what stands before it, so
64882
+ // there is nothing to hand out one at a time. Writing a place that did not
64883
+ // change wakes nobody — a signal ignores a value equal to its own.
64884
+ const refreshPlaces = () => {
64885
+ placesStale = false;
64886
+ let index = 0;
64887
+ let rank = 0;
64888
+ rankOwnedLast = -1;
64889
+ while (rank < slotWalk.length) {
64890
+ const ownerIds = ownerIdsBySlot.get(slotWalk[rank]);
64891
+ if (ownerIds) {
64892
+ for (const ownerId of ownerIds) {
64893
+ const owner = ownerById.get(ownerId);
64894
+ owner.placeSignal.value = index;
64895
+ index += owner.rowCount;
64896
+ }
64897
+ rankOwnedLast = rank;
64930
64898
  }
64931
- notifyScheduled = false;
64932
- runNotify();
64933
- });
64899
+ rank++;
64900
+ }
64901
+ rowTotal = index;
64902
+ totalSignal.value = index;
64903
+ // A run's edges are places too (see refreshFirst).
64904
+ markStale(UNGROUPED);
64934
64905
  };
64935
-
64936
- const _flushSync = () => {
64937
- if (!notifyScheduled) {
64906
+ const addToSlot = (slotId, ownerId) => {
64907
+ const ownerIds = ownerIdsBySlot.get(slotId);
64908
+ if (ownerIds) {
64909
+ ownerIds.push(ownerId);
64910
+ } else {
64911
+ ownerIdsBySlot.set(slotId, [ownerId]);
64912
+ }
64913
+ warnIfEveryRowInOneSlot(slotId);
64914
+ };
64915
+ // Rows that all stand in the same slot keep the order they first mounted in:
64916
+ // the walk is over the children the list is given, and a component holding
64917
+ // them is one child however many rows it renders. Everything about a place
64918
+ // then stops following what the caller writes — a search reordering the rows
64919
+ // moves nothing. Said once, and only for the shape that can be nothing else:
64920
+ // the list's whole content is one child, and several rows came out of it.
64921
+ let everyRowInOneSlotWarned = false;
64922
+ const warnIfEveryRowInOneSlot = (slotId) => {
64923
+ if (everyRowInOneSlotWarned) {
64938
64924
  return;
64939
64925
  }
64940
- notifyScheduled = false;
64941
- runNotify();
64926
+ const rootSlotIds = slotIdsByParent.get(null);
64927
+ if (!rootSlotIds || rootSlotIds.length !== 1 || rootSlotIds[0] !== slotId) {
64928
+ return;
64929
+ }
64930
+ if (ownerIdsBySlot.get(slotId).length < 2) {
64931
+ return;
64932
+ }
64933
+ everyRowInOneSlotWarned = true;
64934
+ console.warn(
64935
+ `List: every row stands in the same slot, so they keep the order they first rendered in — reordering them (a search, a sort) will not move them. The list's rows must be its own children: give it the rows (or a <List.Items>), not a component rendering them.`,
64936
+ );
64942
64937
  };
64943
-
64944
- // Insert key into orderedKeys at the correct position based on explicitOrder.
64945
- // Uses binary search for O(log n) insertion.
64946
- const insertKey = (key, explicitOrder) => {
64947
- let lo = 0;
64948
- let hi = orderedKeys.length;
64949
- while (lo < hi) {
64950
- const mid = (lo + hi) >> 1;
64951
- if (keyToExplicitOrder.get(orderedKeys[mid]) <= explicitOrder) {
64952
- lo = mid + 1;
64953
- } else {
64954
- hi = mid;
64955
- }
64938
+ const removeFromSlot = (slotId, ownerId) => {
64939
+ const ownerIds = ownerIdsBySlot.get(slotId);
64940
+ if (!ownerIds) {
64941
+ return;
64956
64942
  }
64957
- orderedKeys.splice(lo, 0, key);
64958
- for (let i = lo; i < orderedKeys.length; i++) {
64959
- keyToOrderedIndex.set(orderedKeys[i], i);
64943
+ const index = ownerIds.indexOf(ownerId);
64944
+ if (index !== -1) {
64945
+ ownerIds.splice(index, 1);
64946
+ }
64947
+ if (ownerIds.length === 0) {
64948
+ ownerIdsBySlot.delete(slotId);
64960
64949
  }
64961
64950
  };
64962
-
64963
- const insertAllKey = (key, explicitOrder) => {
64964
- let lo = 0;
64965
- let hi = allOrderedKeys.length;
64966
- while (lo < hi) {
64967
- const mid = (lo + hi) >> 1;
64968
- if (keyToExplicitOrder.get(allOrderedKeys[mid]) <= explicitOrder) {
64969
- lo = mid + 1;
64970
- } else {
64971
- hi = mid;
64951
+ // A slot the walk no longer names: whatever stood in it is gone, and so is
64952
+ // whatever a walk inside it had declared.
64953
+ const dropSlot = (slotId) => {
64954
+ const ownerIds = ownerIdsBySlot.get(slotId);
64955
+ if (ownerIds) {
64956
+ for (const ownerId of ownerIds) {
64957
+ ownerById.delete(ownerId);
64972
64958
  }
64959
+ ownerIdsBySlot.delete(slotId);
64973
64960
  }
64974
- allOrderedKeys.splice(lo, 0, key);
64975
- for (let i = lo; i < allOrderedKeys.length; i++) {
64976
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
64961
+ const childSlotIds = slotIdsByParent.get(slotId);
64962
+ if (childSlotIds) {
64963
+ slotIdsByParent.delete(slotId);
64964
+ for (const childSlotId of childSlotIds) {
64965
+ dropSlot(childSlotId);
64966
+ }
64977
64967
  }
64978
64968
  };
64979
64969
 
64980
- const removeAllKey = (key) => {
64981
- const idx = keyToAllOrderedIndex.get(key);
64982
- if (idx !== undefined) {
64983
- allOrderedKeys.splice(idx, 1);
64984
- keyToAllOrderedIndex.delete(key);
64985
- for (let i = idx; i < allOrderedKeys.length; i++) {
64986
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
64987
- }
64970
+ // ---- the rows drawn ----
64971
+ // rowId { ownerId, place, groupId, data, mounted, visible, item }
64972
+ const rowById = new Map();
64973
+ // groupId → { firstSignal, countSignal, noMatchCountSignal }
64974
+ const groupById = new Map();
64975
+ // ownerId { from, to }, for the runs (see declareWindow).
64976
+ const windowByOwner = new Map();
64977
+ // The first place something stands at, outside any group: the mounted rows
64978
+ // and the rows a run holds above its window. Per group, the group's first
64979
+ // mounted row.
64980
+ const firstStandingSignal = signal(-1);
64981
+ // The scopes whose first row left: recounted on the next ask, or at the end
64982
+ // of the frame, whichever comes first. A row mounting before the first one
64983
+ // moves it at once, no recount needed — the first can only ever move up.
64984
+ const staleScopes = new Set();
64985
+ const scopeOf = (groupId) => (groupId === undefined ? UNGROUPED : groupId);
64986
+ const groupOf = (groupId) => {
64987
+ let group = groupById.get(groupId);
64988
+ if (!group) {
64989
+ group = {
64990
+ firstSignal: signal(-1),
64991
+ countSignal: signal(0),
64992
+ noMatchCountSignal: signal(0),
64993
+ };
64994
+ groupById.set(groupId, group);
64988
64995
  }
64996
+ return group;
64989
64997
  };
64990
-
64991
- // Register or update an item. data.hidden controls visibility.
64992
- // explicitOrder is the caller-provided index that determines sort position.
64993
- const syncItem = (key, index, data) => {
64994
- if (data.role === "presentation") {
64995
- registrations.delete(key);
64996
- const idx = keyToOrderedIndex.get(key);
64997
- if (idx !== undefined) {
64998
- orderedKeys.splice(idx, 1);
64999
- keyToOrderedIndex.delete(key);
65000
- for (let i = idx; i < orderedKeys.length; i++) {
65001
- keyToOrderedIndex.set(orderedKeys[i], i);
65002
- }
64998
+ const firstSignalOf = (scope) =>
64999
+ scope === UNGROUPED ? firstStandingSignal : groupOf(scope).firstSignal;
65000
+ const refreshFirst = (scope) => {
65001
+ staleScopes.delete(scope);
65002
+ let first = -1;
65003
+ const consider = (place) => {
65004
+ if (place !== undefined && (first === -1 || place < first)) {
65005
+ first = place;
65003
65006
  }
65004
- keyToExplicitOrder.delete(key);
65005
- allRegistrations.delete(key);
65006
- removeAllKey(key);
65007
- allKeys.delete(key);
65008
- return;
65009
- }
65010
-
65011
- // Maintain allRegistrations and allOrderedKeys for all non-presentation items.
65012
- allRegistrations.set(key, data);
65013
- allKeys.add(key);
65014
- const currentAllIdx = keyToAllOrderedIndex.get(key);
65015
- const previousOrder = keyToExplicitOrder.get(key);
65016
- keyToExplicitOrder.set(key, index);
65017
- if (currentAllIdx === undefined) {
65018
- insertAllKey(key, index);
65019
- } else if (previousOrder !== index) {
65020
- allOrderedKeys.splice(currentAllIdx, 1);
65021
- keyToAllOrderedIndex.delete(key);
65022
- for (let i = currentAllIdx; i < allOrderedKeys.length; i++) {
65023
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
65007
+ };
65008
+ for (const row of rowById.values()) {
65009
+ if (row.mounted && scopeOf(row.groupId) === scope) {
65010
+ consider(row.place);
65024
65011
  }
65025
- insertAllKey(key, index);
65026
65012
  }
65027
-
65028
- if (data.filtered || data.hidden) {
65029
- registrations.delete(key);
65030
- const idx = keyToOrderedIndex.get(key);
65031
- if (idx !== undefined) {
65032
- orderedKeys.splice(idx, 1);
65033
- keyToOrderedIndex.delete(key);
65034
- for (let i = idx; i < orderedKeys.length; i++) {
65035
- keyToOrderedIndex.set(orderedKeys[i], i);
65013
+ if (scope === UNGROUPED) {
65014
+ for (const [ownerId, window] of windowByOwner) {
65015
+ const owner = ownerById.get(ownerId);
65016
+ if (!owner) {
65017
+ continue;
65018
+ }
65019
+ const start = owner.placeSignal.peek();
65020
+ if (window.from > start) {
65021
+ consider(start);
65022
+ }
65023
+ if (window.to < start + owner.rowCount) {
65024
+ consider(window.to);
65036
65025
  }
65037
65026
  }
65038
- return;
65039
- }
65040
-
65041
- registrations.set(key, data);
65042
- const currentIdx = keyToOrderedIndex.get(key);
65043
- if (currentIdx === undefined) {
65044
- insertKey(key, index);
65045
- return;
65046
65027
  }
65047
- if (previousOrder === index) {
65028
+ firstSignalOf(scope).value = first;
65029
+ };
65030
+ const markStale = (scope) => {
65031
+ if (staleScopes.has(scope)) {
65048
65032
  return;
65049
65033
  }
65050
- orderedKeys.splice(currentIdx, 1);
65051
- keyToOrderedIndex.delete(key);
65052
- for (let i = currentIdx; i < orderedKeys.length; i++) {
65053
- keyToOrderedIndex.set(orderedKeys[i], i);
65034
+ staleScopes.add(scope);
65035
+ queueMicrotask(() => {
65036
+ if (staleScopes.has(scope)) {
65037
+ refreshFirst(scope);
65038
+ }
65039
+ });
65040
+ };
65041
+ const leaveFirst = (row) => {
65042
+ const scope = scopeOf(row.groupId);
65043
+ if (firstSignalOf(scope).peek() === row.place) {
65044
+ markStale(scope);
65054
65045
  }
65055
- insertKey(key, index);
65056
65046
  };
65057
65047
 
65058
- const unregisterKey = (key) => {
65059
- registrations.delete(key);
65060
- const idx = keyToOrderedIndex.get(key);
65061
- if (idx !== undefined) {
65062
- orderedKeys.splice(idx, 1);
65063
- keyToOrderedIndex.delete(key);
65064
- for (let i = idx; i < orderedKeys.length; i++) {
65065
- keyToOrderedIndex.set(orderedKeys[i], i);
65048
+ // ---- the items, settled once per frame ----
65049
+ const itemsSignal = signal([]);
65050
+ const visibleItemsSignal = signal([]);
65051
+ const countSignal = signal(0);
65052
+ const visibleCountSignal = signal(0);
65053
+ const noMatchCountSignal = signal(0);
65054
+ let notifyScheduled = false;
65055
+ const runNotify = () => {
65056
+ batch(() => {
65057
+ const itemRows = [];
65058
+ for (const row of rowById.values()) {
65059
+ if (row.item) {
65060
+ itemRows.push(row);
65061
+ }
65062
+ }
65063
+ itemRows.sort(compareRowPlaces);
65064
+ const items = [];
65065
+ const visibleItems = [];
65066
+ let noMatchCount = 0;
65067
+ const countByGroup = new Map();
65068
+ const noMatchCountByGroup = new Map();
65069
+ const prevItems = itemsSignal.peek();
65070
+ const prevVisibleItems = visibleItemsSignal.peek();
65071
+ let itemsChanged = prevItems.length !== itemRows.length;
65072
+ let visibleItemsChanged = false;
65073
+ for (const row of itemRows) {
65074
+ const item = row.data;
65075
+ // Compared by reference: any prop change (selected, disabled, …) is a
65076
+ // new props object.
65077
+ if (!itemsChanged && item !== prevItems[items.length]) {
65078
+ itemsChanged = true;
65079
+ }
65080
+ items.push(item);
65081
+ const noMatch = item.match === false;
65082
+ if (noMatch) {
65083
+ noMatchCount++;
65084
+ }
65085
+ if (row.groupId !== undefined) {
65086
+ countByGroup.set(
65087
+ row.groupId,
65088
+ (countByGroup.get(row.groupId) || 0) + 1,
65089
+ );
65090
+ if (noMatch) {
65091
+ noMatchCountByGroup.set(
65092
+ row.groupId,
65093
+ (noMatchCountByGroup.get(row.groupId) || 0) + 1,
65094
+ );
65095
+ }
65096
+ }
65097
+ if (row.visible) {
65098
+ if (
65099
+ !visibleItemsChanged &&
65100
+ item !== prevVisibleItems[visibleItems.length]
65101
+ ) {
65102
+ visibleItemsChanged = true;
65103
+ }
65104
+ visibleItems.push(item);
65105
+ }
65106
+ }
65107
+ if (visibleItems.length !== prevVisibleItems.length) {
65108
+ visibleItemsChanged = true;
65066
65109
  }
65110
+ let someChange = false;
65111
+ if (countSignal.peek() !== items.length) {
65112
+ countSignal.value = items.length;
65113
+ someChange = true;
65114
+ }
65115
+ if (visibleCountSignal.peek() !== visibleItems.length) {
65116
+ visibleCountSignal.value = visibleItems.length;
65117
+ someChange = true;
65118
+ }
65119
+ if (itemsChanged) {
65120
+ itemsSignal.value = items;
65121
+ someChange = true;
65122
+ }
65123
+ if (visibleItemsChanged) {
65124
+ visibleItemsSignal.value = visibleItems;
65125
+ someChange = true;
65126
+ }
65127
+ if (noMatchCountSignal.peek() !== noMatchCount) {
65128
+ noMatchCountSignal.value = noMatchCount;
65129
+ someChange = true;
65130
+ }
65131
+ for (const [groupId, group] of groupById) {
65132
+ group.countSignal.value = countByGroup.get(groupId) || 0;
65133
+ group.noMatchCountSignal.value = noMatchCountByGroup.get(groupId) || 0;
65134
+ }
65135
+ if (someChange && listRows.onChange) {
65136
+ listRows.onChange();
65137
+ }
65138
+ });
65139
+ };
65140
+ const notify = () => {
65141
+ if (notifyScheduled) {
65142
+ return;
65067
65143
  }
65068
- keyToExplicitOrder.delete(key);
65069
- allRegistrations.delete(key);
65070
- removeAllKey(key);
65071
- allKeys.delete(key);
65144
+ notifyScheduled = true;
65145
+ queueMicrotask(() => {
65146
+ if (!notifyScheduled) {
65147
+ return;
65148
+ }
65149
+ notifyScheduled = false;
65150
+ runNotify();
65151
+ });
65072
65152
  };
65073
65153
 
65074
- const keyForId = (id) => {
65075
- if (!idToKey.has(id)) {
65076
- idToKey.set(id, keyCounter++);
65077
- }
65078
- return idToKey.get(id);
65079
- };
65080
-
65081
- // Register an item. data.hidden controls visibility.
65082
- // explicitOrder is the caller-provided index (e.g. from items.map((item, i) => ...))
65083
- // that determines this item's position among siblings.
65084
- // Returns the item's visible rank among non-hidden items, or -1 when hidden.
65085
- const useTrackItem = (data) => {
65086
- const { id, index } = data;
65087
- const key = keyForId(id);
65088
-
65089
- syncItem(key, index, data);
65090
- notify();
65091
-
65092
- useLayoutEffect(() => {
65093
- return () => {
65094
- unregisterKey(key);
65095
- notify();
65096
- };
65097
- }, []);
65098
-
65099
- if (data.filtered || data.hidden || data.role === "presentation") {
65100
- return -1;
65101
- }
65102
- return keyToOrderedIndex.get(key) ?? -1;
65103
- };
65104
-
65105
- const getTrackedItemByIndex = (index) => {
65106
- const key = orderedKeys[index];
65107
- if (key === undefined) {
65108
- return undefined;
65109
- }
65110
- return registrations.get(key);
65111
- };
65112
-
65113
- // The items as they stand right now, notification pending or not — same
65114
- // content as itemsSignal, minus the wait.
65115
- //
65116
- // Items register during their own render, while the signal is only updated
65117
- // on a deferred microtask (see notify): a sibling rendering after them would
65118
- // otherwise paint from an empty list and correct itself a frame later. That
65119
- // frame is visible whenever the painted size feeds a layout decision — a
65120
- // dialog sizing itself on its content measures the empty version and shifts
65121
- // once the real one lands. Reading this instead makes the first paint the
65122
- // right one. Callers must still subscribe to itemsSignal to re-render on
65123
- // LATER changes; this is the value to display, not the notification.
65124
- const peekItems = () => {
65125
- if (!notifyScheduled) {
65126
- return itemsSignal.peek();
65127
- }
65128
- const items = [];
65129
- for (const key of allOrderedKeys) {
65130
- items.push(allRegistrations.get(key));
65131
- }
65132
- return items;
65133
- };
65134
-
65135
- return {
65136
- useTrackItem,
65137
- getTrackedItemByIndex,
65138
- peekItems,
65154
+ const listRows = {
65155
+ totalSignal,
65156
+ pagesSignal,
65157
+ refreshingSignal,
65158
+ // The rows that are items, in place order — every one drawn, and the ones
65159
+ // that show — and what the search made of them. Written once per frame.
65139
65160
  itemsSignal,
65140
65161
  visibleItemsSignal,
65141
65162
  countSignal,
65142
65163
  visibleCountSignal,
65143
65164
  noMatchCountSignal,
65144
- _flushSync,
65165
+ // Called once per frame in which the items changed. Set by the list.
65166
+ onChange: null,
65167
+ // What a run needs to know about the list it lives in: how many rows the
65168
+ // list is willing to draw at once, which end it opens on, and how much
65169
+ // room one row is given — a row whose content has not arrived must take
65170
+ // exactly that, or the rows drawn would not reach where the list says they
65171
+ // are.
65172
+ renderBudget: 0,
65173
+ scrolled: "start",
65174
+ // The list is on its way somewhere: what the window frames is not what it
65175
+ // is about to frame, so a run must not fetch for it (see holdWindow).
65176
+ holdPending: false,
65177
+ // Called by a run just before rows land in it: what is on screen must not
65178
+ // move because something arrived above it. Set by the list itself.
65179
+ captureAnchor: () => {},
65180
+ horizontal: false,
65181
+ virtualItemSizeSignal: null,
65182
+ renderSkeleton: undefined,
65183
+ // The children a walk stands over, in order — said in one call, before any
65184
+ // of them renders, so that what a child asks next is answered against the
65185
+ // whole picture and not against the children that happened to render
65186
+ // first. Said again on every render of the walk, and heard only when
65187
+ // something moved.
65188
+ declareSlots: (parentSlotId, slotIds) => {
65189
+ const slotIdsPrevious = slotIdsByParent.get(parentSlotId);
65190
+ if (slotIdsPrevious && sameSlotIds(slotIdsPrevious, slotIds)) {
65191
+ return;
65192
+ }
65193
+ if (slotIdsPrevious) {
65194
+ const slotIdSet = new Set(slotIds);
65195
+ for (const slotId of slotIdsPrevious) {
65196
+ if (!slotIdSet.has(slotId)) {
65197
+ dropSlot(slotId);
65198
+ }
65199
+ }
65200
+ }
65201
+ slotIdsByParent.set(parentSlotId, slotIds);
65202
+ rebuildWalk();
65203
+ refreshPlaces();
65204
+ },
65205
+ // Whether something has taken this slot for its own: what it renders
65206
+ // inside is then its to place (a run draws its groups with their rows
65207
+ // already placed), and no walk inside it has anything to declare.
65208
+ slotHasOwner: (slotId) => ownerIdsBySlot.has(slotId),
65209
+ // Whether any run of rows lives in this list: what makes a render window
65210
+ // mean anything (see List's renderBudget).
65211
+ hasRuns: () => locatorByOwner.size > 0,
65212
+ setRowLocator: (ownerId, locate) => {
65213
+ locatorByOwner.set(ownerId, locate);
65214
+ },
65215
+ dropRowLocator: (ownerId) => {
65216
+ locatorByOwner.delete(ownerId);
65217
+ },
65218
+ // Where the row named by that id sits, asked of whoever holds it.
65219
+ locateRow: (id) => {
65220
+ for (const locate of locatorByOwner.values()) {
65221
+ const index = locate(id);
65222
+ if (index !== null) {
65223
+ return index;
65224
+ }
65225
+ }
65226
+ return null;
65227
+ },
65228
+ // The place the owner's rows start at — read from a signal, so that the
65229
+ // owner is rendered again when it moves (see the top of this file). Asked on
65230
+ // every render, and answered without a second look for as long as the
65231
+ // owner stands in the same slot for the same number of rows.
65232
+ take: (ownerId, rowCount, slotId) => {
65233
+ let owner = ownerById.get(ownerId);
65234
+ if (owner) {
65235
+ if (owner.slotId !== slotId || owner.rowCount !== rowCount) {
65236
+ removeFromSlot(owner.slotId, ownerId);
65237
+ addToSlot(slotId, ownerId);
65238
+ owner.slotId = slotId;
65239
+ owner.rowCount = rowCount;
65240
+ placesStale = true;
65241
+ }
65242
+ if (placesStale) {
65243
+ refreshPlaces();
65244
+ }
65245
+ return owner.placeSignal.value;
65246
+ }
65247
+ if (placesStale) {
65248
+ refreshPlaces();
65249
+ }
65250
+ const rank = rankBySlot.get(slotId);
65251
+ addToSlot(slotId, ownerId);
65252
+ if (rank !== undefined && rank >= rankOwnedLast) {
65253
+ owner = { slotId, rowCount, placeSignal: signal(rowTotal) };
65254
+ ownerById.set(ownerId, owner);
65255
+ rowTotal += rowCount;
65256
+ rankOwnedLast = rank;
65257
+ totalSignal.value = rowTotal;
65258
+ return owner.placeSignal.value;
65259
+ }
65260
+ owner = { slotId, rowCount, placeSignal: signal(0) };
65261
+ ownerById.set(ownerId, owner);
65262
+ refreshPlaces();
65263
+ return owner.placeSignal.value;
65264
+ },
65265
+ // The owner stands for no row of the collection: it was filtered out by a
65266
+ // search, or it is gone.
65267
+ drop: (ownerId) => {
65268
+ const owner = ownerById.get(ownerId);
65269
+ if (!owner) {
65270
+ return;
65271
+ }
65272
+ ownerById.delete(ownerId);
65273
+ removeFromSlot(owner.slotId, ownerId);
65274
+ windowByOwner.delete(ownerId);
65275
+ if (placesStale) {
65276
+ return;
65277
+ }
65278
+ placesStale = true;
65279
+ queueMicrotask(() => {
65280
+ if (placesStale) {
65281
+ refreshPlaces();
65282
+ }
65283
+ });
65284
+ },
65285
+
65286
+ // ---- the rows drawn ----
65287
+
65288
+ // A run says which of its rows it draws. The others stand: rows above the
65289
+ // window are above every row drawn, whether or not any of the drawn ones
65290
+ // mounts (see refreshFirst).
65291
+ declareWindow: (ownerId, from, to) => {
65292
+ const window = windowByOwner.get(ownerId);
65293
+ if (window && window.from === from && window.to === to) {
65294
+ return;
65295
+ }
65296
+ windowByOwner.set(ownerId, { from, to });
65297
+ markStale(UNGROUPED);
65298
+ },
65299
+ // A row says what it is, where it renders: its place, the group it is
65300
+ // in, and its data — from which follows whether it mounts at all
65301
+ // (filtered out), whether it shows (hidden keeps the room, not the
65302
+ // content), and whether it is an item (a group wrapper, a skeleton, are
65303
+ // rows of the list but items of nobody). Said in the name of the
65304
+ // component, not of the item id: two components may stand for one item for
65305
+ // a moment, one leaving as the other arrives.
65306
+ draw: (rowId, { ownerId, place, groupId, data }) => {
65307
+ const mounted = !data.filtered;
65308
+ const visible = mounted && !data.hidden;
65309
+ const item = !data.skeleton && data.role !== "presentation";
65310
+ let row = rowById.get(rowId);
65311
+ if (row) {
65312
+ if (
65313
+ row.mounted &&
65314
+ (row.place !== place || row.groupId !== groupId || !mounted)
65315
+ ) {
65316
+ leaveFirst(row);
65317
+ }
65318
+ row.ownerId = ownerId;
65319
+ row.place = place;
65320
+ row.groupId = groupId;
65321
+ row.data = data;
65322
+ row.mounted = mounted;
65323
+ row.visible = visible;
65324
+ row.item = item;
65325
+ } else {
65326
+ row = { ownerId, place, groupId, data, mounted, visible, item };
65327
+ rowById.set(rowId, row);
65328
+ }
65329
+ if (mounted) {
65330
+ const firstSignal = firstSignalOf(scopeOf(groupId));
65331
+ const first = firstSignal.peek();
65332
+ if (first === -1 || place < first) {
65333
+ firstSignal.value = place;
65334
+ }
65335
+ }
65336
+ notify();
65337
+ },
65338
+ // The row is gone. A declared row is its own owner and gives its place
65339
+ // back with it; a run's row leaves the run's places alone.
65340
+ erase: (rowId) => {
65341
+ const row = rowById.get(rowId);
65342
+ if (!row) {
65343
+ return;
65344
+ }
65345
+ rowById.delete(rowId);
65346
+ if (row.mounted) {
65347
+ leaveFirst(row);
65348
+ }
65349
+ if (row.ownerId === rowId) {
65350
+ listRows.drop(rowId);
65351
+ }
65352
+ notify();
65353
+ },
65354
+ // Whether nothing of the list stands above this row: in its group, no
65355
+ // other row of the group mounts before it; outside groups, no row mounts
65356
+ // before it and no run has rows above its window before it. Answered from
65357
+ // a signal, so a row rendered from a kept vnode is rendered again when the
65358
+ // row before it leaves or comes back.
65359
+ isFirst: (rowId) => {
65360
+ const row = rowById.get(rowId);
65361
+ const scope = scopeOf(row.groupId);
65362
+ if (staleScopes.has(scope)) {
65363
+ refreshFirst(scope);
65364
+ }
65365
+ return firstSignalOf(scope).value === row.place;
65366
+ },
65367
+ // What a group knows about its rows: how many, and how many of them the
65368
+ // search left out (see ListItemGroup).
65369
+ group: (groupId) => groupOf(groupId),
65370
+ dropGroup: (groupId) => {
65371
+ groupById.delete(groupId);
65372
+ staleScopes.delete(groupId);
65373
+ },
65374
+ // Written when a frame's rows have settled (see notify); the value to act
65375
+ // on is peeked from wherever the change is heard.
65376
+ flushSync: () => {
65377
+ if (!notifyScheduled) {
65378
+ return;
65379
+ }
65380
+ notifyScheduled = false;
65381
+ runNotify();
65382
+ },
65145
65383
  };
65384
+ return listRows;
65146
65385
  };
65147
-
65148
- const ListItemHeaderOrFooterResolver = props => {
65149
- const Next = useNextResolver();
65150
- if (props.header) {
65151
- return renderResolver(ListItemHeader, props);
65386
+ const sameSlotIds = (left, right) => {
65387
+ if (left.length !== right.length) {
65388
+ return false;
65152
65389
  }
65153
- if (props.footer) {
65154
- return renderResolver(ListItemFooter, props);
65390
+ let index = 0;
65391
+ while (index < left.length) {
65392
+ if (left[index] !== right[index]) {
65393
+ return false;
65394
+ }
65395
+ index++;
65155
65396
  }
65156
- return jsx(Next, {
65157
- ...props
65158
- });
65397
+ return true;
65159
65398
  };
65160
- const ListItemHeader = props => {
65161
- const Next = useNextResolver();
65162
- const {
65163
- ref
65164
- } = props;
65165
- useDisplayedLayoutEffect(ref, headerEl => {
65166
- const listContainerEl = headerEl.closest(".navi_list_container");
65167
- const rect = headerEl.getBoundingClientRect();
65168
- listContainerEl.style.setProperty("--list-header-height", `${rect.height}px`);
65169
- listContainerEl.style.setProperty("--list-header-width", `${rect.width}px`);
65170
- }, []);
65171
- return jsx(Next, {
65172
- ...props,
65173
- header: undefined,
65174
- role: "presentation",
65175
- baseClassName: "navi_list_item_header"
65176
- });
65177
- };
65178
- const ListItemFooter = props => {
65179
- const Next = useNextResolver();
65180
- const {
65181
- ref
65182
- } = props;
65183
- useDisplayedLayoutEffect(ref, footerEl => {
65184
- const listContainerEl = footerEl.closest(".navi_list_container");
65185
- const rect = footerEl.getBoundingClientRect();
65186
- listContainerEl.style.setProperty("--list-footer-height", `${rect.height}px`);
65187
- listContainerEl.style.setProperty("--list-footer-width", `${rect.width}px`);
65188
- }, []);
65189
- return jsx(Next, {
65190
- ...props,
65191
- footer: undefined,
65192
- role: "presentation",
65193
- baseClassName: "navi_list_item_footer"
65194
- });
65399
+
65400
+ // Rows in place order; a row with no place (a declared row filtered out) last.
65401
+ const compareRowPlaces = (left, right) => {
65402
+ if (left.place === undefined) {
65403
+ return right.place === undefined ? 0 : 1;
65404
+ }
65405
+ if (right.place === undefined) {
65406
+ return -1;
65407
+ }
65408
+ return left.place - right.place;
65195
65409
  };
65196
65410
 
65197
65411
  // What a row may say it is waiting on, all of them about the row as a thing
@@ -65999,8 +66213,10 @@ const applySearchHighlight = (el, highlight) => {
65999
66213
  };
66000
66214
 
66001
66215
  installImportMetaCssBuild(import.meta);
66002
- const ListItemTrackerContext = createContext(null);
66003
- const GroupItemTrackerContext = createContext(null);
66216
+ const ListRowsContext = createContext(null);
66217
+ // The group a row is declared in, by id (see ListItemGroup): what its count
66218
+ // and its separator are scoped to.
66219
+ const ListGroupContext = createContext(undefined);
66004
66220
  const PendingScrollRefContext = createContext(null);
66005
66221
  // Controls how List.Item behaves when match=false (set via List searchNoMatchMode prop):
66006
66222
  // "remove" — remove from DOM (default)
@@ -66039,20 +66255,15 @@ const SeparatorContext = createContext(null);
66039
66255
  // Set by <List itemTransition>: each row then gets a view-transition-name of
66040
66256
  // its own, so a change wrapped in a view transition animates row by row.
66041
66257
  const ItemTransitionContext = createContext(false);
66042
- // What the list knows about the collection as a whole: how many rows it has,
66043
- // which of them it actually holds, and where each child's rows start. Filled in
66044
- // by the children as they render (see createListVirtual), read by everything
66045
- // that must reserve room for what is not rendered.
66046
- const ListVirtualContext = createContext(null);
66047
66258
  // Set around each row a run of items renders (see ListItems): which row of the
66048
- // collection it is, and where it stands among the rows the list holds. Carried
66049
- // by context rather than injected into whatever vnode renderItem returned, so
66050
- // that returning a component of one's own — instead of a bare <List.Item> —
66051
- // works the same way.
66259
+ // collection it is, where it stands among the rows the list holds, and which
66260
+ // run it belongs to. Carried by context rather than injected into whatever
66261
+ // vnode renderItem returned, so that returning a component of one's own —
66262
+ // instead of a bare <List.Item> — works the same way.
66052
66263
  const ListRowContext = createContext(null);
66053
66264
  // The slot a child of the list stands in, by id (see ListDeclaredChildren). A
66054
66265
  // row takes its place in the collection by slot: the place is then the list's
66055
- // to move, and the row's to follow — see createListVirtual.
66266
+ // to move, and the row's to follow — see list_rows.js.
66056
66267
  const ListSlotContext = createContext(null);
66057
66268
  const css$x = /* css */`@layer navi {
66058
66269
  .navi_list_container {
@@ -66543,7 +66754,7 @@ const ListUI = props => {
66543
66754
  overflow,
66544
66755
  overflowX,
66545
66756
  overflowY,
66546
- virtual,
66757
+ listRows,
66547
66758
  ...rest
66548
66759
  } = props;
66549
66760
  const scrollBoxPaddingProps = {};
@@ -66612,16 +66823,19 @@ const ListUI = props => {
66612
66823
  observer.disconnect();
66613
66824
  };
66614
66825
  }, [lockSize]);
66615
- const tracker = useItemTracker({
66616
- onChange: () => {
66617
- onListVisibleItemsChange?.(tracker.visibleItemsSignal.peek());
66618
- }
66826
+ listRows.onChange = () => {
66827
+ onListVisibleItemsChange?.(listRows.visibleItemsSignal.peek());
66828
+ };
66829
+ // Code in a layout effect of the list reads the rows as they stand after
66830
+ // the commit; the rows settle on a microtask, which preact does not wait for.
66831
+ useLayoutEffect(() => {
66832
+ listRows.flushSync();
66619
66833
  });
66620
66834
  // What the runs ask for and stand for: the steady budget, whatever the
66621
66835
  // window of the first paint draws — a run asking for the rows of the first
66622
66836
  // picture and then for the rest is two round trips for one opening.
66623
- virtual.renderBudget = renderBudgetAfterPaint;
66624
- virtual.scrolled = scrolled ?? defaultScrolled;
66837
+ listRows.renderBudget = renderBudgetAfterPaint;
66838
+ listRows.scrolled = scrolled ?? defaultScrolled;
66625
66839
  const {
66626
66840
  virtualItemSizeSignal,
66627
66841
  renderWindow,
@@ -66630,11 +66844,10 @@ const ListUI = props => {
66630
66844
  captureAnchor
66631
66845
  } = useListScrollSync({
66632
66846
  ref,
66633
- tracker,
66847
+ listRows,
66634
66848
  renderBudget,
66635
66849
  renderBudgetSteady: renderBudgetAfterPaint,
66636
66850
  virtualItemSize,
66637
- virtual,
66638
66851
  scrolled,
66639
66852
  defaultScrolled,
66640
66853
  onScrolledChange,
@@ -66653,28 +66866,28 @@ const ListUI = props => {
66653
66866
  if (props.renderBudget === undefined || renderBudgetWarnedRef.current) {
66654
66867
  return;
66655
66868
  }
66656
- if (virtual.hasRuns() || tracker.itemsSignal.peek().length === 0) {
66869
+ if (listRows.hasRuns() || listRows.itemsSignal.peek().length === 0) {
66657
66870
  return;
66658
66871
  }
66659
66872
  renderBudgetWarnedRef.current = true;
66660
66873
  console.warn(`List: renderBudget=${renderBudget} has no effect here. The render window frames the rows a run draws (<List.Items itemsAction>); items declared one by one (<List.Item>) are all rendered. Move the items to <List.Items> to cap the number of DOM nodes, or drop the prop.`);
66661
66874
  });
66662
- virtual.captureAnchor = captureAnchor;
66663
- virtual.virtualItemSizeSignal = virtualItemSizeSignal;
66664
- virtual.horizontal = Boolean(horizontal);
66665
- virtual.renderSkeleton = renderSkeleton;
66875
+ listRows.captureAnchor = captureAnchor;
66876
+ listRows.virtualItemSizeSignal = virtualItemSizeSignal;
66877
+ listRows.horizontal = Boolean(horizontal);
66878
+ listRows.renderSkeleton = renderSkeleton;
66666
66879
 
66667
66880
  // A row is addressed by id from outside (--navi-scroll, --navi-select): the
66668
- // ones drawn have registered themselves with the tracker, and the ones a run
66881
+ // ones drawn have said so (see list_rows.js), and the ones a run
66669
66882
  // holds without drawing are known only to that run (see List.Items' row
66670
66883
  // locator). Both answer here, so a row is reachable whether or not the
66671
66884
  // window happens to frame it.
66672
66885
  const getItemById = itemId => {
66673
- const itemDrawn = tracker.itemsSignal.peek().find(item => item.id === itemId);
66886
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
66674
66887
  if (itemDrawn) {
66675
66888
  return itemDrawn;
66676
66889
  }
66677
- const rowIndex = virtual.locateRow(itemId);
66890
+ const rowIndex = listRows.locateRow(itemId);
66678
66891
  if (rowIndex === null) {
66679
66892
  return undefined;
66680
66893
  }
@@ -66683,13 +66896,13 @@ const ListUI = props => {
66683
66896
  index: rowIndex
66684
66897
  };
66685
66898
  };
66686
- const noMatchCount = tracker.noMatchCountSignal.value;
66899
+ const noMatchCount = listRows.noMatchCountSignal.value;
66687
66900
  // What the list stands for, which is not always what it holds: a run saying
66688
66901
  // it covers 60 rows is not an empty list while it waits for the first of
66689
66902
  // them (see List.Items).
66690
66903
  // eslint-disable-next-line no-unused-expressions
66691
- virtual.pagesSignal.value;
66692
- const itemCount = tracker.countSignal.value || virtual.totalSignal.value;
66904
+ listRows.pagesSignal.value;
66905
+ const itemCount = listRows.countSignal.value || listRows.totalSignal.value;
66693
66906
  const allNoMatch = noMatchCount > 0 && noMatchCount === itemCount;
66694
66907
  const searching = Boolean(searchText);
66695
66908
  const fallbackDisabled = fallback !== undefined && !fallback;
@@ -66787,7 +67000,7 @@ const ListUI = props => {
66787
67000
  expand: expand,
66788
67001
  "navi-nothing-to-display": nothingToDisplay ? "" : undefined,
66789
67002
  "navi-loading": loading ? "" : undefined,
66790
- "navi-refreshing": virtual.refreshingSignal.value ? "" : undefined,
67003
+ "navi-refreshing": listRows.refreshingSignal.value ? "" : undefined,
66791
67004
  "navi-error": error ? "" : undefined,
66792
67005
  styleCSSVars: LIST_STYLE_CSS_VARS,
66793
67006
  pseudoClasses: LIST_PSEUDO_CLASSES,
@@ -66823,9 +67036,8 @@ const ListUI = props => {
66823
67036
  spacing: spacing,
66824
67037
  columns: columns,
66825
67038
  itemColumns: itemColumns,
66826
- tracker: tracker,
67039
+ listRows: listRows,
66827
67040
  renderWindow: renderWindow,
66828
- virtual: virtual,
66829
67041
  pendingScrollRef: pendingScrollRef,
66830
67042
  overflow: overflow,
66831
67043
  overflowX: overflowX,
@@ -66846,11 +67058,11 @@ const ListFirstResolver = props => {
66846
67058
  props.ref = props.ref || refDefault;
66847
67059
  const idDefault = useId();
66848
67060
  props.id = props.id || idDefault;
66849
- const virtualRef = useRef(null);
66850
- if (!virtualRef.current) {
66851
- virtualRef.current = createListVirtual();
67061
+ const listRowsRef = useRef(null);
67062
+ if (!listRowsRef.current) {
67063
+ listRowsRef.current = createListRows();
66852
67064
  }
66853
- props.virtual = virtualRef.current;
67065
+ props.listRows = listRowsRef.current;
66854
67066
  const parallelGuard = useParallelGuard(props.parallelGuard ?? PARALLEL_GUARD_DEFAULT);
66855
67067
  return jsx(ParallelGuardContext.Provider, {
66856
67068
  value: parallelGuard,
@@ -66876,9 +67088,8 @@ const ListContent = ({
66876
67088
  spacing,
66877
67089
  columns,
66878
67090
  itemColumns,
66879
- tracker,
67091
+ listRows,
66880
67092
  renderWindow,
66881
- virtual,
66882
67093
  pendingScrollRef,
66883
67094
  overflow,
66884
67095
  overflowX,
@@ -66932,9 +67143,8 @@ const ListContent = ({
66932
67143
  columns: columns,
66933
67144
  itemColumns: itemColumns,
66934
67145
  ...listProps,
66935
- tracker: tracker,
67146
+ listRows: listRows,
66936
67147
  renderWindow: renderWindow,
66937
- virtual: virtual,
66938
67148
  children: children
66939
67149
  })
66940
67150
  })
@@ -66958,11 +67168,10 @@ const LIST_STYLE_CSS_VARS = {
66958
67168
  const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
66959
67169
  const useListScrollSync = ({
66960
67170
  ref,
66961
- tracker,
67171
+ listRows,
66962
67172
  renderBudget,
66963
67173
  renderBudgetSteady,
66964
67174
  virtualItemSize,
66965
- virtual,
66966
67175
  scrolled,
66967
67176
  defaultScrolled,
66968
67177
  onScrolledChange,
@@ -66972,7 +67181,7 @@ const useListScrollSync = ({
66972
67181
  }) => {
66973
67182
  const debugScroll = useDebugScroll();
66974
67183
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
66975
- virtual,
67184
+ listRows,
66976
67185
  renderBudget,
66977
67186
  scrolledWanted: scrolled ?? defaultScrolled
66978
67187
  });
@@ -66997,7 +67206,7 @@ const useListScrollSync = ({
66997
67206
  ref,
66998
67207
  scrollerElResolved,
66999
67208
  renderBudget,
67000
- totalSignal: virtual.totalSignal,
67209
+ totalSignal: listRows.totalSignal,
67001
67210
  virtualItemSizeSignal,
67002
67211
  horizontal
67003
67212
  });
@@ -67022,7 +67231,7 @@ const useListScrollSync = ({
67022
67231
  anchorRef.current = captureScrollAnchor({
67023
67232
  scrollerEl: getScroller(),
67024
67233
  listEl: getListEl(),
67025
- items: tracker.visibleItemsSignal.peek(),
67234
+ items: listRows.visibleItemsSignal.peek(),
67026
67235
  horizontal
67027
67236
  });
67028
67237
  };
@@ -67054,7 +67263,7 @@ const useListScrollSync = ({
67054
67263
  start,
67055
67264
  end
67056
67265
  } = renderWindowRef.current;
67057
- const total = virtual.totalSignal.peek();
67266
+ const total = listRows.totalSignal.peek();
67058
67267
  let framedStart = start;
67059
67268
  let framedEnd = start + renderBudget;
67060
67269
  if (total > 0 && framedEnd > total) {
@@ -67097,19 +67306,19 @@ const useListScrollSync = ({
67097
67306
  // jumped.
67098
67307
  const holdWindow = () => {
67099
67308
  if (startPlaceRef.current.userTookOver) {
67100
- virtual.holdPending = false;
67309
+ listRows.holdPending = false;
67101
67310
  return;
67102
67311
  }
67103
67312
  // Held somewhere it has not reached yet: what the window frames right now
67104
67313
  // is not what it will frame, so nothing should be fetched for it.
67105
- virtual.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
67106
- const total = virtual.totalSignal.peek();
67314
+ listRows.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
67315
+ const total = listRows.totalSignal.peek();
67107
67316
  if (total <= renderBudget) {
67108
67317
  // The whole collection is what the list draws: wherever in it the list is
67109
67318
  // held, the window is already its place. Nowhere to move to means nothing
67110
67319
  // to wait for — a hold left standing here is a list that never asks for
67111
67320
  // anything again.
67112
- virtual.holdPending = false;
67321
+ listRows.holdPending = false;
67113
67322
  return;
67114
67323
  }
67115
67324
  const half = Math.floor(renderBudget / 2);
@@ -67119,7 +67328,7 @@ const useListScrollSync = ({
67119
67328
  } else if (typeof scrolledWanted === "number") {
67120
67329
  wantedStart = scrolledWanted - half;
67121
67330
  } else if (scrolledWanted && scrolledWanted.id !== undefined) {
67122
- const rowIndex = virtual.locateRow(scrolledWanted.id);
67331
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
67123
67332
  if (rowIndex !== null) {
67124
67333
  wantedStart = rowIndex - half;
67125
67334
  } else if (typeof scrolledWanted.index === "number") {
@@ -67146,14 +67355,14 @@ const useListScrollSync = ({
67146
67355
  end
67147
67356
  } = renderWindowRef.current;
67148
67357
  if (wantedStart === start && end - start === renderBudget) {
67149
- virtual.holdPending = false;
67358
+ listRows.holdPending = false;
67150
67359
  return;
67151
67360
  }
67152
67361
  renderWindowRef.current = {
67153
67362
  start: wantedStart,
67154
67363
  end: wantedStart + renderBudget
67155
67364
  };
67156
- virtual.holdPending = false;
67365
+ listRows.holdPending = false;
67157
67366
  };
67158
67367
  const pendingScrollRef = useRef();
67159
67368
  const scrollToItem = (item, {
@@ -67164,7 +67373,7 @@ const useListScrollSync = ({
67164
67373
  if (!item) {
67165
67374
  return;
67166
67375
  }
67167
- const items = tracker.itemsSignal.peek();
67376
+ const items = listRows.itemsSignal.peek();
67168
67377
  const itemCount = items.length;
67169
67378
  if (itemCount === 0) {
67170
67379
  return;
@@ -67292,7 +67501,7 @@ const useListScrollSync = ({
67292
67501
  return;
67293
67502
  }
67294
67503
  hasBeenDisplayedRef.current = true;
67295
- const items = tracker.itemsSignal.peek();
67504
+ const items = listRows.itemsSignal.peek();
67296
67505
  const firstSelected = items.find(i => {
67297
67506
  if (i.selected) {
67298
67507
  return true;
@@ -67376,7 +67585,7 @@ const useListScrollSync = ({
67376
67585
  scrollValues: savedScroll,
67377
67586
  scrollerEl: listScrollContainerEl,
67378
67587
  listEl: getListEl(),
67379
- tracker,
67588
+ listRows,
67380
67589
  virtualItemSizeSignal,
67381
67590
  renderWindowRef,
67382
67591
  horizontal
@@ -67393,7 +67602,7 @@ const useListScrollSync = ({
67393
67602
  });
67394
67603
  return undefined;
67395
67604
  }
67396
- const visibleItems = tracker.visibleItemsSignal.peek();
67605
+ const visibleItems = listRows.visibleItemsSignal.peek();
67397
67606
  const topItems = visibleItems.slice(0, renderBudget);
67398
67607
  const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchInfo?.matchScore ?? ""}`).join(",");
67399
67608
  const currentTopMatchScore = topMatchScoresKeyRef.current;
@@ -67431,7 +67640,7 @@ const useListScrollSync = ({
67431
67640
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
67432
67641
  return;
67433
67642
  }
67434
- if (virtual.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
67643
+ if (listRows.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
67435
67644
  return;
67436
67645
  }
67437
67646
  // Coming back to a named row: it has to be on screen to be put back where
@@ -67443,13 +67652,13 @@ const useListScrollSync = ({
67443
67652
  // Only whoever holds the rows can say where that one sits: the list
67444
67653
  // itself knows the rows it has drawn, and this one is precisely the one
67445
67654
  // it has not drawn yet.
67446
- const rowIndex = virtual.locateRow(scrolledWanted.id);
67655
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
67447
67656
  if (rowIndex === null) {
67448
67657
  // Not there yet. Where it stood is enough to be roughly right in the
67449
67658
  // meantime — the scrollbar lands near its final place instead of at the
67450
67659
  // top, and the exact position is taken once the row itself can be
67451
67660
  // measured.
67452
- if (virtual.pagesSignal.peek() === 0) {
67661
+ if (listRows.pagesSignal.peek() === 0) {
67453
67662
  if (typeof scrolledWanted.index === "number") {
67454
67663
  const rowPosition = scrolledWanted.index * virtualItemSizeSignal.peek();
67455
67664
  anchorRef.current = null;
@@ -67566,7 +67775,7 @@ const useListScrollSync = ({
67566
67775
  const position = captureScrollAnchor({
67567
67776
  scrollerEl: getScroller(),
67568
67777
  listEl: getListEl(),
67569
- items: tracker.visibleItemsSignal.peek(),
67778
+ items: listRows.visibleItemsSignal.peek(),
67570
67779
  horizontal
67571
67780
  });
67572
67781
  if (!position) {
@@ -67654,7 +67863,7 @@ const useListScrollSync = ({
67654
67863
  anchorRef.current = null;
67655
67864
  return;
67656
67865
  }
67657
- const items = tracker.visibleItemsSignal.peek();
67866
+ const items = listRows.visibleItemsSignal.peek();
67658
67867
  const itemNow = items.find(i => i.id === anchor.id);
67659
67868
  if (!itemNow) {
67660
67869
  anchorRef.current = null;
@@ -67676,7 +67885,7 @@ const useListScrollSync = ({
67676
67885
  const windowSize = end - start;
67677
67886
  const startShifted = start + indexShift;
67678
67887
  let startWanted = startShifted < 0 ? 0 : startShifted;
67679
- const total = virtual.totalSignal.peek();
67888
+ const total = listRows.totalSignal.peek();
67680
67889
  // Same normalization as the scroll listener: a window running past the
67681
67890
  // last row slides back instead of framing fewer rows than its budget
67682
67891
  // allows — every row that fits in it must stay rendered.
@@ -67734,7 +67943,7 @@ const useListScrollSync = ({
67734
67943
  const windowSlidRef = useRef(false);
67735
67944
  useRef(false);
67736
67945
  const evaluateWindow = reason => {
67737
- const total = virtual.totalSignal.peek();
67946
+ const total = listRows.totalSignal.peek();
67738
67947
  if (total <= renderBudget) {
67739
67948
  return;
67740
67949
  }
@@ -67756,7 +67965,7 @@ const useListScrollSync = ({
67756
67965
  },
67757
67966
  scrollerEl,
67758
67967
  listEl,
67759
- tracker,
67968
+ listRows,
67760
67969
  virtualItemSizeSignal,
67761
67970
  renderWindowRef,
67762
67971
  horizontal
@@ -68355,12 +68564,12 @@ const getScrollInfo = ({
68355
68564
  scrollValues,
68356
68565
  scrollerEl,
68357
68566
  listEl,
68358
- tracker,
68567
+ listRows,
68359
68568
  virtualItemSizeSignal,
68360
68569
  renderWindowRef,
68361
68570
  horizontal
68362
68571
  }) => {
68363
- const items = tracker.itemsSignal.peek();
68572
+ const items = listRows.itemsSignal.peek();
68364
68573
  const viewportRect = getScrollerViewportRect(scrollerEl);
68365
68574
  const listRect = listEl.getBoundingClientRect();
68366
68575
  let hitEl = null;
@@ -68485,7 +68694,7 @@ const measureItemSize = (listEl, horizontal) => {
68485
68694
  };
68486
68695
  };
68487
68696
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68488
- virtual,
68697
+ listRows,
68489
68698
  renderBudget,
68490
68699
  scrolledWanted
68491
68700
  }) => {
@@ -68545,7 +68754,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68545
68754
  // size is for, and a list drawing every row it has would pay a layout on
68546
68755
  // each of its renders for a number nothing reads.
68547
68756
  const sizeAlreadyKnown = virtualSizeSignal.peek() !== 0;
68548
- const rowsHeldOffScreen = virtual.totalSignal.peek() > renderBudget;
68757
+ const rowsHeldOffScreen = listRows.totalSignal.peek() > renderBudget;
68549
68758
  if (!virtualItemSizeProp && sizeAlreadyKnown && rowsHeldOffScreen && ref.current) {
68550
68759
  const listEl = ref.current.querySelector(".navi_list");
68551
68760
  const measure = listEl ? measureItemSize(listEl, horizontal) : null;
@@ -68561,7 +68770,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68561
68770
  // screen, and a list held somewhere (placeWhereHeld) before it knows where
68562
68771
  // that is. A list drawing every row it has, opening at its start, would
68563
68772
  // pay a layout in every commit for a number nobody reads.
68564
- const sizeRead = virtual.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
68773
+ const sizeRead = listRows.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
68565
68774
  if (!sizeRead) {
68566
68775
  return undefined;
68567
68776
  }
@@ -68613,9 +68822,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68613
68822
  // item after each commit and writes to the signal, causing only the fillers to
68614
68823
  // re-render.
68615
68824
  const UnorderedList = ({
68616
- tracker,
68825
+ listRows,
68617
68826
  renderWindow,
68618
- virtual,
68619
68827
  fallback,
68620
68828
  fallbackShown,
68621
68829
  searchFallback,
@@ -68665,17 +68873,14 @@ const UnorderedList = ({
68665
68873
  value: separator ?? null,
68666
68874
  children: jsx(ItemTransitionContext.Provider, {
68667
68875
  value: Boolean(itemTransition),
68668
- children: jsx(ListItemTrackerContext.Provider, {
68669
- value: tracker,
68670
- children: jsx(ListVirtualContext.Provider, {
68671
- value: virtual,
68672
- children: jsx(ListRowContext.Provider, {
68673
- value: null,
68674
- children: jsx(ListItemColumnsContext.Provider, {
68675
- value: columns ? null : itemColumns || null,
68676
- children: jsx(ListDeclaredChildren, {
68677
- children: children
68678
- })
68876
+ children: jsx(ListRowsContext.Provider, {
68877
+ value: listRows,
68878
+ children: jsx(ListRowContext.Provider, {
68879
+ value: null,
68880
+ children: jsx(ListItemColumnsContext.Provider, {
68881
+ value: columns ? null : itemColumns || null,
68882
+ children: jsx(ListDeclaredChildren, {
68883
+ children: children
68679
68884
  })
68680
68885
  })
68681
68886
  })
@@ -68726,8 +68931,8 @@ const VirtualFiller = ({
68726
68931
  edge,
68727
68932
  itemCount
68728
68933
  }) => {
68729
- const virtual = useContext(ListVirtualContext);
68730
- const sizeToFill = itemCount * virtual.virtualItemSizeSignal.value;
68934
+ const listRows = useContext(ListRowsContext);
68935
+ const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
68731
68936
  if (!sizeToFill) {
68732
68937
  return null;
68733
68938
  }
@@ -68791,22 +68996,12 @@ const ListItemRowResolver = props => {
68791
68996
  ...props
68792
68997
  });
68793
68998
  }
68794
- // eslint-disable-next-line no-unused-vars
68795
- const {
68796
- id,
68797
- index,
68798
- item,
68799
- rowMinHeight,
68800
- rowMinWidth,
68801
- ...rowProps
68802
- } = row;
68803
68999
  return jsx(Next, {
68804
- ...rowProps,
68805
69000
  ...props,
68806
69001
  id: props.id || row.id,
68807
69002
  index: row.index,
68808
- minHeight: props.minHeight === undefined ? rowMinHeight : props.minHeight,
68809
- minWidth: props.minWidth === undefined ? rowMinWidth : props.minWidth
69003
+ minHeight: props.minHeight === undefined ? row.rowMinHeight : props.minHeight,
69004
+ minWidth: props.minWidth === undefined ? row.rowMinWidth : props.minWidth
68810
69005
  });
68811
69006
  };
68812
69007
  const ListItemPresentationResolver = props => {
@@ -68880,12 +69075,11 @@ const ListItemUI = props => {
68880
69075
  }
68881
69076
  const idDefault = useId();
68882
69077
  props.id = props.id || idDefault;
68883
- const tracker = useContext(ListItemTrackerContext);
68884
- const virtual = useContext(ListVirtualContext);
69078
+ const listRows = useContext(ListRowsContext);
69079
+ const groupId = useContext(ListGroupContext);
68885
69080
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
68886
69081
  // The run this row belongs to, when it comes from one (see ListItems): it
68887
- // registered the row, decided it is inside the render window, and placed its
68888
- // separator. All that is left here is to draw it.
69082
+ // gave the row its place and decided it is inside the render window.
68889
69083
  const row = useContext(ListRowContext);
68890
69084
  const slotId = useContext(ListSlotContext);
68891
69085
  // There is no standalone match/matchScore/highlight prop — participation
@@ -68893,7 +69087,7 @@ const ListItemUI = props => {
68893
69087
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
68894
69088
  // matchRanges }), so there is exactly one way to wire it up.
68895
69089
  const matchInfo = props.matchInfo;
68896
- // Expose match on the tracked item: the tracker counts non-matching items via
69090
+ // Expose match on the row: the list counts non-matching rows via
68897
69091
  // `item.match === false` (drives noMatchCount → allNoMatch → the searchFallback
68898
69092
  // / hide-when-empty behavior). Without this a matchInfo-based search would
68899
69093
  // filter items out but never register them as "no match".
@@ -68919,68 +69113,47 @@ const ListItemUI = props => {
68919
69113
  // and the one leaving must give back its own place, not the newcomer's.
68920
69114
  if (!row) {
68921
69115
  if (props.filtered) {
68922
- virtual.drop(idDefault);
69116
+ listRows.drop(idDefault);
68923
69117
  } else {
68924
- props.index = virtual.take(idDefault, 1, slotId);
69118
+ props.index = listRows.take(idDefault, 1, slotId);
68925
69119
  }
68926
69120
  }
69121
+ // Every row that renders says so, whether it was declared one by one or
69122
+ // drawn by a run: what it is (its value, whether it is selected) and whether
69123
+ // it mounts at all are written where it renders, in one place.
69124
+ listRows.draw(idDefault, {
69125
+ ownerId: row ? row.ownerId : idDefault,
69126
+ place: props.index,
69127
+ groupId,
69128
+ data: props
69129
+ });
68927
69130
  useLayoutEffect(() => {
68928
69131
  return () => {
68929
- virtual.drop(idDefault);
69132
+ listRows.erase(idDefault);
68930
69133
  };
68931
69134
  }, []);
68932
- // Every row that is drawn registers itself, whether it was declared one by
68933
- // one or drawn by a run: what it says about itself (its value, whether it is
68934
- // selected) is written where it is drawn, in one place.
68935
- const item = props;
68936
- tracker.useTrackItem(item);
68937
- const groupTracker = useContext(GroupItemTrackerContext);
68938
- const groupVisibleIndex = groupTracker ? groupTracker.useTrackItem(item) : null;
68939
69135
  const separator = useContext(SeparatorContext);
68940
69136
  if (props.filtered) {
68941
69137
  return null;
68942
69138
  }
68943
- // html-hidden items: excluded from virtual scroll accounting but always in DOM
68944
- if (props.hidden) {
68945
- // Its separator stays too, and stays invisible with it: the point of
68946
- // keeping a row that matches nothing is that nothing moves, and a divider
68947
- // that leaves takes its own height away.
68948
- if (!separator || props.index === 0) {
68949
- return jsx(ListItemReal, {
68950
- ...props
68951
- });
68952
- }
68953
- return jsxs(Fragment, {
68954
- children: [cloneElement(resolveSeparatorVnode(separator, props.index - 1), {
68955
- style: VISIBILITY_HIDDEN_STYLE
68956
- }), jsx(ListItemReal, {
68957
- ...props
68958
- })]
68959
- });
68960
- }
68961
- if (row) {
68962
- return jsx(ListItemReal, {
68963
- ...props
68964
- });
68965
- }
68966
- const index = props.index;
68967
69139
  const listItemVnode = jsx(ListItemReal, {
68968
69140
  ...props
68969
69141
  });
68970
- // "Am I the first visible item?" is answered by the place the list handed
68971
- // out (virtual.take above), not by the tracker's visibleIndex: during a
68972
- // reorder render pass (items resorted by search score) the other items still
68973
- // carry stale keyToExplicitOrder values, the binary search reads them, no
68974
- // item comes out at 0 and a spurious separator appears at the top. Inside a
68975
- // group, each group has its own tracker and its items do not reorder, so
68976
- // groupVisibleIndex is reliable.
68977
- const isFirstInList = groupVisibleIndex === null ? index === 0 : groupVisibleIndex === 0;
68978
- if (!separator || isFirstInList) {
69142
+ // The separator a row wears is the one at the gap above it: none when
69143
+ // nothing of the list stands above it (see list_rows.js).
69144
+ if (!separator || listRows.isFirst(idDefault)) {
68979
69145
  return listItemVnode;
68980
69146
  }
68981
- // separatorIndex is only used as the function-form argument (gap index)
68982
- const separatorIndex = groupVisibleIndex === null ? index : groupVisibleIndex;
68983
- const separatorVnode = resolveSeparatorVnode(separator, separatorIndex - 1);
69147
+ // The gap index, only used as the function-form argument.
69148
+ let separatorVnode = resolveSeparatorVnode(separator, props.index - 1);
69149
+ if (props.hidden) {
69150
+ // A row kept in the DOM but hidden keeps its separator, hidden with it:
69151
+ // the point of keeping a row that matches nothing is that nothing moves,
69152
+ // and a divider that leaves takes its own height away.
69153
+ separatorVnode = cloneElement(separatorVnode, {
69154
+ style: VISIBILITY_HIDDEN_STYLE
69155
+ });
69156
+ }
68984
69157
  return jsxs(Fragment, {
68985
69158
  children: [separatorVnode, listItemVnode]
68986
69159
  });
@@ -69301,346 +69474,45 @@ const ListItem = /*#__PURE__*/createComponentResolver([ListItemFirstResolver, Li
69301
69474
  pure: true
69302
69475
  });
69303
69476
 
69304
- // Everything the list knows about the collection while its children are being
69305
- // rendered: how many rows it has in total, which of them are actually held, and
69306
- // where each child's rows start.
69307
- //
69308
- // A child knows how many rows it stands for but not what was declared before
69309
- // it, and it cannot deduce that from when it renders: a render is free to skip
69310
- // it. A child that draws from signals and whose props are all referentially
69311
- // === the previous ones does not render again (@preact/signals installs a
69312
- // shouldComponentUpdate that says so), which is what any child nobody rebuilt
69313
- // this frame is — and children numbered as they render would then slide up
69314
- // into the place of the one that was skipped.
69477
+ // The walk that gives the list's children their places: a slot for each of
69478
+ // them, declared to the list's rows all at once before any child renders,
69479
+ // and handed to the child through a provider of its own — which is what lets
69480
+ // the row reach it however deep the caller buried it in components of theirs.
69315
69481
  //
69316
- // So the places are read off the walk instead of off the renders: the list
69317
- // names a slot for each of its children and declares them here, in order,
69318
- // before any of them renders (see ListDeclaredChildren). A child then takes
69319
- // its place BY SLOT, and the place is a signal: it moves when what stands
69320
- // before it changes — a row filtered out, a run taking in rows, a slot added
69321
- // or moved — and the child follows, rendered again for it whether or not
69322
- // anything else would have rendered it.
69323
- const createListVirtual = () => {
69324
- const totalSignal = signal(0);
69325
- // Bumped whenever a run takes in rows. The list itself has to hear about it:
69326
- // rows arriving outside the render window change nothing it can see (nothing
69327
- // registers, nothing is drawn), and yet they are what it may have been
69328
- // waiting for — the row it was told to open on, for one.
69329
- const pagesSignal = signal(0);
69330
- // How many runs are re-reading rows they already show. The list wears it as
69331
- // an attribute: what is drawn is from before, and the app may want to say so
69332
- // without taking anything away.
69333
- const refreshingSignal = signal(0);
69334
- // The slots each walk declared, in order, by the slot the walk stands in
69335
- // (null for the list's own children). Together they are a tree: a group's
69336
- // rows live inside the group's slot.
69337
- const slotIdsByParent = new Map();
69338
- // Who took a place a row, or a run of rows — and how many rows of the
69339
- // collection it stands for. The place itself is a signal, see take.
69340
- const ownerById = new Map();
69341
- // The owners standing in each slot, in the order they took their place.
69342
- // One, as a rule; a child that renders several rows keeps them in the order
69343
- // they first rendered, which is all it can be told.
69344
- const ownerIdsBySlot = new Map();
69345
- const locatorByOwner = new Map();
69346
- // The slots as the tree reads, first to last, and where each stands in it.
69347
- // Rebuilt once a walk has changed the tree, read to place the owners.
69348
- const slotWalk = [];
69349
- const rankBySlot = new Map();
69350
- let rowTotal = 0;
69351
- // Owners have left and the others have not been moved up yet. Done on the
69352
- // next ask rather than on the spot: rows leave many at a time (a search, a
69353
- // list unmounting), and moving the others up once is enough.
69354
- let placesStale = false;
69355
- // Where the last slot holding an owner stands: an owner arriving at or after
69356
- // it is placed at the end without going over the others — a whole first
69357
- // render, rows arriving in order, costs each row nothing but itself.
69358
- let rankOwnedLast = -1;
69359
- const rebuildWalk = () => {
69360
- slotWalk.length = 0;
69361
- rankBySlot.clear();
69362
- const visit = parentSlotId => {
69363
- const slotIds = slotIdsByParent.get(parentSlotId);
69364
- if (!slotIds) {
69365
- return;
69366
- }
69367
- for (const slotId of slotIds) {
69368
- rankBySlot.set(slotId, slotWalk.length);
69369
- slotWalk.push(slotId);
69370
- visit(slotId);
69371
- }
69372
- };
69373
- visit(null);
69374
- };
69375
- // Every place, in one go: a place is the sum of what stands before it, so
69376
- // there is nothing to hand out one at a time. Writing a place that did not
69377
- // change wakes nobody — a signal ignores a value equal to its own.
69378
- const refreshPlaces = () => {
69379
- placesStale = false;
69380
- let index = 0;
69381
- let rank = 0;
69382
- rankOwnedLast = -1;
69383
- while (rank < slotWalk.length) {
69384
- const ownerIds = ownerIdsBySlot.get(slotWalk[rank]);
69385
- if (ownerIds) {
69386
- for (const ownerId of ownerIds) {
69387
- const owner = ownerById.get(ownerId);
69388
- owner.placeSignal.value = index;
69389
- index += owner.rowCount;
69390
- }
69391
- rankOwnedLast = rank;
69392
- }
69393
- rank++;
69394
- }
69395
- rowTotal = index;
69396
- totalSignal.value = index;
69397
- };
69398
- const addToSlot = (slotId, ownerId) => {
69399
- const ownerIds = ownerIdsBySlot.get(slotId);
69400
- if (ownerIds) {
69401
- ownerIds.push(ownerId);
69402
- } else {
69403
- ownerIdsBySlot.set(slotId, [ownerId]);
69404
- }
69405
- warnIfEveryRowInOneSlot(slotId);
69406
- };
69407
- // Rows that all stand in the same slot keep the order they first mounted in:
69408
- // the walk is over the children the list is given, and a component holding
69409
- // them is one child however many rows it renders. Everything about a place
69410
- // then stops following what the caller writes — a search reordering the rows
69411
- // moves nothing. Said once, and only for the shape that can be nothing else:
69412
- // the list's whole content is one child, and several rows came out of it.
69413
- let everyRowInOneSlotWarned = false;
69414
- const warnIfEveryRowInOneSlot = slotId => {
69415
- if (everyRowInOneSlotWarned) {
69416
- return;
69417
- }
69418
- const rootSlotIds = slotIdsByParent.get(null);
69419
- if (!rootSlotIds || rootSlotIds.length !== 1 || rootSlotIds[0] !== slotId) {
69420
- return;
69421
- }
69422
- if (ownerIdsBySlot.get(slotId).length < 2) {
69423
- return;
69424
- }
69425
- everyRowInOneSlotWarned = true;
69426
- console.warn(`List: every row stands in the same slot, so they keep the order they first rendered in — reordering them (a search, a sort) will not move them. The list's rows must be its own children: give it the rows (or a <List.Items>), not a component rendering them.`);
69427
- };
69428
- const removeFromSlot = (slotId, ownerId) => {
69429
- const ownerIds = ownerIdsBySlot.get(slotId);
69430
- if (!ownerIds) {
69431
- return;
69432
- }
69433
- const index = ownerIds.indexOf(ownerId);
69434
- if (index !== -1) {
69435
- ownerIds.splice(index, 1);
69436
- }
69437
- if (ownerIds.length === 0) {
69438
- ownerIdsBySlot.delete(slotId);
69439
- }
69440
- };
69441
- // A slot the walk no longer names: whatever stood in it is gone, and so is
69442
- // whatever a walk inside it had declared.
69443
- const dropSlot = slotId => {
69444
- const ownerIds = ownerIdsBySlot.get(slotId);
69445
- if (ownerIds) {
69446
- for (const ownerId of ownerIds) {
69447
- ownerById.delete(ownerId);
69448
- }
69449
- ownerIdsBySlot.delete(slotId);
69450
- }
69451
- const childSlotIds = slotIdsByParent.get(slotId);
69452
- if (childSlotIds) {
69453
- slotIdsByParent.delete(slotId);
69454
- for (const childSlotId of childSlotIds) {
69455
- dropSlot(childSlotId);
69456
- }
69457
- }
69458
- };
69459
- const virtual = {
69460
- totalSignal,
69461
- pagesSignal,
69462
- refreshingSignal,
69463
- // What a run needs to know about the list it lives in: how many rows the
69464
- // list is willing to draw at once, which end it opens on, and how much
69465
- // room one row is given — a row whose content has not arrived must take
69466
- // exactly that, or the rows drawn would not reach where the list says they
69467
- // are.
69468
- renderBudget: 0,
69469
- scrolled: "start",
69470
- // The list is on its way somewhere: what the window frames is not what it
69471
- // is about to frame, so a run must not fetch for it (see holdWindow).
69472
- holdPending: false,
69473
- // Called by a run just before rows land in it: what is on screen must not
69474
- // move because something arrived above it. Set by the list itself.
69475
- captureAnchor: () => {},
69476
- horizontal: false,
69477
- virtualItemSizeSignal: null,
69478
- renderSkeleton: undefined,
69479
- // The children a walk stands over, in order — said in one call, before any
69480
- // of them renders, so that what a child asks next is answered against the
69481
- // whole picture and not against the children that happened to render
69482
- // first. Said again on every render of the walk, and heard only when
69483
- // something moved.
69484
- declareSlots: (parentSlotId, slotIds) => {
69485
- const slotIdsPrevious = slotIdsByParent.get(parentSlotId);
69486
- if (slotIdsPrevious && sameSlotIds(slotIdsPrevious, slotIds)) {
69487
- return;
69488
- }
69489
- if (slotIdsPrevious) {
69490
- const slotIdSet = new Set(slotIds);
69491
- for (const slotId of slotIdsPrevious) {
69492
- if (!slotIdSet.has(slotId)) {
69493
- dropSlot(slotId);
69494
- }
69495
- }
69496
- }
69497
- slotIdsByParent.set(parentSlotId, slotIds);
69498
- rebuildWalk();
69499
- refreshPlaces();
69500
- },
69501
- // Whether something has taken this slot for its own: what it renders
69502
- // inside is then its to place (a run draws its groups with their rows
69503
- // already placed), and no walk inside it has anything to declare.
69504
- slotHasOwner: slotId => ownerIdsBySlot.has(slotId),
69505
- // Whether any run of rows lives in this list: what makes a render window
69506
- // mean anything (see List's renderBudget).
69507
- hasRuns: () => locatorByOwner.size > 0,
69508
- setRowLocator: (ownerId, locate) => {
69509
- locatorByOwner.set(ownerId, locate);
69510
- },
69511
- dropRowLocator: ownerId => {
69512
- locatorByOwner.delete(ownerId);
69513
- },
69514
- // Where the row named by that id sits, asked of whoever holds it.
69515
- locateRow: id => {
69516
- for (const locate of locatorByOwner.values()) {
69517
- const index = locate(id);
69518
- if (index !== null) {
69519
- return index;
69520
- }
69521
- }
69522
- return null;
69523
- },
69524
- // The place the owner's rows start at — read from a signal, so that the
69525
- // owner is rendered again when it moves (see createListVirtual). Asked on
69526
- // every render, and answered without a second look for as long as the
69527
- // owner stands in the same slot for the same number of rows.
69528
- take: (ownerId, rowCount, slotId) => {
69529
- let owner = ownerById.get(ownerId);
69530
- if (owner) {
69531
- if (owner.slotId !== slotId || owner.rowCount !== rowCount) {
69532
- removeFromSlot(owner.slotId, ownerId);
69533
- addToSlot(slotId, ownerId);
69534
- owner.slotId = slotId;
69535
- owner.rowCount = rowCount;
69536
- placesStale = true;
69537
- }
69538
- if (placesStale) {
69539
- refreshPlaces();
69540
- }
69541
- return owner.placeSignal.value;
69542
- }
69543
- if (placesStale) {
69544
- refreshPlaces();
69545
- }
69546
- const rank = rankBySlot.get(slotId);
69547
- addToSlot(slotId, ownerId);
69548
- if (rank !== undefined && rank >= rankOwnedLast) {
69549
- owner = {
69550
- slotId,
69551
- rowCount,
69552
- placeSignal: signal(rowTotal)
69553
- };
69554
- ownerById.set(ownerId, owner);
69555
- rowTotal += rowCount;
69556
- rankOwnedLast = rank;
69557
- totalSignal.value = rowTotal;
69558
- return owner.placeSignal.value;
69559
- }
69560
- owner = {
69561
- slotId,
69562
- rowCount,
69563
- placeSignal: signal(0)
69564
- };
69565
- ownerById.set(ownerId, owner);
69566
- refreshPlaces();
69567
- return owner.placeSignal.value;
69568
- },
69569
- // The owner stands for no row of the collection: it was filtered out by a
69570
- // search, or it is gone.
69571
- drop: ownerId => {
69572
- const owner = ownerById.get(ownerId);
69573
- if (!owner) {
69574
- return;
69575
- }
69576
- ownerById.delete(ownerId);
69577
- removeFromSlot(owner.slotId, ownerId);
69578
- if (placesStale) {
69579
- return;
69580
- }
69581
- placesStale = true;
69582
- queueMicrotask(() => {
69583
- if (placesStale) {
69584
- refreshPlaces();
69585
- }
69586
- });
69587
- }
69588
- };
69589
- return virtual;
69590
- };
69591
- const sameSlotIds = (left, right) => {
69592
- if (left.length !== right.length) {
69593
- return false;
69594
- }
69595
- let index = 0;
69596
- while (index < left.length) {
69597
- if (left[index] !== right[index]) {
69598
- return false;
69599
- }
69600
- index++;
69601
- }
69602
- return true;
69603
- };
69604
-
69605
- // The walk that gives the list's children their places: a slot for each of
69606
- // them, declared to the list's virtual all at once before any child renders,
69607
- // and handed to the child through a provider of its own — which is what lets
69608
- // the row reach it however deep the caller buried it in components of theirs.
69609
- //
69610
- // A slot is named the way preact tells the child apart: by key when it has
69611
- // one, by position otherwise, and inside the array it was given in — a nested
69612
- // array is one child to preact, so what follows the array keeps its name
69613
- // however many rows the array holds. A child preact would not render (null,
69614
- // a boolean) has no slot: it is not there.
69615
- const ListDeclaredChildren = ({
69616
- children
69617
- }) => {
69618
- const virtual = useContext(ListVirtualContext);
69619
- const parentSlotId = useContext(ListSlotContext);
69620
- if (parentSlotId !== null && virtual.slotHasOwner(parentSlotId)) {
69621
- return children;
69622
- }
69623
- const slotIds = [];
69624
- const declared = [];
69625
- declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
69626
- virtual.declareSlots(parentSlotId, slotIds);
69627
- return jsx(Fragment, {
69628
- children: declared
69629
- });
69630
- };
69631
- const declareChildren = (children, prefix, slotIds, declared) => {
69632
- const childArray = Array.isArray(children) ? children : [children];
69633
- let index = 0;
69634
- for (const child of childArray) {
69635
- if (Array.isArray(child)) {
69636
- declareChildren(child, `${prefix}${index}/`, slotIds, declared);
69637
- } else if (child !== null && child !== undefined && child !== false && child !== true) {
69638
- const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
69639
- slotIds.push(slotId);
69640
- declared.push(jsx(ListSlotContext.Provider, {
69641
- value: slotId,
69642
- children: child
69643
- }, slotId));
69482
+ // A slot is named the way preact tells the child apart: by key when it has
69483
+ // one, by position otherwise, and inside the array it was given in — a nested
69484
+ // array is one child to preact, so what follows the array keeps its name
69485
+ // however many rows the array holds. A child preact would not render (null,
69486
+ // a boolean) has no slot: it is not there.
69487
+ const ListDeclaredChildren = ({
69488
+ children
69489
+ }) => {
69490
+ const listRows = useContext(ListRowsContext);
69491
+ const parentSlotId = useContext(ListSlotContext);
69492
+ if (parentSlotId !== null && listRows.slotHasOwner(parentSlotId)) {
69493
+ return children;
69494
+ }
69495
+ const slotIds = [];
69496
+ const declared = [];
69497
+ declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
69498
+ listRows.declareSlots(parentSlotId, slotIds);
69499
+ return jsx(Fragment, {
69500
+ children: declared
69501
+ });
69502
+ };
69503
+ const declareChildren = (children, prefix, slotIds, declared) => {
69504
+ const childArray = Array.isArray(children) ? children : [children];
69505
+ let index = 0;
69506
+ for (const child of childArray) {
69507
+ if (Array.isArray(child)) {
69508
+ declareChildren(child, `${prefix}${index}/`, slotIds, declared);
69509
+ } else if (child !== null && child !== undefined && child !== false && child !== true) {
69510
+ const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
69511
+ slotIds.push(slotId);
69512
+ declared.push(jsx(ListSlotContext.Provider, {
69513
+ value: slotId,
69514
+ children: child
69515
+ }, slotId));
69644
69516
  }
69645
69517
  index++;
69646
69518
  }
@@ -69782,7 +69654,7 @@ const ListItems = ({
69782
69654
  onRequestStateChange
69783
69655
  }) => {
69784
69656
  const ownerId = useId();
69785
- const virtual = useContext(ListVirtualContext);
69657
+ const listRows = useContext(ListRowsContext);
69786
69658
  const slotId = useContext(ListSlotContext);
69787
69659
  const renderWindow = useContext(RenderWindowContext);
69788
69660
  const separator = useContext(SeparatorContext);
@@ -69794,10 +69666,9 @@ const ListItems = ({
69794
69666
  // row at the same index, in the same refreshing state: everything the
69795
69667
  // function is given.
69796
69668
  const rowVnodesRef = useRef(null);
69797
- if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem || rowVnodesRef.current.separator !== separator) {
69669
+ if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem) {
69798
69670
  rowVnodesRef.current = {
69799
69671
  renderItem,
69800
- separator,
69801
69672
  byItem: new Map()
69802
69673
  };
69803
69674
  }
@@ -69809,7 +69680,7 @@ const ListItems = ({
69809
69680
  memoryBudget,
69810
69681
  onRequestStateChange
69811
69682
  });
69812
- const renderRowSkeleton = renderSkeleton === undefined ? virtual.renderSkeleton : renderSkeleton;
69683
+ const renderRowSkeleton = renderSkeleton === undefined ? listRows.renderSkeleton : renderSkeleton;
69813
69684
  // A row on its way takes the room the list reserves for it: anything else
69814
69685
  // and the rows drawn stop short of where the scroll says they are. Read
69815
69686
  // where a row is actually missing, and not before: the size settles after
@@ -69821,9 +69692,9 @@ const ListItems = ({
69821
69692
  return skeletonRow;
69822
69693
  }
69823
69694
  skeletonRow = {};
69824
- const virtualItemSize = virtual.virtualItemSizeSignal.value;
69695
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
69825
69696
  if (virtualItemSize) {
69826
- if (virtual.horizontal) {
69697
+ if (listRows.horizontal) {
69827
69698
  skeletonRow.rowMinWidth = `${virtualItemSize}px`;
69828
69699
  } else {
69829
69700
  skeletonRow.rowMinHeight = `${virtualItemSize}px`;
@@ -69831,7 +69702,7 @@ const ListItems = ({
69831
69702
  }
69832
69703
  return skeletonRow;
69833
69704
  };
69834
- const runStart = virtual.take(ownerId, store.rowCount, slotId);
69705
+ const runStart = listRows.take(ownerId, store.rowCount, slotId);
69835
69706
  const runEnd = runStart + store.rowCount;
69836
69707
  // The two ways to count the same row. The list numbers its rows from its own
69837
69708
  // first one, whatever draws it; the store numbers the collection's, straight
@@ -69846,6 +69717,7 @@ const ListItems = ({
69846
69717
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
69847
69718
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
69848
69719
  store.forget(rankOf(windowFrom), rankOf(windowTo));
69720
+ listRows.declareWindow(ownerId, windowFrom, windowTo);
69849
69721
 
69850
69722
  // The row answers to its own id when the item carries one — that is what
69851
69723
  // addresses it from outside (--navi-select, --navi-scroll, startAt) — and
@@ -69855,7 +69727,7 @@ const ListItems = ({
69855
69727
  // Where a row named from outside actually sits. Only the run can answer:
69856
69728
  // rows it holds but does not draw are nowhere else — a list only knows the
69857
69729
  // rows it has drawn (they register themselves, see ListItemUI).
69858
- virtual.setRowLocator(ownerId, id => {
69730
+ listRows.setRowLocator(ownerId, id => {
69859
69731
  let found = null;
69860
69732
  store.eachHeld((item, rank) => {
69861
69733
  const rowIndex = rowOf(rank);
@@ -69867,8 +69739,8 @@ const ListItems = ({
69867
69739
  });
69868
69740
  useLayoutEffect(() => {
69869
69741
  return () => {
69870
- virtual.dropRowLocator(ownerId);
69871
- virtual.drop(ownerId);
69742
+ listRows.dropRowLocator(ownerId);
69743
+ listRows.drop(ownerId);
69872
69744
  };
69873
69745
  }, []);
69874
69746
 
@@ -69894,7 +69766,7 @@ const ListItems = ({
69894
69766
  let askStart = missingStart;
69895
69767
  let askEnd = missingEnd;
69896
69768
  if (missingStart !== -1) {
69897
- const rowsPerPage = pageSize || virtual.renderBudget;
69769
+ const rowsPerPage = pageSize || listRows.renderBudget;
69898
69770
  const holeSize = missingEnd - missingStart + 1;
69899
69771
  if (holeSize < rowsPerPage) {
69900
69772
  // Which way the page grows: away from the rows already held, which is
@@ -69969,13 +69841,9 @@ const ListItems = ({
69969
69841
  }, `${ownerId}_group_${group.key}`));
69970
69842
  group = null;
69971
69843
  };
69972
- // Which group a row belongs to, or undefined when it belongs to none. Asked
69973
- // before the row is pushed as well as while pushing it: a row opening a
69974
- // group is the one row that must not wear a separator (see below).
69844
+ // Which group a row belongs to, or undefined when it belongs to none.
69975
69845
  const groupKeyOf = (item, rowIndex) => groupBy && item !== undefined ? groupBy(item, rowIndex) : undefined;
69976
- const opensGroup = groupKey => groupKey !== undefined && (!group || group.key !== groupKey);
69977
- const pushRow = (rowNode, item, rowIndex) => {
69978
- const groupKey = groupKeyOf(item, rowIndex);
69846
+ const pushRow = (rowNode, item, rowIndex, groupKey) => {
69979
69847
  if (groupKey === undefined) {
69980
69848
  closeGroup();
69981
69849
  rows.push(rowNode);
@@ -70013,7 +69881,7 @@ const ListItems = ({
70013
69881
  rows.push(jsx("li", {
70014
69882
  className: "navi_list_failed_rows",
70015
69883
  style: {
70016
- "--size-to-fill": `${failedRowCount * virtual.virtualItemSizeSignal.value}px`
69884
+ "--size-to-fill": `${failedRowCount * listRows.virtualItemSizeSignal.value}px`
70017
69885
  },
70018
69886
  children: renderError ? renderError({
70019
69887
  error: store.failure.error,
@@ -70030,78 +69898,70 @@ const ListItems = ({
70030
69898
  }
70031
69899
  const item = getItemAt(rowIndex);
70032
69900
  const key = item === undefined ? `${ownerId}_skeleton_${rowIndex}` : idOf(item, rowIndex);
69901
+ const groupKey = groupKeyOf(item, rowIndex);
69902
+ if (item === undefined) {
69903
+ // A row on its way never reaches ListItemUI (see ListItemSkeletonResolver):
69904
+ // it is stood among the rows that mount, and given its separator, here.
69905
+ let rowVnode;
69906
+ if (renderRowSkeleton === false) {
69907
+ // The row must still take its room: without it the rows below would
69908
+ // climb up and slide back down as the answer arrives.
69909
+ rowVnode = jsx(ListItem, {
69910
+ skeleton: true,
69911
+ style: VISIBILITY_HIDDEN_STYLE
69912
+ });
69913
+ } else if (renderRowSkeleton) {
69914
+ rowVnode = renderRowSkeleton(rowIndex);
69915
+ } else {
69916
+ rowVnode = jsx(ListItem, {
69917
+ skeleton: true
69918
+ });
69919
+ }
69920
+ if (rowVnode) {
69921
+ pushRow(jsx(ListRunSkeletonRow, {
69922
+ row: {
69923
+ id: key,
69924
+ index: rowIndex,
69925
+ ownerId,
69926
+ ...getSkeletonRow()
69927
+ },
69928
+ separator: separator,
69929
+ children: rowVnode
69930
+ }, key), item, rowIndex, groupKey);
69931
+ }
69932
+ rowIndex++;
69933
+ continue;
69934
+ }
70033
69935
  let rowVnode;
70034
69936
  let rowContextValue;
70035
- let rowKept = null;
70036
- if (item !== undefined) {
70037
- const rowVnodeKept = rowVnodesByItem.get(item);
70038
- if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
70039
- rowVnode = rowVnodeKept.vnode;
70040
- rowContextValue = rowVnodeKept.rowContextValue;
70041
- rowKept = rowVnodeKept;
70042
- } else {
70043
- rowVnode = renderItem(item, rowIndex, renderItemState);
70044
- // Kept with the vnode, for the same reason: a context value that is a
70045
- // fresh object on every render forces every consumer of it to render,
70046
- // which is the row's own chain — the vnode handed back unchanged would
70047
- // then buy nothing.
70048
- rowContextValue = {
70049
- id: key,
70050
- index: rowIndex,
70051
- item
70052
- };
70053
- rowKept = {
70054
- vnode: rowVnode,
70055
- rowContextValue,
70056
- rowIndex,
70057
- refreshing: renderItemState.refreshing,
70058
- separatorVnode: null
70059
- };
70060
- rowVnodesByItem.set(item, rowKept);
70061
- }
70062
- } else if (renderRowSkeleton === false) {
70063
- // The row must still take its room: without it the rows below would
70064
- // climb up and slide back down as the answer arrives.
70065
- rowVnode = jsx(ListItem, {
70066
- skeleton: true,
70067
- style: VISIBILITY_HIDDEN_STYLE
70068
- });
70069
- } else if (renderRowSkeleton) {
70070
- rowVnode = renderRowSkeleton(rowIndex);
69937
+ const rowVnodeKept = rowVnodesByItem.get(item);
69938
+ if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
69939
+ rowVnode = rowVnodeKept.vnode;
69940
+ rowContextValue = rowVnodeKept.rowContextValue;
70071
69941
  } else {
70072
- rowVnode = jsx(ListItem, {
70073
- skeleton: true
69942
+ rowVnode = renderItem(item, rowIndex, renderItemState);
69943
+ // Kept with the vnode, for the same reason: a context value that is a
69944
+ // fresh object on every render forces every consumer of it to render,
69945
+ // which is the row's own chain — the vnode handed back unchanged would
69946
+ // then buy nothing.
69947
+ rowContextValue = {
69948
+ id: key,
69949
+ index: rowIndex,
69950
+ item,
69951
+ ownerId
69952
+ };
69953
+ rowVnodesByItem.set(item, {
69954
+ vnode: rowVnode,
69955
+ rowContextValue,
69956
+ rowIndex,
69957
+ refreshing: renderItemState.refreshing
70074
69958
  });
70075
69959
  }
70076
69960
  if (rowVnode) {
70077
- // The first row of a group wears no separator: the gap it sits at is the
70078
- // one between two groups, and that gap is the group wrapper's own — it
70079
- // is a row of the list like any other and draws its separator itself
70080
- // (see ListItemUI). Drawn here it would land inside the group instead,
70081
- // as a hairline under the label.
70082
- const drawSeparator = separator && rowIndex > 0 && !opensGroup(groupKeyOf(item, rowIndex));
70083
- if (drawSeparator) {
70084
- // Kept with the row too: a separator built again is a separator
70085
- // rendered again.
70086
- let separatorVnode = rowKept ? rowKept.separatorVnode : null;
70087
- if (!separatorVnode) {
70088
- separatorVnode = cloneElement(resolveSeparatorVnode(separator, rowIndex - 1), {
70089
- key: `${key}_separator`
70090
- });
70091
- if (rowKept) {
70092
- rowKept.separatorVnode = separatorVnode;
70093
- }
70094
- }
70095
- pushRow(separatorVnode, item, rowIndex);
70096
- }
70097
69961
  pushRow(jsx(ListRowContext.Provider, {
70098
- value: item === undefined ? {
70099
- id: key,
70100
- index: rowIndex,
70101
- ...getSkeletonRow()
70102
- } : rowContextValue,
69962
+ value: rowContextValue,
70103
69963
  children: rowVnode
70104
- }, key), item, rowIndex);
69964
+ }, key), item, rowIndex, groupKey);
70105
69965
  }
70106
69966
  rowIndex++;
70107
69967
  }
@@ -70115,6 +69975,44 @@ const ListItems = ({
70115
69975
  return rows;
70116
69976
  };
70117
69977
 
69978
+ // A run's row that has not arrived, standing where the real one will. It never
69979
+ // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
69980
+ // rows here, and wears the separator of the gap above it the way a real row
69981
+ // does there.
69982
+ const SKELETON_ROW_DATA = {
69983
+ skeleton: true
69984
+ };
69985
+ const ListRunSkeletonRow = ({
69986
+ row,
69987
+ separator,
69988
+ children
69989
+ }) => {
69990
+ const listRows = useContext(ListRowsContext);
69991
+ const groupId = useContext(ListGroupContext);
69992
+ const rowId = useId();
69993
+ listRows.draw(rowId, {
69994
+ ownerId: row.ownerId,
69995
+ place: row.index,
69996
+ groupId,
69997
+ data: SKELETON_ROW_DATA
69998
+ });
69999
+ useLayoutEffect(() => {
70000
+ return () => {
70001
+ listRows.erase(rowId);
70002
+ };
70003
+ }, []);
70004
+ const rowVnode = jsx(ListRowContext.Provider, {
70005
+ value: row,
70006
+ children: children
70007
+ });
70008
+ if (!separator || listRows.isFirst(rowId)) {
70009
+ return rowVnode;
70010
+ }
70011
+ return jsxs(Fragment, {
70012
+ children: [resolveSeparatorVnode(separator, row.index - 1), rowVnode]
70013
+ });
70014
+ };
70015
+
70118
70016
  // What is drawn where rows were asked for and never came: the sentence and the
70119
70017
  // way out, in the row itself — the rest of the list is fine, so replacing all
70120
70018
  // of it (List's own `error`) would be a lie.
@@ -70260,7 +70158,7 @@ const useItemStore = ({
70260
70158
  // where the hole is, and cleared by a retry — which is what makes the same
70261
70159
  // range askable again (see the request memory just above).
70262
70160
  const [failure, setFailure] = useState(null);
70263
- const virtual = useContext(ListVirtualContext);
70161
+ const listRows = useContext(ListRowsContext);
70264
70162
  // The rows are there, which is what the list waits for to place itself on the
70265
70163
  // row it is held at (see placeWhereHeld). Said from an effect: a signal read
70266
70164
  // during this very render must not be written during it.
@@ -70269,12 +70167,12 @@ const useItemStore = ({
70269
70167
  return;
70270
70168
  }
70271
70169
  itemsHeldRef.current = true;
70272
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
70170
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
70273
70171
  });
70274
70172
  // Before the first answer a run does not know how many rows it stands for.
70275
70173
  // It stands for a windowful of them: a list that is about to be filled looks
70276
70174
  // like rows on their way, not like an empty list.
70277
- const rowCount = pages.count ?? count ?? virtual.renderBudget;
70175
+ const rowCount = pages.count ?? count ?? listRows.renderBudget;
70278
70176
  // A run that never received anything has nothing to keep on screen: asking
70279
70177
  // again is its first ask, not a refresh.
70280
70178
  if (staleRef.current && pages.count === undefined) {
@@ -70284,9 +70182,9 @@ const useItemStore = ({
70284
70182
  if (!refreshing) {
70285
70183
  return null;
70286
70184
  }
70287
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() + 1;
70185
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() + 1;
70288
70186
  return () => {
70289
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
70187
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() - 1;
70290
70188
  };
70291
70189
  }, [refreshing]);
70292
70190
 
@@ -70385,7 +70283,7 @@ const useItemStore = ({
70385
70283
  // how many rows there are, so it asks for the rows the list would open
70386
70284
  // on — counting back from the end when that is where it opens, the way
70387
70285
  // an HTTP range does.
70388
- const budget = virtual.renderBudget;
70286
+ const budget = listRows.renderBudget;
70389
70287
  let start = missingStart;
70390
70288
  let end = missingEnd;
70391
70289
  let around;
@@ -70396,11 +70294,11 @@ const useItemStore = ({
70396
70294
  // The list is held on a row nothing on screen leads to: the rows it holds
70397
70295
  // do not contain it, so no window it could draw will ever bring it. Only
70398
70296
  // asking for it by name does.
70399
- const wanted = virtual.scrolled;
70297
+ const wanted = listRows.scrolled;
70400
70298
  const askingAroundWantedRow = revalidating &&
70401
70299
  // Only while the hold stands: once the user has taken the list over,
70402
70300
  // the reading position is where they are, not where it opened.
70403
- virtual.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && virtual.locateRow(wanted.id) === null;
70301
+ listRows.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && listRows.locateRow(wanted.id) === null;
70404
70302
  if (askingAroundWantedRow) {
70405
70303
  around = wanted.id;
70406
70304
  // Where it stood when it was written down is enough to frame the ask;
@@ -70423,7 +70321,7 @@ const useItemStore = ({
70423
70321
  around = firstHeld.id;
70424
70322
  }
70425
70323
  } else if (pages.count === undefined) {
70426
- const scrolled = virtual.scrolled;
70324
+ const scrolled = listRows.scrolled;
70427
70325
  if (scrolled === "end") {
70428
70326
  // Counting back from the end, the way an HTTP range does: a list
70429
70327
  // opening on its last rows asks for them before it knows how many
@@ -70454,7 +70352,7 @@ const useItemStore = ({
70454
70352
  // way somewhere the window does not frame yet, `count` that it knows
70455
70353
  // how many rows it stands for.
70456
70354
  const debugAsk = outcome => {
70457
- debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${virtual.holdPending} count=${pages.count})`);
70355
+ debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${listRows.holdPending} count=${pages.count})`);
70458
70356
  };
70459
70357
  if (start === -1) {
70460
70358
  // Nothing missing and nothing to revalidate: the run has what it
@@ -70462,7 +70360,7 @@ const useItemStore = ({
70462
70360
  debugAsk("nothing missing");
70463
70361
  return;
70464
70362
  }
70465
- if (virtual.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
70363
+ if (listRows.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
70466
70364
  // The one ask a hold lets through: the row the list is held on is
70467
70365
  // what would lift the hold, and nothing else is going to bring it.
70468
70366
  debugAsk("held on a row not reached yet");
@@ -70553,7 +70451,7 @@ const useItemStore = ({
70553
70451
  const pageCount = Array.isArray(page) ? pageItems.length : page.count ?? pageStart + pageItems.length;
70554
70452
  // Before the rows land: what is on screen has to stay where it is,
70555
70453
  // and the DOM still shows the state to hold onto.
70556
- virtual.captureAnchor();
70454
+ listRows.captureAnchor();
70557
70455
  if (revalidating) {
70558
70456
  // The rows held stood for a composition that has moved on; the
70559
70457
  // ones outside the window are forgotten and asked for again if the
@@ -70575,7 +70473,7 @@ const useItemStore = ({
70575
70473
  replace: revalidating
70576
70474
  });
70577
70475
  }
70578
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
70476
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
70579
70477
  setPageVersion(version => version + 1);
70580
70478
  };
70581
70479
  const failed = error => {
@@ -70643,10 +70541,16 @@ const ListItemGroup = ({
70643
70541
  ...rest
70644
70542
  }) => {
70645
70543
  const groupId = useId();
70646
- const groupTracker = useItemTracker();
70544
+ const listRows = useContext(ListRowsContext);
70545
+ const group = listRows.group(groupId);
70546
+ useLayoutEffect(() => {
70547
+ return () => {
70548
+ listRows.dropGroup(groupId);
70549
+ };
70550
+ }, []);
70647
70551
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
70648
- const groupItemCount = groupTracker.countSignal.value;
70649
- const groupNoMatchCount = groupTracker.noMatchCountSignal.value;
70552
+ const groupItemCount = group.countSignal.value;
70553
+ const groupNoMatchCount = group.noMatchCountSignal.value;
70650
70554
  // Every row of this group failed the search: the label has nothing left to
70651
70555
  // title. "remove" empties the group on its own (and hiddenWhileEmpty takes it
70652
70556
  // out of the flow), "muted" keeps the rows readable so the label stays useful
@@ -70690,8 +70594,8 @@ const ListItemGroup = ({
70690
70594
  className: "navi_list_item_group_list",
70691
70595
  role: "group",
70692
70596
  "aria-labelledby": groupId,
70693
- children: jsx(GroupItemTrackerContext.Provider, {
70694
- value: groupTracker,
70597
+ children: jsx(ListGroupContext.Provider, {
70598
+ value: groupId,
70695
70599
  children: jsx(ListDeclaredChildren, {
70696
70600
  children: children
70697
70601
  })
@@ -76080,414 +75984,812 @@ const SplitButton = props => {
76080
75984
  });
76081
75985
  };
76082
75986
 
76083
- // What the Picker's popup answers to — Picker's own popup props, named here so
76084
- // a caller reaches all of them through the split button (see picker.jsx's JSDoc
76085
- // for what each one says).
76086
- const POPUP_PROP_SET = new Set(["mode", "popupLayer", "popupTestId", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMinWidth", "dialogMinHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdrop", "backdropVariant", "backdropColor", "backdropFilter", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
76087
- const splitPopupProps = props => {
76088
- const popupProps = {};
76089
- const boxProps = {};
76090
- for (const key of Object.keys(props)) {
76091
- if (POPUP_PROP_SET.has(key)) {
76092
- popupProps[key] = props[key];
75987
+ // What the Picker's popup answers to — Picker's own popup props, named here so
75988
+ // a caller reaches all of them through the split button (see picker.jsx's JSDoc
75989
+ // for what each one says).
75990
+ const POPUP_PROP_SET = new Set(["mode", "popupLayer", "popupTestId", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMinWidth", "dialogMinHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdrop", "backdropVariant", "backdropColor", "backdropFilter", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
75991
+ const splitPopupProps = props => {
75992
+ const popupProps = {};
75993
+ const boxProps = {};
75994
+ for (const key of Object.keys(props)) {
75995
+ if (POPUP_PROP_SET.has(key)) {
75996
+ popupProps[key] = props[key];
75997
+ } else {
75998
+ boxProps[key] = props[key];
75999
+ }
76000
+ }
76001
+ return [popupProps, boxProps];
76002
+ };
76003
+
76004
+ /**
76005
+ * applySearch — matches value against searchText.
76006
+ *
76007
+ * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
76008
+ * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
76009
+ * Multi-word: if searchText contains spaces, each word must appear somewhere in
76010
+ * the value for it to match. Ranges for all words are returned.
76011
+ *
76012
+ * Score table:
76013
+ *
76014
+ * Situation Score
76015
+ * ─────────────────────────────────────── ───────────────────────────
76016
+ * phrase at start of value 1
76017
+ * multi-word, one word at start (all match) 0.75
76018
+ * phrase / word at word boundary 0.625
76019
+ * phrase / words mid-word 0.5
76020
+ * + case-exact bonus +0.125
76021
+ * multi-word partial: score × (matched/total)
76022
+ *
76023
+ * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
76024
+ * Intended to be passed to useSearch as the matchFn parameter.
76025
+ */
76026
+ const applySearch = (searchText, value) => {
76027
+ if (!searchText) {
76028
+ return { match: true, matchScore: 0, matchRanges: [] };
76029
+ }
76030
+ if (searchText.length > 100) {
76031
+ searchText = searchText.slice(0, 100);
76032
+ }
76033
+ const str = String(value);
76034
+ const foldedStr = foldAccents(str).toLowerCase();
76035
+ const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
76036
+
76037
+ // Try exact phrase match first (gives best score).
76038
+ const phraseRanges = [];
76039
+ let phraseIdx = foldedStr.indexOf(foldedSearch);
76040
+ while (phraseIdx !== -1) {
76041
+ phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
76042
+ phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
76043
+ }
76044
+ if (phraseRanges.length > 0) {
76045
+ const atStart = foldedStr.startsWith(foldedSearch);
76046
+ const atWordBoundary = phraseRanges.some(([start]) =>
76047
+ isWordBoundary(foldedStr, start),
76048
+ );
76049
+ const caseExact = str.includes(searchText);
76050
+ let baseScore;
76051
+ if (atStart) {
76052
+ baseScore = SCORE_PHRASE_AT_START;
76053
+ } else if (atWordBoundary) {
76054
+ baseScore = SCORE_AT_WORD_BOUNDARY;
76055
+ } else {
76056
+ baseScore = SCORE_MID_WORD;
76057
+ }
76058
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76059
+ return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
76060
+ }
76061
+
76062
+ // Multi-word OR: split on whitespace, any word matching contributes to the score.
76063
+ // Items where all words match rank higher than partial matches.
76064
+ // Note: words always has at least 1 element here (searchText is non-empty and
76065
+ // foldedSearch.split filters empty strings). This path also handles the case
76066
+ // where searchText has trailing/leading spaces: the phrase match above tries
76067
+ // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
76068
+ // here to try each word individually (e.g. "tc" matches "tca").
76069
+ const matchRanges = [];
76070
+ let matchedWordCount = 0;
76071
+ let anyWordAtStart = false;
76072
+ let anyWordAtWordBoundary = false;
76073
+ let allMatchedWordsExact = true;
76074
+ for (let w = 0; w < words.length; w++) {
76075
+ const word = words[w];
76076
+ const originalWord = originalWords[w];
76077
+ let idx = foldedStr.indexOf(word);
76078
+ if (idx === -1) {
76079
+ continue;
76080
+ }
76081
+ matchedWordCount++;
76082
+ let wordHasExactMatch = false;
76083
+ while (idx !== -1) {
76084
+ matchRanges.push([idx, idx + word.length]);
76085
+ if (idx === 0) {
76086
+ anyWordAtStart = true;
76087
+ anyWordAtWordBoundary = true;
76088
+ } else if (isWordBoundary(foldedStr, idx)) {
76089
+ anyWordAtWordBoundary = true;
76090
+ }
76091
+ if (str.slice(idx, idx + word.length) === originalWord) {
76092
+ wordHasExactMatch = true;
76093
+ }
76094
+ idx = foldedStr.indexOf(word, idx + 1);
76095
+ }
76096
+ if (!wordHasExactMatch) {
76097
+ allMatchedWordsExact = false;
76098
+ }
76099
+ }
76100
+ if (matchedWordCount === 0) {
76101
+ return tryAcronymMatch(foldedStr, str, searchText);
76102
+ }
76103
+ const wordRatio = matchedWordCount / words.length;
76104
+ let baseScore;
76105
+ if (anyWordAtStart) {
76106
+ baseScore = SCORE_MULTI_WORD_AT_START;
76107
+ } else if (anyWordAtWordBoundary) {
76108
+ baseScore = SCORE_AT_WORD_BOUNDARY;
76109
+ } else {
76110
+ baseScore = SCORE_MID_WORD;
76111
+ }
76112
+ const matchScore =
76113
+ (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
76114
+ wordRatio;
76115
+ return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
76116
+ };
76117
+
76118
+ // Returns true when position idx in str is at a word boundary,
76119
+ // meaning it is either the start of the string or the preceding character
76120
+ // is not a Unicode letter or digit.
76121
+ const isWordBoundary = (str, idx) => {
76122
+ if (idx === 0) {
76123
+ return true;
76124
+ }
76125
+ return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
76126
+ };
76127
+
76128
+ // Strip diacritics for accent-insensitive matching.
76129
+ // NFC normalization first ensures precomposed characters (é → single code unit),
76130
+ // so the folded string has the same length as the NFC source — ranges computed
76131
+ // on the folded string map 1:1 to positions in the original string.
76132
+ const foldAccents = (str) => {
76133
+ return str
76134
+ .normalize("NFC")
76135
+ .normalize("NFD")
76136
+ .replace(/\p{Mn}/gu, "");
76137
+ };
76138
+
76139
+ const SCORE_PHRASE_AT_START = 1;
76140
+ const SCORE_MULTI_WORD_AT_START = 0.75;
76141
+ const SCORE_AT_WORD_BOUNDARY = 0.625;
76142
+ const SCORE_MID_WORD = 0.5;
76143
+ const SCORE_ACRONYM = 0.4;
76144
+ const SCORE_BONUS_CASE_EXACT = 0.125;
76145
+
76146
+ // Acronym match: each char of searchText (spaces stripped) must be the first
76147
+ // letter of a word in value, in order (greedy subsequence on word-starts).
76148
+ // e.g. "TC" matches "Total Count" highlighting the T and C.
76149
+ const tryAcronymMatch = (foldedStr, str, searchText) => {
76150
+ const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
76151
+ if (acronymChars.length < 2) {
76152
+ // Single-char acronym is too ambiguous — skip.
76153
+ return { match: false, matchScore: 0, matchRanges: [] };
76154
+ }
76155
+ const wordStarts = [];
76156
+ for (let i = 0; i < foldedStr.length; i++) {
76157
+ if (isWordBoundary(foldedStr, i)) {
76158
+ wordStarts.push(i);
76159
+ }
76160
+ }
76161
+ const matchedPositions = [];
76162
+ let wordIdx = 0;
76163
+ const originalAcronym = searchText.replace(/\s/g, "");
76164
+ for (let si = 0; si < acronymChars.length; si++) {
76165
+ const ch = acronymChars[si];
76166
+ let found = false;
76167
+ while (wordIdx < wordStarts.length) {
76168
+ const pos = wordStarts[wordIdx];
76169
+ wordIdx++;
76170
+ if (foldedStr[pos] === ch) {
76171
+ matchedPositions.push(pos);
76172
+ found = true;
76173
+ break;
76174
+ }
76175
+ }
76176
+ if (!found) {
76177
+ return { match: false, matchScore: 0, matchRanges: [] };
76178
+ }
76179
+ }
76180
+ const atStart = matchedPositions[0] === 0;
76181
+ const caseExact = matchedPositions.every(
76182
+ (p, i) => str[p] === originalAcronym[i],
76183
+ );
76184
+ const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
76185
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76186
+ const matchRanges = matchedPositions.map((p) => [p, p + 1]);
76187
+ return { match: true, matchScore, matchRanges };
76188
+ };
76189
+
76190
+ // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
76191
+ // for the same searchText across all items in a list render.
76192
+ const searchCache = new Map();
76193
+ const SEARCH_CACHE_MAX_SIZE = 20;
76194
+ const getSearchInfo = (searchText) => {
76195
+ if (searchCache.has(searchText)) {
76196
+ const cached = searchCache.get(searchText);
76197
+ searchCache.delete(searchText);
76198
+ searchCache.set(searchText, cached);
76199
+ return cached;
76200
+ }
76201
+ const foldedSearch = foldAccents(searchText).toLowerCase();
76202
+ const words = foldedSearch.split(/\s+/).filter(Boolean);
76203
+ const originalWords = searchText.split(/\s+/).filter(Boolean);
76204
+ const info = { foldedSearch, words, originalWords };
76205
+ searchCache.set(searchText, info);
76206
+ if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
76207
+ searchCache.delete(searchCache.keys().next().value);
76208
+ }
76209
+ return info;
76210
+ };
76211
+
76212
+ // Merge overlapping or adjacent [start, end] ranges (sorted by start).
76213
+ const mergeRanges = (ranges) => {
76214
+ if (ranges.length < 2) {
76215
+ return ranges;
76216
+ }
76217
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
76218
+ const merged = [sorted[0]];
76219
+ for (let i = 1; i < sorted.length; i++) {
76220
+ const last = merged[merged.length - 1];
76221
+ const current = sorted[i];
76222
+ if (current[0] <= last[1]) {
76223
+ if (current[1] > last[1]) {
76224
+ last[1] = current[1];
76225
+ }
76226
+ } else {
76227
+ merged.push(current);
76228
+ }
76229
+ }
76230
+ return merged;
76231
+ };
76232
+
76233
+ /**
76234
+ * createSearch — builds a matchFn compatible with useSearch that searches
76235
+ * across multiple named fields of an item, each with its own DOM selector
76236
+ * and optional priority weight.
76237
+ *
76238
+ * Usage:
76239
+ * ```js
76240
+ * const searchPerson = createSearch({
76241
+ * name: {
76242
+ * getter: (item) => item.name,
76243
+ * domSelector: ".name",
76244
+ * },
76245
+ * address: {
76246
+ * getter: (item) => item.address,
76247
+ * domSelector: ".address",
76248
+ * priority: 1.5,
76249
+ * },
76250
+ * });
76251
+ *
76252
+ * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
76253
+ * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
76254
+ * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
76255
+ * // — ListItem handles the per-selector object format for matchRanges.
76256
+ * ```
76257
+ *
76258
+ * Each field config:
76259
+ * - getter(item): string — extracts the text to search
76260
+ * - domSelector: string — CSS selector used by ListItem to find the target element
76261
+ * - priority?: number — multiplier applied to the field's score (default 1)
76262
+ * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
76263
+ * defaults to applySearch
76264
+ */
76265
+ const createSearch = (fields) => {
76266
+ return (searchText, item) => {
76267
+ if (!searchText) {
76268
+ return { match: true, matchScore: 0, matchRanges: {} };
76269
+ }
76270
+ let totalScore = 0;
76271
+ const matchRanges = {};
76272
+ for (const [
76273
+ ,
76274
+ { getter, domSelector, priority = 1, matchFn = applySearch },
76275
+ ] of Object.entries(fields)) {
76276
+ const fieldValue = getter(item);
76277
+ const result = matchFn(searchText, fieldValue);
76278
+ if (result.match && result.matchRanges.length > 0) {
76279
+ totalScore += result.matchScore * priority;
76280
+ matchRanges[domSelector] = result.matchRanges;
76281
+ }
76282
+ }
76283
+ if (totalScore === 0) {
76284
+ return { match: false, matchScore: 0, matchRanges: {} };
76285
+ }
76286
+ return { match: true, matchScore: totalScore, matchRanges };
76287
+ };
76288
+ };
76289
+
76290
+ /**
76291
+ * useSearch — reorders items so matched ones come first (sorted by score desc),
76292
+ * followed by non-matched items in their natural order. No item is hidden.
76293
+ * Returns [orderedItems, getItemMatchInfo].
76294
+ * - orderedItems: all items, reordered
76295
+ * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
76296
+ * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
76297
+ * there is no need to destructure the three fields by hand.
76298
+ *
76299
+ * When searchText is empty, natural order is preserved and all items match with score 0.
76300
+ *
76301
+ * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
76302
+ * to each ListItem. The list's matchFallback will be shown when all items are hidden.
76303
+ */
76304
+ const useSearchText = (searchText, items, matchFn = applySearch) => {
76305
+ if (typeof searchText !== "string" && searchText !== undefined) {
76306
+ throw new TypeError(
76307
+ "useSearchText: searchText must be a string or undefined",
76308
+ );
76309
+ }
76310
+ if (items === undefined) {
76311
+ throw new TypeError("useSearch: items is undefined");
76312
+ }
76313
+ const { orderedItems, matchInfoMap } = useMemo(() => {
76314
+ const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
76315
+ searchText,
76316
+ items,
76317
+ matchFn,
76318
+ );
76319
+ const orderedItems = [];
76320
+ for (const [, bucket] of scoreEntries) {
76321
+ for (const { item } of bucket) {
76322
+ orderedItems.push(item);
76323
+ }
76324
+ }
76325
+ for (const { item } of nonMatched) {
76326
+ orderedItems.push(item);
76327
+ }
76328
+ return { orderedItems, matchInfoMap };
76329
+ }, [items, searchText, matchFn]);
76330
+
76331
+ // The same function for as long as the map is the same: a `renderItem`
76332
+ // reading it is stable only if this is, and a run keeps the rows it drew
76333
+ // only for a stable `renderItem` (see List.Items).
76334
+ const getItemMatchInfo = useCallback(
76335
+ (item) => matchInfoMap.get(item),
76336
+ [matchInfoMap],
76337
+ );
76338
+
76339
+ return [orderedItems, getItemMatchInfo];
76340
+ };
76341
+
76342
+ const buildMatchInfo = (searchText, items, matchFn) => {
76343
+ // scoreEntries: [score, bucket][] kept sorted desc by score.
76344
+ // New distinct score values are inserted via bisect — O(1) in practice
76345
+ // since there are very few distinct scores (today just 0 and 1).
76346
+ const scoreEntries = []; // [score, bucket][]
76347
+ const nonMatched = [];
76348
+
76349
+ for (const item of items) {
76350
+ const result = matchFn(searchText, item);
76351
+ if (!result.match) {
76352
+ nonMatched.push({
76353
+ item,
76354
+ matchScore: result.matchScore,
76355
+ matchRanges: result.matchRanges,
76356
+ });
76357
+ continue;
76358
+ }
76359
+ const score = result.matchScore;
76360
+ // Find existing bucket or insert a new entry in desc order.
76361
+ let lo = 0;
76362
+ let hi = scoreEntries.length;
76363
+ while (lo < hi) {
76364
+ const mid = (lo + hi) >> 1;
76365
+ if (scoreEntries[mid][0] > score) {
76366
+ lo = mid + 1;
76367
+ } else if (scoreEntries[mid][0] < score) {
76368
+ hi = mid;
76369
+ } else {
76370
+ lo = mid;
76371
+ hi = mid; // exact match — found the bucket
76372
+ }
76373
+ }
76374
+ if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
76375
+ scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
76093
76376
  } else {
76094
- boxProps[key] = props[key];
76377
+ scoreEntries.splice(lo, 0, [
76378
+ score,
76379
+ [{ item, matchRanges: result.matchRanges }],
76380
+ ]);
76095
76381
  }
76096
76382
  }
76097
- return [popupProps, boxProps];
76383
+
76384
+ const matchInfoMap = new Map();
76385
+ for (const [score, bucket] of scoreEntries) {
76386
+ for (const { item, matchRanges } of bucket) {
76387
+ matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
76388
+ }
76389
+ }
76390
+ for (const { item, matchScore, matchRanges } of nonMatched) {
76391
+ matchInfoMap.set(item, { match: false, matchScore, matchRanges });
76392
+ }
76393
+
76394
+ return { scoreEntries, nonMatched, matchInfoMap };
76098
76395
  };
76099
76396
 
76100
- /**
76101
- * applySearchmatches value against searchText.
76397
+ /*
76398
+ * useItemTracker()hook that creates a stable item tracker for the lifetime
76399
+ * of the host component.
76102
76400
  *
76103
- * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
76104
- * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
76105
- * Multi-word: if searchText contains spaces, each word must appear somewhere in
76106
- * the value for it to match. Ranges for all words are returned.
76401
+ * USAGE:
76402
+ * ```jsx
76403
+ * function ListControlled({ items }) {
76404
+ * const tracker = useItemTracker({
76405
+ * onChange: () => console.log("items changed"),
76406
+ * });
76107
76407
  *
76108
- * Score table:
76408
+ * return (
76409
+ * <ul>
76410
+ * {items.map((item, i) => (
76411
+ * <Row key={item.id} id={item.id} index={i} hidden={item.hidden} value={item.value} tracker={tracker} />
76412
+ * ))}
76413
+ * <Count tracker={tracker} />
76414
+ * </ul>
76415
+ * );
76416
+ * }
76109
76417
  *
76110
- * Situation Score
76111
- * ─────────────────────────────────────── ───────────────────────────
76112
- * phrase at start of value 1
76113
- * multi-word, one word at start (all match) 0.75
76114
- * phrase / word at word boundary 0.625
76115
- * phrase / words mid-word 0.5
76116
- * + case-exact bonus +0.125
76117
- * multi-word partial: score × (matched/total)
76418
+ * function Row({ id, index, hidden, value, tracker }) {
76419
+ * const visibleIndex = tracker.useTrackItem({ id, index, hidden, value });
76420
+ * if (visibleIndex === -1) return null;
76421
+ * return <li>{value}</li>;
76422
+ * }
76118
76423
  *
76119
- * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
76120
- * Intended to be passed to useSearch as the matchFn parameter.
76424
+ * function Count({ tracker }) {
76425
+ * const count = tracker.visibleCountSignal.value; // re-renders only when count changes
76426
+ * return <span>{count} items</span>;
76427
+ * }
76428
+ * ```
76429
+ *
76430
+ * INTERNALS:
76431
+ * - registrations: Map key → data, contains only visible items
76432
+ * - idToKey: Map id → key, stable across renders
76433
+ * - orderedKeys: number[] of visible item keys sorted by explicit order
76434
+ * - keyToOrderedIndex: Map key → orderedKeys index, gives O(1) indexOf equivalent
76435
+ * - keyToExplicitOrder: Map key → explicitly passed index, used to maintain sort order
76436
+ * - allItemsSignal: signal(array), all items including hidden, ordered by explicit index
76437
+ * - visibleItemsSignal: signal(array), non-hidden items only
76438
+ * - countSignal: signal(number), count of all items including hidden
76439
+ * - visibleCountSignal: signal(number), updated in microtask batch, only when count changes
76440
+ * - propSignals: Map propName → signal(array), updated in microtask batch with element equality
76441
+ * - onChangeRef: holds the latest onChange callback, called once per microtask batch
76442
+ *
76443
+ * useTrackItem(id, data, index): registers the item with an explicitly provided index
76444
+ * that determines its position among siblings. The caller (e.g. items.map) knows the
76445
+ * correct order and passes it directly — no render-sequence deduction needed.
76446
+ * Returns the visible rank (position among non-hidden items), or -1 when hidden.
76447
+ * Signals and onChange are deferred to a microtask so multiple items updating
76448
+ * in one commit cause only one notification.
76449
+ *
76450
+ * getTrackedItemByIndex(index): synchronous O(1) lookup of a visible item by
76451
+ * its visible rank. Returns undefined when index is out of range.
76452
+ *
76453
+ * peekItems(): the items as they stand right now, without waiting for the
76454
+ * deferred notification — what a sibling rendering after the items must read
76455
+ * to paint them in the same commit.
76121
76456
  */
76122
- const applySearch = (searchText, value) => {
76123
- if (!searchText) {
76124
- return { match: true, matchScore: 0, matchRanges: [] };
76125
- }
76126
- if (searchText.length > 100) {
76127
- searchText = searchText.slice(0, 100);
76128
- }
76129
- const str = String(value);
76130
- const foldedStr = foldAccents(str).toLowerCase();
76131
- const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
76132
76457
 
76133
- // Try exact phrase match first (gives best score).
76134
- const phraseRanges = [];
76135
- let phraseIdx = foldedStr.indexOf(foldedSearch);
76136
- while (phraseIdx !== -1) {
76137
- phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
76138
- phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
76139
- }
76140
- if (phraseRanges.length > 0) {
76141
- const atStart = foldedStr.startsWith(foldedSearch);
76142
- const atWordBoundary = phraseRanges.some(([start]) =>
76143
- isWordBoundary(foldedStr, start),
76144
- );
76145
- const caseExact = str.includes(searchText);
76146
- let baseScore;
76147
- if (atStart) {
76148
- baseScore = SCORE_PHRASE_AT_START;
76149
- } else if (atWordBoundary) {
76150
- baseScore = SCORE_AT_WORD_BOUNDARY;
76151
- } else {
76152
- baseScore = SCORE_MID_WORD;
76153
- }
76154
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76155
- return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
76458
+ const useItemTracker = ({ onChange } = {}) => {
76459
+ const onChangeRef = useRef(onChange);
76460
+ onChangeRef.current = onChange;
76461
+ const trackerRef = useRef(null);
76462
+ let tracker = trackerRef.current;
76463
+ if (!tracker) {
76464
+ trackerRef.current = tracker = createItemTracker((items) => {
76465
+ onChangeRef.current?.(items);
76466
+ });
76156
76467
  }
76468
+ // When code in useLayoutEffect of the caller wants to run the tracker must be in sync
76469
+ // without this layout effect the tracker might not have been synced yet and preact would call layout effect
76470
+ // before we had time to sync
76471
+ useLayoutEffect(() => {
76472
+ tracker._flushSync();
76473
+ });
76474
+ return tracker;
76475
+ };
76476
+
76477
+ const createItemTracker = (onChange) => {
76478
+ const registrations = new Map(); // key → data (visible items only)
76479
+ const idToKey = new Map(); // id → insertion key (stable, auto-incremented)
76480
+ let keyCounter = 0;
76481
+ // orderedKeys: visible item keys sorted by their explicitly provided index.
76482
+ const orderedKeys = []; // number[]
76483
+ // keyToOrderedIndex: O(1) equivalent of orderedKeys.indexOf(key).
76484
+ const keyToOrderedIndex = new Map(); // key → index in orderedKeys
76485
+ const allKeys = new Set(); // all registered keys including hidden
76486
+ const keyToExplicitOrder = new Map(); // key → explicitly passed index
76487
+
76488
+ const allRegistrations = new Map(); // key → data (all items including hidden)
76489
+ const allOrderedKeys = []; // all item keys sorted by explicit order
76490
+ const keyToAllOrderedIndex = new Map(); // key → index in allOrderedKeys
76491
+
76492
+ const itemsSignal = signal([]);
76493
+ const visibleItemsSignal = signal([]);
76494
+ const countSignal = signal(0);
76495
+ const visibleCountSignal = signal(0);
76496
+ const noMatchCountSignal = signal(0);
76497
+
76498
+ let notifyScheduled = false;
76499
+ const runNotify = () => {
76500
+ batch(() => {
76501
+ let someChange = false;
76502
+
76503
+ const newCount = allKeys.size;
76504
+ const countModified = countSignal.peek() !== newCount;
76505
+ if (countModified) {
76506
+ countSignal.value = newCount;
76507
+ someChange = true;
76508
+ }
76509
+
76510
+ // Build allItems and visibleItems in a single pass over allOrderedKeys.
76511
+ // Visible items are those without data.hidden or data.filtered — same
76512
+ // relative order as orderedKeys (syncItem already excludes both from
76513
+ // orderedKeys; this must match or consumers relying on visibleCountSignal
76514
+ // would count filtered-out items as if they still took up space).
76515
+ const prevAllItems = itemsSignal.peek();
76516
+ const prevVisibleItems = visibleItemsSignal.peek();
76517
+ let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
76518
+ let visibleItemsChanged = false;
76519
+ const allItems = [];
76520
+ const visibleItems = [];
76521
+ let newNoMatchCount = 0;
76522
+ for (let i = 0; i < allOrderedKeys.length; i++) {
76523
+ const key = allOrderedKeys[i];
76524
+ const item = allRegistrations.get(key);
76525
+ allItems.push(item);
76526
+ // Compare by reference: catches any prop change (id, selected, disabled, …)
76527
+ if (!allItemsChanged && item !== prevAllItems[i]) {
76528
+ allItemsChanged = true;
76529
+ }
76530
+ if (item.match === false) {
76531
+ newNoMatchCount++;
76532
+ }
76533
+ if (!item.hidden && !item.filtered) {
76534
+ const visibleIdx = visibleItems.length;
76535
+ visibleItems.push(item);
76536
+ if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
76537
+ visibleItemsChanged = true;
76538
+ }
76539
+ }
76540
+ }
76541
+
76542
+ const newVisibleCount = visibleItems.length;
76543
+ const visibleCountModified =
76544
+ visibleCountSignal.peek() !== newVisibleCount;
76545
+ if (visibleCountModified) {
76546
+ visibleCountSignal.value = newVisibleCount;
76547
+ someChange = true;
76548
+ }
76549
+ if (allItemsChanged) {
76550
+ itemsSignal.value = allItems;
76551
+ someChange = true;
76552
+ }
76553
+ if (visibleItemsChanged) {
76554
+ visibleItemsSignal.value = visibleItems;
76555
+ someChange = true;
76556
+ }
76557
+ const noMatchCountModified =
76558
+ noMatchCountSignal.peek() !== newNoMatchCount;
76559
+ if (noMatchCountModified) {
76560
+ noMatchCountSignal.value = newNoMatchCount;
76561
+ someChange = true;
76562
+ }
76563
+ if (someChange) {
76564
+ onChange?.();
76565
+ }
76566
+ });
76567
+ };
76157
76568
 
76158
- // Multi-word OR: split on whitespace, any word matching contributes to the score.
76159
- // Items where all words match rank higher than partial matches.
76160
- // Note: words always has at least 1 element here (searchText is non-empty and
76161
- // foldedSearch.split filters empty strings). This path also handles the case
76162
- // where searchText has trailing/leading spaces: the phrase match above tries
76163
- // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
76164
- // here to try each word individually (e.g. "tc" matches "tca").
76165
- const matchRanges = [];
76166
- let matchedWordCount = 0;
76167
- let anyWordAtStart = false;
76168
- let anyWordAtWordBoundary = false;
76169
- let allMatchedWordsExact = true;
76170
- for (let w = 0; w < words.length; w++) {
76171
- const word = words[w];
76172
- const originalWord = originalWords[w];
76173
- let idx = foldedStr.indexOf(word);
76174
- if (idx === -1) {
76175
- continue;
76569
+ const notify = () => {
76570
+ if (notifyScheduled) {
76571
+ return;
76176
76572
  }
76177
- matchedWordCount++;
76178
- let wordHasExactMatch = false;
76179
- while (idx !== -1) {
76180
- matchRanges.push([idx, idx + word.length]);
76181
- if (idx === 0) {
76182
- anyWordAtStart = true;
76183
- anyWordAtWordBoundary = true;
76184
- } else if (isWordBoundary(foldedStr, idx)) {
76185
- anyWordAtWordBoundary = true;
76573
+ notifyScheduled = true;
76574
+ queueMicrotask(() => {
76575
+ if (!notifyScheduled) {
76576
+ return; // was already flushed synchronously
76186
76577
  }
76187
- if (str.slice(idx, idx + word.length) === originalWord) {
76188
- wordHasExactMatch = true;
76578
+ notifyScheduled = false;
76579
+ runNotify();
76580
+ });
76581
+ };
76582
+
76583
+ const _flushSync = () => {
76584
+ if (!notifyScheduled) {
76585
+ return;
76586
+ }
76587
+ notifyScheduled = false;
76588
+ runNotify();
76589
+ };
76590
+
76591
+ // Insert key into orderedKeys at the correct position based on explicitOrder.
76592
+ // Uses binary search for O(log n) insertion.
76593
+ const insertKey = (key, explicitOrder) => {
76594
+ let lo = 0;
76595
+ let hi = orderedKeys.length;
76596
+ while (lo < hi) {
76597
+ const mid = (lo + hi) >> 1;
76598
+ if (keyToExplicitOrder.get(orderedKeys[mid]) <= explicitOrder) {
76599
+ lo = mid + 1;
76600
+ } else {
76601
+ hi = mid;
76189
76602
  }
76190
- idx = foldedStr.indexOf(word, idx + 1);
76191
76603
  }
76192
- if (!wordHasExactMatch) {
76193
- allMatchedWordsExact = false;
76604
+ orderedKeys.splice(lo, 0, key);
76605
+ for (let i = lo; i < orderedKeys.length; i++) {
76606
+ keyToOrderedIndex.set(orderedKeys[i], i);
76194
76607
  }
76195
- }
76196
- if (matchedWordCount === 0) {
76197
- return tryAcronymMatch(foldedStr, str, searchText);
76198
- }
76199
- const wordRatio = matchedWordCount / words.length;
76200
- let baseScore;
76201
- if (anyWordAtStart) {
76202
- baseScore = SCORE_MULTI_WORD_AT_START;
76203
- } else if (anyWordAtWordBoundary) {
76204
- baseScore = SCORE_AT_WORD_BOUNDARY;
76205
- } else {
76206
- baseScore = SCORE_MID_WORD;
76207
- }
76208
- const matchScore =
76209
- (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
76210
- wordRatio;
76211
- return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
76212
- };
76213
-
76214
- // Returns true when position idx in str is at a word boundary,
76215
- // meaning it is either the start of the string or the preceding character
76216
- // is not a Unicode letter or digit.
76217
- const isWordBoundary = (str, idx) => {
76218
- if (idx === 0) {
76219
- return true;
76220
- }
76221
- return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
76222
- };
76223
-
76224
- // Strip diacritics for accent-insensitive matching.
76225
- // NFC normalization first ensures precomposed characters (é → single code unit),
76226
- // so the folded string has the same length as the NFC source — ranges computed
76227
- // on the folded string map 1:1 to positions in the original string.
76228
- const foldAccents = (str) => {
76229
- return str
76230
- .normalize("NFC")
76231
- .normalize("NFD")
76232
- .replace(/\p{Mn}/gu, "");
76233
- };
76234
-
76235
- const SCORE_PHRASE_AT_START = 1;
76236
- const SCORE_MULTI_WORD_AT_START = 0.75;
76237
- const SCORE_AT_WORD_BOUNDARY = 0.625;
76238
- const SCORE_MID_WORD = 0.5;
76239
- const SCORE_ACRONYM = 0.4;
76240
- const SCORE_BONUS_CASE_EXACT = 0.125;
76608
+ };
76241
76609
 
76242
- // Acronym match: each char of searchText (spaces stripped) must be the first
76243
- // letter of a word in value, in order (greedy subsequence on word-starts).
76244
- // e.g. "TC" matches "Total Count" highlighting the T and C.
76245
- const tryAcronymMatch = (foldedStr, str, searchText) => {
76246
- const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
76247
- if (acronymChars.length < 2) {
76248
- // Single-char acronym is too ambiguous — skip.
76249
- return { match: false, matchScore: 0, matchRanges: [] };
76250
- }
76251
- const wordStarts = [];
76252
- for (let i = 0; i < foldedStr.length; i++) {
76253
- if (isWordBoundary(foldedStr, i)) {
76254
- wordStarts.push(i);
76255
- }
76256
- }
76257
- const matchedPositions = [];
76258
- let wordIdx = 0;
76259
- const originalAcronym = searchText.replace(/\s/g, "");
76260
- for (let si = 0; si < acronymChars.length; si++) {
76261
- const ch = acronymChars[si];
76262
- let found = false;
76263
- while (wordIdx < wordStarts.length) {
76264
- const pos = wordStarts[wordIdx];
76265
- wordIdx++;
76266
- if (foldedStr[pos] === ch) {
76267
- matchedPositions.push(pos);
76268
- found = true;
76269
- break;
76610
+ const insertAllKey = (key, explicitOrder) => {
76611
+ let lo = 0;
76612
+ let hi = allOrderedKeys.length;
76613
+ while (lo < hi) {
76614
+ const mid = (lo + hi) >> 1;
76615
+ if (keyToExplicitOrder.get(allOrderedKeys[mid]) <= explicitOrder) {
76616
+ lo = mid + 1;
76617
+ } else {
76618
+ hi = mid;
76270
76619
  }
76271
76620
  }
76272
- if (!found) {
76273
- return { match: false, matchScore: 0, matchRanges: [] };
76621
+ allOrderedKeys.splice(lo, 0, key);
76622
+ for (let i = lo; i < allOrderedKeys.length; i++) {
76623
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76274
76624
  }
76275
- }
76276
- const atStart = matchedPositions[0] === 0;
76277
- const caseExact = matchedPositions.every(
76278
- (p, i) => str[p] === originalAcronym[i],
76279
- );
76280
- const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
76281
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76282
- const matchRanges = matchedPositions.map((p) => [p, p + 1]);
76283
- return { match: true, matchScore, matchRanges };
76284
- };
76625
+ };
76285
76626
 
76286
- // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
76287
- // for the same searchText across all items in a list render.
76288
- const searchCache = new Map();
76289
- const SEARCH_CACHE_MAX_SIZE = 20;
76290
- const getSearchInfo = (searchText) => {
76291
- if (searchCache.has(searchText)) {
76292
- const cached = searchCache.get(searchText);
76293
- searchCache.delete(searchText);
76294
- searchCache.set(searchText, cached);
76295
- return cached;
76296
- }
76297
- const foldedSearch = foldAccents(searchText).toLowerCase();
76298
- const words = foldedSearch.split(/\s+/).filter(Boolean);
76299
- const originalWords = searchText.split(/\s+/).filter(Boolean);
76300
- const info = { foldedSearch, words, originalWords };
76301
- searchCache.set(searchText, info);
76302
- if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
76303
- searchCache.delete(searchCache.keys().next().value);
76304
- }
76305
- return info;
76306
- };
76627
+ const removeAllKey = (key) => {
76628
+ const idx = keyToAllOrderedIndex.get(key);
76629
+ if (idx !== undefined) {
76630
+ allOrderedKeys.splice(idx, 1);
76631
+ keyToAllOrderedIndex.delete(key);
76632
+ for (let i = idx; i < allOrderedKeys.length; i++) {
76633
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76634
+ }
76635
+ }
76636
+ };
76307
76637
 
76308
- // Merge overlapping or adjacent [start, end] ranges (sorted by start).
76309
- const mergeRanges = (ranges) => {
76310
- if (ranges.length < 2) {
76311
- return ranges;
76312
- }
76313
- const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
76314
- const merged = [sorted[0]];
76315
- for (let i = 1; i < sorted.length; i++) {
76316
- const last = merged[merged.length - 1];
76317
- const current = sorted[i];
76318
- if (current[0] <= last[1]) {
76319
- if (current[1] > last[1]) {
76320
- last[1] = current[1];
76638
+ // Register or update an item. data.hidden controls visibility.
76639
+ // explicitOrder is the caller-provided index that determines sort position.
76640
+ const syncItem = (key, index, data) => {
76641
+ if (data.role === "presentation") {
76642
+ registrations.delete(key);
76643
+ const idx = keyToOrderedIndex.get(key);
76644
+ if (idx !== undefined) {
76645
+ orderedKeys.splice(idx, 1);
76646
+ keyToOrderedIndex.delete(key);
76647
+ for (let i = idx; i < orderedKeys.length; i++) {
76648
+ keyToOrderedIndex.set(orderedKeys[i], i);
76649
+ }
76650
+ }
76651
+ keyToExplicitOrder.delete(key);
76652
+ allRegistrations.delete(key);
76653
+ removeAllKey(key);
76654
+ allKeys.delete(key);
76655
+ return;
76656
+ }
76657
+
76658
+ // Maintain allRegistrations and allOrderedKeys for all non-presentation items.
76659
+ allRegistrations.set(key, data);
76660
+ allKeys.add(key);
76661
+ const currentAllIdx = keyToAllOrderedIndex.get(key);
76662
+ const previousOrder = keyToExplicitOrder.get(key);
76663
+ keyToExplicitOrder.set(key, index);
76664
+ if (currentAllIdx === undefined) {
76665
+ insertAllKey(key, index);
76666
+ } else if (previousOrder !== index) {
76667
+ allOrderedKeys.splice(currentAllIdx, 1);
76668
+ keyToAllOrderedIndex.delete(key);
76669
+ for (let i = currentAllIdx; i < allOrderedKeys.length; i++) {
76670
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76321
76671
  }
76322
- } else {
76323
- merged.push(current);
76672
+ insertAllKey(key, index);
76324
76673
  }
76325
- }
76326
- return merged;
76327
- };
76328
76674
 
76329
- /**
76330
- * createSearch — builds a matchFn compatible with useSearch that searches
76331
- * across multiple named fields of an item, each with its own DOM selector
76332
- * and optional priority weight.
76333
- *
76334
- * Usage:
76335
- * ```js
76336
- * const searchPerson = createSearch({
76337
- * name: {
76338
- * getter: (item) => item.name,
76339
- * domSelector: ".name",
76340
- * },
76341
- * address: {
76342
- * getter: (item) => item.address,
76343
- * domSelector: ".address",
76344
- * priority: 1.5,
76345
- * },
76346
- * });
76347
- *
76348
- * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
76349
- * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
76350
- * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
76351
- * // — ListItem handles the per-selector object format for matchRanges.
76352
- * ```
76353
- *
76354
- * Each field config:
76355
- * - getter(item): string — extracts the text to search
76356
- * - domSelector: string — CSS selector used by ListItem to find the target element
76357
- * - priority?: number — multiplier applied to the field's score (default 1)
76358
- * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
76359
- * defaults to applySearch
76360
- */
76361
- const createSearch = (fields) => {
76362
- return (searchText, item) => {
76363
- if (!searchText) {
76364
- return { match: true, matchScore: 0, matchRanges: {} };
76365
- }
76366
- let totalScore = 0;
76367
- const matchRanges = {};
76368
- for (const [
76369
- ,
76370
- { getter, domSelector, priority = 1, matchFn = applySearch },
76371
- ] of Object.entries(fields)) {
76372
- const fieldValue = getter(item);
76373
- const result = matchFn(searchText, fieldValue);
76374
- if (result.match && result.matchRanges.length > 0) {
76375
- totalScore += result.matchScore * priority;
76376
- matchRanges[domSelector] = result.matchRanges;
76675
+ if (data.filtered || data.hidden) {
76676
+ registrations.delete(key);
76677
+ const idx = keyToOrderedIndex.get(key);
76678
+ if (idx !== undefined) {
76679
+ orderedKeys.splice(idx, 1);
76680
+ keyToOrderedIndex.delete(key);
76681
+ for (let i = idx; i < orderedKeys.length; i++) {
76682
+ keyToOrderedIndex.set(orderedKeys[i], i);
76683
+ }
76377
76684
  }
76685
+ return;
76378
76686
  }
76379
- if (totalScore === 0) {
76380
- return { match: false, matchScore: 0, matchRanges: {} };
76687
+
76688
+ registrations.set(key, data);
76689
+ const currentIdx = keyToOrderedIndex.get(key);
76690
+ if (currentIdx === undefined) {
76691
+ insertKey(key, index);
76692
+ return;
76381
76693
  }
76382
- return { match: true, matchScore: totalScore, matchRanges };
76694
+ if (previousOrder === index) {
76695
+ return;
76696
+ }
76697
+ orderedKeys.splice(currentIdx, 1);
76698
+ keyToOrderedIndex.delete(key);
76699
+ for (let i = currentIdx; i < orderedKeys.length; i++) {
76700
+ keyToOrderedIndex.set(orderedKeys[i], i);
76701
+ }
76702
+ insertKey(key, index);
76383
76703
  };
76384
- };
76385
76704
 
76386
- /**
76387
- * useSearch — reorders items so matched ones come first (sorted by score desc),
76388
- * followed by non-matched items in their natural order. No item is hidden.
76389
- * Returns [orderedItems, getItemMatchInfo].
76390
- * - orderedItems: all items, reordered
76391
- * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
76392
- * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
76393
- * there is no need to destructure the three fields by hand.
76394
- *
76395
- * When searchText is empty, natural order is preserved and all items match with score 0.
76396
- *
76397
- * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
76398
- * to each ListItem. The list's matchFallback will be shown when all items are hidden.
76399
- */
76400
- const useSearchText = (searchText, items, matchFn = applySearch) => {
76401
- if (typeof searchText !== "string" && searchText !== undefined) {
76402
- throw new TypeError(
76403
- "useSearchText: searchText must be a string or undefined",
76404
- );
76405
- }
76406
- if (items === undefined) {
76407
- throw new TypeError("useSearch: items is undefined");
76408
- }
76409
- const { orderedItems, matchInfoMap } = useMemo(() => {
76410
- const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
76411
- searchText,
76412
- items,
76413
- matchFn,
76414
- );
76415
- const orderedItems = [];
76416
- for (const [, bucket] of scoreEntries) {
76417
- for (const { item } of bucket) {
76418
- orderedItems.push(item);
76705
+ const unregisterKey = (key) => {
76706
+ registrations.delete(key);
76707
+ const idx = keyToOrderedIndex.get(key);
76708
+ if (idx !== undefined) {
76709
+ orderedKeys.splice(idx, 1);
76710
+ keyToOrderedIndex.delete(key);
76711
+ for (let i = idx; i < orderedKeys.length; i++) {
76712
+ keyToOrderedIndex.set(orderedKeys[i], i);
76419
76713
  }
76420
76714
  }
76421
- for (const { item } of nonMatched) {
76422
- orderedItems.push(item);
76715
+ keyToExplicitOrder.delete(key);
76716
+ allRegistrations.delete(key);
76717
+ removeAllKey(key);
76718
+ allKeys.delete(key);
76719
+ };
76720
+
76721
+ const keyForId = (id) => {
76722
+ if (!idToKey.has(id)) {
76723
+ idToKey.set(id, keyCounter++);
76423
76724
  }
76424
- return { orderedItems, matchInfoMap };
76425
- }, [items, searchText, matchFn]);
76725
+ return idToKey.get(id);
76726
+ };
76426
76727
 
76427
- // The same function for as long as the map is the same: a `renderItem`
76428
- // reading it is stable only if this is, and a run keeps the rows it drew
76429
- // only for a stable `renderItem` (see List.Items).
76430
- const getItemMatchInfo = useCallback(
76431
- (item) => matchInfoMap.get(item),
76432
- [matchInfoMap],
76433
- );
76728
+ // Register an item. data.hidden controls visibility.
76729
+ // explicitOrder is the caller-provided index (e.g. from items.map((item, i) => ...))
76730
+ // that determines this item's position among siblings.
76731
+ // Returns the item's visible rank among non-hidden items, or -1 when hidden.
76732
+ const useTrackItem = (data) => {
76733
+ const { id, index } = data;
76734
+ const key = keyForId(id);
76434
76735
 
76435
- return [orderedItems, getItemMatchInfo];
76436
- };
76736
+ syncItem(key, index, data);
76737
+ notify();
76437
76738
 
76438
- const buildMatchInfo = (searchText, items, matchFn) => {
76439
- // scoreEntries: [score, bucket][] kept sorted desc by score.
76440
- // New distinct score values are inserted via bisect — O(1) in practice
76441
- // since there are very few distinct scores (today just 0 and 1).
76442
- const scoreEntries = []; // [score, bucket][]
76443
- const nonMatched = [];
76739
+ useLayoutEffect(() => {
76740
+ return () => {
76741
+ unregisterKey(key);
76742
+ notify();
76743
+ };
76744
+ }, []);
76444
76745
 
76445
- for (const item of items) {
76446
- const result = matchFn(searchText, item);
76447
- if (!result.match) {
76448
- nonMatched.push({
76449
- item,
76450
- matchScore: result.matchScore,
76451
- matchRanges: result.matchRanges,
76452
- });
76453
- continue;
76454
- }
76455
- const score = result.matchScore;
76456
- // Find existing bucket or insert a new entry in desc order.
76457
- let lo = 0;
76458
- let hi = scoreEntries.length;
76459
- while (lo < hi) {
76460
- const mid = (lo + hi) >> 1;
76461
- if (scoreEntries[mid][0] > score) {
76462
- lo = mid + 1;
76463
- } else if (scoreEntries[mid][0] < score) {
76464
- hi = mid;
76465
- } else {
76466
- lo = mid;
76467
- hi = mid; // exact match — found the bucket
76468
- }
76746
+ if (data.filtered || data.hidden || data.role === "presentation") {
76747
+ return -1;
76469
76748
  }
76470
- if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
76471
- scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
76472
- } else {
76473
- scoreEntries.splice(lo, 0, [
76474
- score,
76475
- [{ item, matchRanges: result.matchRanges }],
76476
- ]);
76749
+ return keyToOrderedIndex.get(key) ?? -1;
76750
+ };
76751
+
76752
+ const getTrackedItemByIndex = (index) => {
76753
+ const key = orderedKeys[index];
76754
+ if (key === undefined) {
76755
+ return undefined;
76477
76756
  }
76478
- }
76757
+ return registrations.get(key);
76758
+ };
76479
76759
 
76480
- const matchInfoMap = new Map();
76481
- for (const [score, bucket] of scoreEntries) {
76482
- for (const { item, matchRanges } of bucket) {
76483
- matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
76760
+ // The items as they stand right now, notification pending or not — same
76761
+ // content as itemsSignal, minus the wait.
76762
+ //
76763
+ // Items register during their own render, while the signal is only updated
76764
+ // on a deferred microtask (see notify): a sibling rendering after them would
76765
+ // otherwise paint from an empty list and correct itself a frame later. That
76766
+ // frame is visible whenever the painted size feeds a layout decision — a
76767
+ // dialog sizing itself on its content measures the empty version and shifts
76768
+ // once the real one lands. Reading this instead makes the first paint the
76769
+ // right one. Callers must still subscribe to itemsSignal to re-render on
76770
+ // LATER changes; this is the value to display, not the notification.
76771
+ const peekItems = () => {
76772
+ if (!notifyScheduled) {
76773
+ return itemsSignal.peek();
76484
76774
  }
76485
- }
76486
- for (const { item, matchScore, matchRanges } of nonMatched) {
76487
- matchInfoMap.set(item, { match: false, matchScore, matchRanges });
76488
- }
76775
+ const items = [];
76776
+ for (const key of allOrderedKeys) {
76777
+ items.push(allRegistrations.get(key));
76778
+ }
76779
+ return items;
76780
+ };
76489
76781
 
76490
- return { scoreEntries, nonMatched, matchInfoMap };
76782
+ return {
76783
+ useTrackItem,
76784
+ getTrackedItemByIndex,
76785
+ peekItems,
76786
+ itemsSignal,
76787
+ visibleItemsSignal,
76788
+ countSignal,
76789
+ visibleCountSignal,
76790
+ noMatchCountSignal,
76791
+ _flushSync,
76792
+ };
76491
76793
  };
76492
76794
 
76493
76795
  installImportMetaCssBuild(import.meta);