@jsenv/navi 0.29.344 → 0.29.346

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
+ }
65066
65062
  }
65067
- }
65068
- keyToExplicitOrder.delete(key);
65069
- allRegistrations.delete(key);
65070
- removeAllKey(key);
65071
- allKeys.delete(key);
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;
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
+ });
65072
65139
  };
65073
-
65074
- const keyForId = (id) => {
65075
- if (!idToKey.has(id)) {
65076
- idToKey.set(id, keyCounter++);
65140
+ const notify = () => {
65141
+ if (notifyScheduled) {
65142
+ return;
65077
65143
  }
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;
65144
+ notifyScheduled = true;
65145
+ queueMicrotask(() => {
65146
+ if (!notifyScheduled) {
65147
+ return;
65148
+ }
65149
+ notifyScheduled = false;
65150
+ runNotify();
65151
+ });
65133
65152
  };
65134
65153
 
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,21 +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, where it stands among the rows the list holds, and the
66049
- // run's own account of which rows mount (see createRunRows) what the row
66050
- // draws its separator from. Carried by context rather than injected into
66051
- // whatever vnode renderItem returned, so that returning a component of one's
66052
- // own — instead of a bare <List.Item> — 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.
66053
66263
  const ListRowContext = createContext(null);
66054
66264
  // The slot a child of the list stands in, by id (see ListDeclaredChildren). A
66055
66265
  // row takes its place in the collection by slot: the place is then the list's
66056
- // to move, and the row's to follow — see createListVirtual.
66266
+ // to move, and the row's to follow — see list_rows.js.
66057
66267
  const ListSlotContext = createContext(null);
66058
66268
  const css$x = /* css */`@layer navi {
66059
66269
  .navi_list_container {
@@ -66544,7 +66754,7 @@ const ListUI = props => {
66544
66754
  overflow,
66545
66755
  overflowX,
66546
66756
  overflowY,
66547
- virtual,
66757
+ listRows,
66548
66758
  ...rest
66549
66759
  } = props;
66550
66760
  const scrollBoxPaddingProps = {};
@@ -66613,16 +66823,19 @@ const ListUI = props => {
66613
66823
  observer.disconnect();
66614
66824
  };
66615
66825
  }, [lockSize]);
66616
- const tracker = useItemTracker({
66617
- onChange: () => {
66618
- onListVisibleItemsChange?.(tracker.visibleItemsSignal.peek());
66619
- }
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();
66620
66833
  });
66621
66834
  // What the runs ask for and stand for: the steady budget, whatever the
66622
66835
  // window of the first paint draws — a run asking for the rows of the first
66623
66836
  // picture and then for the rest is two round trips for one opening.
66624
- virtual.renderBudget = renderBudgetAfterPaint;
66625
- virtual.scrolled = scrolled ?? defaultScrolled;
66837
+ listRows.renderBudget = renderBudgetAfterPaint;
66838
+ listRows.scrolled = scrolled ?? defaultScrolled;
66626
66839
  const {
66627
66840
  virtualItemSizeSignal,
66628
66841
  renderWindow,
@@ -66631,11 +66844,10 @@ const ListUI = props => {
66631
66844
  captureAnchor
66632
66845
  } = useListScrollSync({
66633
66846
  ref,
66634
- tracker,
66847
+ listRows,
66635
66848
  renderBudget,
66636
66849
  renderBudgetSteady: renderBudgetAfterPaint,
66637
66850
  virtualItemSize,
66638
- virtual,
66639
66851
  scrolled,
66640
66852
  defaultScrolled,
66641
66853
  onScrolledChange,
@@ -66654,28 +66866,28 @@ const ListUI = props => {
66654
66866
  if (props.renderBudget === undefined || renderBudgetWarnedRef.current) {
66655
66867
  return;
66656
66868
  }
66657
- if (virtual.hasRuns() || tracker.itemsSignal.peek().length === 0) {
66869
+ if (listRows.hasRuns() || listRows.itemsSignal.peek().length === 0) {
66658
66870
  return;
66659
66871
  }
66660
66872
  renderBudgetWarnedRef.current = true;
66661
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.`);
66662
66874
  });
66663
- virtual.captureAnchor = captureAnchor;
66664
- virtual.virtualItemSizeSignal = virtualItemSizeSignal;
66665
- virtual.horizontal = Boolean(horizontal);
66666
- virtual.renderSkeleton = renderSkeleton;
66875
+ listRows.captureAnchor = captureAnchor;
66876
+ listRows.virtualItemSizeSignal = virtualItemSizeSignal;
66877
+ listRows.horizontal = Boolean(horizontal);
66878
+ listRows.renderSkeleton = renderSkeleton;
66667
66879
 
66668
66880
  // A row is addressed by id from outside (--navi-scroll, --navi-select): the
66669
- // 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
66670
66882
  // holds without drawing are known only to that run (see List.Items' row
66671
66883
  // locator). Both answer here, so a row is reachable whether or not the
66672
66884
  // window happens to frame it.
66673
66885
  const getItemById = itemId => {
66674
- const itemDrawn = tracker.itemsSignal.peek().find(item => item.id === itemId);
66886
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
66675
66887
  if (itemDrawn) {
66676
66888
  return itemDrawn;
66677
66889
  }
66678
- const rowIndex = virtual.locateRow(itemId);
66890
+ const rowIndex = listRows.locateRow(itemId);
66679
66891
  if (rowIndex === null) {
66680
66892
  return undefined;
66681
66893
  }
@@ -66684,13 +66896,13 @@ const ListUI = props => {
66684
66896
  index: rowIndex
66685
66897
  };
66686
66898
  };
66687
- const noMatchCount = tracker.noMatchCountSignal.value;
66899
+ const noMatchCount = listRows.noMatchCountSignal.value;
66688
66900
  // What the list stands for, which is not always what it holds: a run saying
66689
66901
  // it covers 60 rows is not an empty list while it waits for the first of
66690
66902
  // them (see List.Items).
66691
66903
  // eslint-disable-next-line no-unused-expressions
66692
- virtual.pagesSignal.value;
66693
- const itemCount = tracker.countSignal.value || virtual.totalSignal.value;
66904
+ listRows.pagesSignal.value;
66905
+ const itemCount = listRows.countSignal.value || listRows.totalSignal.value;
66694
66906
  const allNoMatch = noMatchCount > 0 && noMatchCount === itemCount;
66695
66907
  const searching = Boolean(searchText);
66696
66908
  const fallbackDisabled = fallback !== undefined && !fallback;
@@ -66788,7 +67000,7 @@ const ListUI = props => {
66788
67000
  expand: expand,
66789
67001
  "navi-nothing-to-display": nothingToDisplay ? "" : undefined,
66790
67002
  "navi-loading": loading ? "" : undefined,
66791
- "navi-refreshing": virtual.refreshingSignal.value ? "" : undefined,
67003
+ "navi-refreshing": listRows.refreshingSignal.value ? "" : undefined,
66792
67004
  "navi-error": error ? "" : undefined,
66793
67005
  styleCSSVars: LIST_STYLE_CSS_VARS,
66794
67006
  pseudoClasses: LIST_PSEUDO_CLASSES,
@@ -66824,9 +67036,8 @@ const ListUI = props => {
66824
67036
  spacing: spacing,
66825
67037
  columns: columns,
66826
67038
  itemColumns: itemColumns,
66827
- tracker: tracker,
67039
+ listRows: listRows,
66828
67040
  renderWindow: renderWindow,
66829
- virtual: virtual,
66830
67041
  pendingScrollRef: pendingScrollRef,
66831
67042
  overflow: overflow,
66832
67043
  overflowX: overflowX,
@@ -66847,11 +67058,11 @@ const ListFirstResolver = props => {
66847
67058
  props.ref = props.ref || refDefault;
66848
67059
  const idDefault = useId();
66849
67060
  props.id = props.id || idDefault;
66850
- const virtualRef = useRef(null);
66851
- if (!virtualRef.current) {
66852
- virtualRef.current = createListVirtual();
67061
+ const listRowsRef = useRef(null);
67062
+ if (!listRowsRef.current) {
67063
+ listRowsRef.current = createListRows();
66853
67064
  }
66854
- props.virtual = virtualRef.current;
67065
+ props.listRows = listRowsRef.current;
66855
67066
  const parallelGuard = useParallelGuard(props.parallelGuard ?? PARALLEL_GUARD_DEFAULT);
66856
67067
  return jsx(ParallelGuardContext.Provider, {
66857
67068
  value: parallelGuard,
@@ -66877,9 +67088,8 @@ const ListContent = ({
66877
67088
  spacing,
66878
67089
  columns,
66879
67090
  itemColumns,
66880
- tracker,
67091
+ listRows,
66881
67092
  renderWindow,
66882
- virtual,
66883
67093
  pendingScrollRef,
66884
67094
  overflow,
66885
67095
  overflowX,
@@ -66933,9 +67143,8 @@ const ListContent = ({
66933
67143
  columns: columns,
66934
67144
  itemColumns: itemColumns,
66935
67145
  ...listProps,
66936
- tracker: tracker,
67146
+ listRows: listRows,
66937
67147
  renderWindow: renderWindow,
66938
- virtual: virtual,
66939
67148
  children: children
66940
67149
  })
66941
67150
  })
@@ -66959,11 +67168,10 @@ const LIST_STYLE_CSS_VARS = {
66959
67168
  const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
66960
67169
  const useListScrollSync = ({
66961
67170
  ref,
66962
- tracker,
67171
+ listRows,
66963
67172
  renderBudget,
66964
67173
  renderBudgetSteady,
66965
67174
  virtualItemSize,
66966
- virtual,
66967
67175
  scrolled,
66968
67176
  defaultScrolled,
66969
67177
  onScrolledChange,
@@ -66973,7 +67181,7 @@ const useListScrollSync = ({
66973
67181
  }) => {
66974
67182
  const debugScroll = useDebugScroll();
66975
67183
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
66976
- virtual,
67184
+ listRows,
66977
67185
  renderBudget,
66978
67186
  scrolledWanted: scrolled ?? defaultScrolled
66979
67187
  });
@@ -66994,11 +67202,12 @@ const useListScrollSync = ({
66994
67202
  };
66995
67203
  useLayoutEffect(resolveScroller);
66996
67204
  useStickyScrollportWarning();
67205
+ useDuplicateHeaderWarning();
66997
67206
  useStuckWindowWarning({
66998
67207
  ref,
66999
67208
  scrollerElResolved,
67000
67209
  renderBudget,
67001
- totalSignal: virtual.totalSignal,
67210
+ totalSignal: listRows.totalSignal,
67002
67211
  virtualItemSizeSignal,
67003
67212
  horizontal
67004
67213
  });
@@ -67023,7 +67232,7 @@ const useListScrollSync = ({
67023
67232
  anchorRef.current = captureScrollAnchor({
67024
67233
  scrollerEl: getScroller(),
67025
67234
  listEl: getListEl(),
67026
- items: tracker.visibleItemsSignal.peek(),
67235
+ items: listRows.visibleItemsSignal.peek(),
67027
67236
  horizontal
67028
67237
  });
67029
67238
  };
@@ -67055,7 +67264,7 @@ const useListScrollSync = ({
67055
67264
  start,
67056
67265
  end
67057
67266
  } = renderWindowRef.current;
67058
- const total = virtual.totalSignal.peek();
67267
+ const total = listRows.totalSignal.peek();
67059
67268
  let framedStart = start;
67060
67269
  let framedEnd = start + renderBudget;
67061
67270
  if (total > 0 && framedEnd > total) {
@@ -67098,19 +67307,19 @@ const useListScrollSync = ({
67098
67307
  // jumped.
67099
67308
  const holdWindow = () => {
67100
67309
  if (startPlaceRef.current.userTookOver) {
67101
- virtual.holdPending = false;
67310
+ listRows.holdPending = false;
67102
67311
  return;
67103
67312
  }
67104
67313
  // Held somewhere it has not reached yet: what the window frames right now
67105
67314
  // is not what it will frame, so nothing should be fetched for it.
67106
- virtual.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
67107
- const total = virtual.totalSignal.peek();
67315
+ listRows.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
67316
+ const total = listRows.totalSignal.peek();
67108
67317
  if (total <= renderBudget) {
67109
67318
  // The whole collection is what the list draws: wherever in it the list is
67110
67319
  // held, the window is already its place. Nowhere to move to means nothing
67111
67320
  // to wait for — a hold left standing here is a list that never asks for
67112
67321
  // anything again.
67113
- virtual.holdPending = false;
67322
+ listRows.holdPending = false;
67114
67323
  return;
67115
67324
  }
67116
67325
  const half = Math.floor(renderBudget / 2);
@@ -67120,7 +67329,7 @@ const useListScrollSync = ({
67120
67329
  } else if (typeof scrolledWanted === "number") {
67121
67330
  wantedStart = scrolledWanted - half;
67122
67331
  } else if (scrolledWanted && scrolledWanted.id !== undefined) {
67123
- const rowIndex = virtual.locateRow(scrolledWanted.id);
67332
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
67124
67333
  if (rowIndex !== null) {
67125
67334
  wantedStart = rowIndex - half;
67126
67335
  } else if (typeof scrolledWanted.index === "number") {
@@ -67147,14 +67356,14 @@ const useListScrollSync = ({
67147
67356
  end
67148
67357
  } = renderWindowRef.current;
67149
67358
  if (wantedStart === start && end - start === renderBudget) {
67150
- virtual.holdPending = false;
67359
+ listRows.holdPending = false;
67151
67360
  return;
67152
67361
  }
67153
67362
  renderWindowRef.current = {
67154
67363
  start: wantedStart,
67155
67364
  end: wantedStart + renderBudget
67156
67365
  };
67157
- virtual.holdPending = false;
67366
+ listRows.holdPending = false;
67158
67367
  };
67159
67368
  const pendingScrollRef = useRef();
67160
67369
  const scrollToItem = (item, {
@@ -67165,7 +67374,7 @@ const useListScrollSync = ({
67165
67374
  if (!item) {
67166
67375
  return;
67167
67376
  }
67168
- const items = tracker.itemsSignal.peek();
67377
+ const items = listRows.itemsSignal.peek();
67169
67378
  const itemCount = items.length;
67170
67379
  if (itemCount === 0) {
67171
67380
  return;
@@ -67293,7 +67502,7 @@ const useListScrollSync = ({
67293
67502
  return;
67294
67503
  }
67295
67504
  hasBeenDisplayedRef.current = true;
67296
- const items = tracker.itemsSignal.peek();
67505
+ const items = listRows.itemsSignal.peek();
67297
67506
  const firstSelected = items.find(i => {
67298
67507
  if (i.selected) {
67299
67508
  return true;
@@ -67377,7 +67586,7 @@ const useListScrollSync = ({
67377
67586
  scrollValues: savedScroll,
67378
67587
  scrollerEl: listScrollContainerEl,
67379
67588
  listEl: getListEl(),
67380
- tracker,
67589
+ listRows,
67381
67590
  virtualItemSizeSignal,
67382
67591
  renderWindowRef,
67383
67592
  horizontal
@@ -67394,7 +67603,7 @@ const useListScrollSync = ({
67394
67603
  });
67395
67604
  return undefined;
67396
67605
  }
67397
- const visibleItems = tracker.visibleItemsSignal.peek();
67606
+ const visibleItems = listRows.visibleItemsSignal.peek();
67398
67607
  const topItems = visibleItems.slice(0, renderBudget);
67399
67608
  const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchInfo?.matchScore ?? ""}`).join(",");
67400
67609
  const currentTopMatchScore = topMatchScoresKeyRef.current;
@@ -67432,7 +67641,7 @@ const useListScrollSync = ({
67432
67641
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
67433
67642
  return;
67434
67643
  }
67435
- if (virtual.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
67644
+ if (listRows.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
67436
67645
  return;
67437
67646
  }
67438
67647
  // Coming back to a named row: it has to be on screen to be put back where
@@ -67444,13 +67653,13 @@ const useListScrollSync = ({
67444
67653
  // Only whoever holds the rows can say where that one sits: the list
67445
67654
  // itself knows the rows it has drawn, and this one is precisely the one
67446
67655
  // it has not drawn yet.
67447
- const rowIndex = virtual.locateRow(scrolledWanted.id);
67656
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
67448
67657
  if (rowIndex === null) {
67449
67658
  // Not there yet. Where it stood is enough to be roughly right in the
67450
67659
  // meantime — the scrollbar lands near its final place instead of at the
67451
67660
  // top, and the exact position is taken once the row itself can be
67452
67661
  // measured.
67453
- if (virtual.pagesSignal.peek() === 0) {
67662
+ if (listRows.pagesSignal.peek() === 0) {
67454
67663
  if (typeof scrolledWanted.index === "number") {
67455
67664
  const rowPosition = scrolledWanted.index * virtualItemSizeSignal.peek();
67456
67665
  anchorRef.current = null;
@@ -67567,7 +67776,7 @@ const useListScrollSync = ({
67567
67776
  const position = captureScrollAnchor({
67568
67777
  scrollerEl: getScroller(),
67569
67778
  listEl: getListEl(),
67570
- items: tracker.visibleItemsSignal.peek(),
67779
+ items: listRows.visibleItemsSignal.peek(),
67571
67780
  horizontal
67572
67781
  });
67573
67782
  if (!position) {
@@ -67655,7 +67864,7 @@ const useListScrollSync = ({
67655
67864
  anchorRef.current = null;
67656
67865
  return;
67657
67866
  }
67658
- const items = tracker.visibleItemsSignal.peek();
67867
+ const items = listRows.visibleItemsSignal.peek();
67659
67868
  const itemNow = items.find(i => i.id === anchor.id);
67660
67869
  if (!itemNow) {
67661
67870
  anchorRef.current = null;
@@ -67677,7 +67886,7 @@ const useListScrollSync = ({
67677
67886
  const windowSize = end - start;
67678
67887
  const startShifted = start + indexShift;
67679
67888
  let startWanted = startShifted < 0 ? 0 : startShifted;
67680
- const total = virtual.totalSignal.peek();
67889
+ const total = listRows.totalSignal.peek();
67681
67890
  // Same normalization as the scroll listener: a window running past the
67682
67891
  // last row slides back instead of framing fewer rows than its budget
67683
67892
  // allows — every row that fits in it must stay rendered.
@@ -67735,7 +67944,7 @@ const useListScrollSync = ({
67735
67944
  const windowSlidRef = useRef(false);
67736
67945
  useRef(false);
67737
67946
  const evaluateWindow = reason => {
67738
- const total = virtual.totalSignal.peek();
67947
+ const total = listRows.totalSignal.peek();
67739
67948
  if (total <= renderBudget) {
67740
67949
  return;
67741
67950
  }
@@ -67757,7 +67966,7 @@ const useListScrollSync = ({
67757
67966
  },
67758
67967
  scrollerEl,
67759
67968
  listEl,
67760
- tracker,
67969
+ listRows,
67761
67970
  virtualItemSizeSignal,
67762
67971
  renderWindowRef,
67763
67972
  horizontal
@@ -67959,6 +68168,21 @@ const useStickyScrollportWarning = (ref, scroller) => {
67959
68168
  }
67960
68169
  });
67961
68170
  };
68171
+ // A list has one header: the row that caps it — the column row of a table —
68172
+ // and the box the list measures to keep the others from scrolling under it. A
68173
+ // second one takes that same place, so both sit at the capped edge before
68174
+ // every row and the rows declared between them read as belonging to the last:
68175
+ // a title meant to open a run of rows ends up titling nothing. That title is a
68176
+ // group label, which is why this points at List.Group rather than at the
68177
+ // stacking.
68178
+ const useDuplicateHeaderWarning = ref => {
68179
+ useRef(false);
68180
+ useLayoutEffect(() => {
68181
+ {
68182
+ return;
68183
+ }
68184
+ });
68185
+ };
67962
68186
 
67963
68187
  /**
67964
68188
  * "Am I stuck?" — the question a `position: sticky` element cannot ask about
@@ -68356,12 +68580,12 @@ const getScrollInfo = ({
68356
68580
  scrollValues,
68357
68581
  scrollerEl,
68358
68582
  listEl,
68359
- tracker,
68583
+ listRows,
68360
68584
  virtualItemSizeSignal,
68361
68585
  renderWindowRef,
68362
68586
  horizontal
68363
68587
  }) => {
68364
- const items = tracker.itemsSignal.peek();
68588
+ const items = listRows.itemsSignal.peek();
68365
68589
  const viewportRect = getScrollerViewportRect(scrollerEl);
68366
68590
  const listRect = listEl.getBoundingClientRect();
68367
68591
  let hitEl = null;
@@ -68486,7 +68710,7 @@ const measureItemSize = (listEl, horizontal) => {
68486
68710
  };
68487
68711
  };
68488
68712
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68489
- virtual,
68713
+ listRows,
68490
68714
  renderBudget,
68491
68715
  scrolledWanted
68492
68716
  }) => {
@@ -68546,7 +68770,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68546
68770
  // size is for, and a list drawing every row it has would pay a layout on
68547
68771
  // each of its renders for a number nothing reads.
68548
68772
  const sizeAlreadyKnown = virtualSizeSignal.peek() !== 0;
68549
- const rowsHeldOffScreen = virtual.totalSignal.peek() > renderBudget;
68773
+ const rowsHeldOffScreen = listRows.totalSignal.peek() > renderBudget;
68550
68774
  if (!virtualItemSizeProp && sizeAlreadyKnown && rowsHeldOffScreen && ref.current) {
68551
68775
  const listEl = ref.current.querySelector(".navi_list");
68552
68776
  const measure = listEl ? measureItemSize(listEl, horizontal) : null;
@@ -68562,7 +68786,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68562
68786
  // screen, and a list held somewhere (placeWhereHeld) before it knows where
68563
68787
  // that is. A list drawing every row it has, opening at its start, would
68564
68788
  // pay a layout in every commit for a number nobody reads.
68565
- const sizeRead = virtual.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
68789
+ const sizeRead = listRows.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
68566
68790
  if (!sizeRead) {
68567
68791
  return undefined;
68568
68792
  }
@@ -68614,9 +68838,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
68614
68838
  // item after each commit and writes to the signal, causing only the fillers to
68615
68839
  // re-render.
68616
68840
  const UnorderedList = ({
68617
- tracker,
68841
+ listRows,
68618
68842
  renderWindow,
68619
- virtual,
68620
68843
  fallback,
68621
68844
  fallbackShown,
68622
68845
  searchFallback,
@@ -68666,17 +68889,14 @@ const UnorderedList = ({
68666
68889
  value: separator ?? null,
68667
68890
  children: jsx(ItemTransitionContext.Provider, {
68668
68891
  value: Boolean(itemTransition),
68669
- children: jsx(ListItemTrackerContext.Provider, {
68670
- value: tracker,
68671
- children: jsx(ListVirtualContext.Provider, {
68672
- value: virtual,
68673
- children: jsx(ListRowContext.Provider, {
68674
- value: null,
68675
- children: jsx(ListItemColumnsContext.Provider, {
68676
- value: columns ? null : itemColumns || null,
68677
- children: jsx(ListDeclaredChildren, {
68678
- children: children
68679
- })
68892
+ children: jsx(ListRowsContext.Provider, {
68893
+ value: listRows,
68894
+ children: jsx(ListRowContext.Provider, {
68895
+ value: null,
68896
+ children: jsx(ListItemColumnsContext.Provider, {
68897
+ value: columns ? null : itemColumns || null,
68898
+ children: jsx(ListDeclaredChildren, {
68899
+ children: children
68680
68900
  })
68681
68901
  })
68682
68902
  })
@@ -68727,8 +68947,8 @@ const VirtualFiller = ({
68727
68947
  edge,
68728
68948
  itemCount
68729
68949
  }) => {
68730
- const virtual = useContext(ListVirtualContext);
68731
- const sizeToFill = itemCount * virtual.virtualItemSizeSignal.value;
68950
+ const listRows = useContext(ListRowsContext);
68951
+ const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
68732
68952
  if (!sizeToFill) {
68733
68953
  return null;
68734
68954
  }
@@ -68871,12 +69091,11 @@ const ListItemUI = props => {
68871
69091
  }
68872
69092
  const idDefault = useId();
68873
69093
  props.id = props.id || idDefault;
68874
- const tracker = useContext(ListItemTrackerContext);
68875
- const virtual = useContext(ListVirtualContext);
69094
+ const listRows = useContext(ListRowsContext);
69095
+ const groupId = useContext(ListGroupContext);
68876
69096
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
68877
69097
  // The run this row belongs to, when it comes from one (see ListItems): it
68878
- // registered the row and decided it is inside the render window. Whether
68879
- // the row mounts is decided here, and told back to the run.
69098
+ // gave the row its place and decided it is inside the render window.
68880
69099
  const row = useContext(ListRowContext);
68881
69100
  const slotId = useContext(ListSlotContext);
68882
69101
  // There is no standalone match/matchScore/highlight prop — participation
@@ -68884,7 +69103,7 @@ const ListItemUI = props => {
68884
69103
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
68885
69104
  // matchRanges }), so there is exactly one way to wire it up.
68886
69105
  const matchInfo = props.matchInfo;
68887
- // Expose match on the tracked item: the tracker counts non-matching items via
69106
+ // Expose match on the row: the list counts non-matching rows via
68888
69107
  // `item.match === false` (drives noMatchCount → allNoMatch → the searchFallback
68889
69108
  // / hide-when-empty behavior). Without this a matchInfo-based search would
68890
69109
  // filter items out but never register them as "no match".
@@ -68908,33 +69127,27 @@ const ListItemUI = props => {
68908
69127
  // name of this very component (idDefault, not the row's id): two components
68909
69128
  // may stand for the same row for a moment, one leaving as the other arrives,
68910
69129
  // and the one leaving must give back its own place, not the newcomer's.
68911
- if (row) {
69130
+ if (!row) {
68912
69131
  if (props.filtered) {
68913
- row.run.unmount(idDefault);
69132
+ listRows.drop(idDefault);
68914
69133
  } else {
68915
- row.run.mount(idDefault, props.index, row.groupKey);
69134
+ props.index = listRows.take(idDefault, 1, slotId);
68916
69135
  }
68917
- } else if (props.filtered) {
68918
- virtual.drop(idDefault);
68919
- } else {
68920
- props.index = virtual.take(idDefault, 1, slotId);
68921
69136
  }
69137
+ // Every row that renders says so, whether it was declared one by one or
69138
+ // drawn by a run: what it is (its value, whether it is selected) and whether
69139
+ // it mounts at all are written where it renders, in one place.
69140
+ listRows.draw(idDefault, {
69141
+ ownerId: row ? row.ownerId : idDefault,
69142
+ place: props.index,
69143
+ groupId,
69144
+ data: props
69145
+ });
68922
69146
  useLayoutEffect(() => {
68923
69147
  return () => {
68924
- if (row) {
68925
- row.run.unmount(idDefault);
68926
- } else {
68927
- virtual.drop(idDefault);
68928
- }
69148
+ listRows.erase(idDefault);
68929
69149
  };
68930
69150
  }, []);
68931
- // Every row that is drawn registers itself, whether it was declared one by
68932
- // one or drawn by a run: what it says about itself (its value, whether it is
68933
- // selected) is written where it is drawn, in one place.
68934
- const item = props;
68935
- tracker.useTrackItem(item);
68936
- const groupTracker = useContext(GroupItemTrackerContext);
68937
- const groupVisibleIndex = groupTracker ? groupTracker.useTrackItem(item) : null;
68938
69151
  const separator = useContext(SeparatorContext);
68939
69152
  if (props.filtered) {
68940
69153
  return null;
@@ -68942,32 +69155,13 @@ const ListItemUI = props => {
68942
69155
  const listItemVnode = jsx(ListItemReal, {
68943
69156
  ...props
68944
69157
  });
68945
- if (!separator) {
68946
- return listItemVnode;
68947
- }
68948
- // The separator a row wears is the one at the gap above it, so the first row
68949
- // that mounts wears none. "Am I first?" is answered by whoever handed out
68950
- // the place — the run for its rows, the list's virtual for a declared one
68951
- // (virtual.take above) — not by the tracker's visibleIndex: during a reorder
68952
- // render pass (items resorted by search score) the other items still carry
68953
- // stale keyToExplicitOrder values, the binary search reads them, no item
68954
- // comes out at 0 and a spurious separator appears at the top. Inside a
68955
- // declared group, each group has its own tracker and its items do not
68956
- // reorder, so groupVisibleIndex is reliable there.
68957
- let isFirst;
68958
- if (row) {
68959
- isFirst = row.run.isFirst(props.index, row.groupKey);
68960
- } else if (groupVisibleIndex === null || props.hidden) {
68961
- isFirst = props.index === 0;
68962
- } else {
68963
- isFirst = groupVisibleIndex === 0;
68964
- }
68965
- if (isFirst) {
69158
+ // The separator a row wears is the one at the gap above it: none when
69159
+ // nothing of the list stands above it (see list_rows.js).
69160
+ if (!separator || listRows.isFirst(idDefault)) {
68966
69161
  return listItemVnode;
68967
69162
  }
68968
69163
  // The gap index, only used as the function-form argument.
68969
- const gapIndex = row || groupVisibleIndex === null || props.hidden ? props.index - 1 : groupVisibleIndex - 1;
68970
- let separatorVnode = resolveSeparatorVnode(separator, gapIndex);
69164
+ let separatorVnode = resolveSeparatorVnode(separator, props.index - 1);
68971
69165
  if (props.hidden) {
68972
69166
  // A row kept in the DOM but hidden keeps its separator, hidden with it:
68973
69167
  // the point of keeping a row that matches nothing is that nothing moves,
@@ -69296,441 +69490,45 @@ const ListItem = /*#__PURE__*/createComponentResolver([ListItemFirstResolver, Li
69296
69490
  pure: true
69297
69491
  });
69298
69492
 
69299
- // Everything the list knows about the collection while its children are being
69300
- // rendered: how many rows it has in total, which of them are actually held, and
69301
- // where each child's rows start.
69302
- //
69303
- // A child knows how many rows it stands for but not what was declared before
69304
- // it, and it cannot deduce that from when it renders: a render is free to skip
69305
- // it. A child that draws from signals and whose props are all referentially
69306
- // === the previous ones does not render again (@preact/signals installs a
69307
- // shouldComponentUpdate that says so), which is what any child nobody rebuilt
69308
- // this frame is — and children numbered as they render would then slide up
69309
- // into the place of the one that was skipped.
69493
+ // The walk that gives the list's children their places: a slot for each of
69494
+ // them, declared to the list's rows all at once before any child renders,
69495
+ // and handed to the child through a provider of its own — which is what lets
69496
+ // the row reach it however deep the caller buried it in components of theirs.
69310
69497
  //
69311
- // So the places are read off the walk instead of off the renders: the list
69312
- // names a slot for each of its children and declares them here, in order,
69313
- // before any of them renders (see ListDeclaredChildren). A child then takes
69314
- // its place BY SLOT, and the place is a signal: it moves when what stands
69315
- // before it changes — a row filtered out, a run taking in rows, a slot added
69316
- // or moved — and the child follows, rendered again for it whether or not
69317
- // anything else would have rendered it.
69318
- const createListVirtual = () => {
69319
- const totalSignal = signal(0);
69320
- // Bumped whenever a run takes in rows. The list itself has to hear about it:
69321
- // rows arriving outside the render window change nothing it can see (nothing
69322
- // registers, nothing is drawn), and yet they are what it may have been
69323
- // waiting for — the row it was told to open on, for one.
69324
- const pagesSignal = signal(0);
69325
- // How many runs are re-reading rows they already show. The list wears it as
69326
- // an attribute: what is drawn is from before, and the app may want to say so
69327
- // without taking anything away.
69328
- const refreshingSignal = signal(0);
69329
- // The slots each walk declared, in order, by the slot the walk stands in
69330
- // (null for the list's own children). Together they are a tree: a group's
69331
- // rows live inside the group's slot.
69332
- const slotIdsByParent = new Map();
69333
- // Who took a place a row, or a run of rows — and how many rows of the
69334
- // collection it stands for. The place itself is a signal, see take.
69335
- const ownerById = new Map();
69336
- // The owners standing in each slot, in the order they took their place.
69337
- // One, as a rule; a child that renders several rows keeps them in the order
69338
- // they first rendered, which is all it can be told.
69339
- const ownerIdsBySlot = new Map();
69340
- const locatorByOwner = new Map();
69341
- // The slots as the tree reads, first to last, and where each stands in it.
69342
- // Rebuilt once a walk has changed the tree, read to place the owners.
69343
- const slotWalk = [];
69344
- const rankBySlot = new Map();
69345
- let rowTotal = 0;
69346
- // Owners have left and the others have not been moved up yet. Done on the
69347
- // next ask rather than on the spot: rows leave many at a time (a search, a
69348
- // list unmounting), and moving the others up once is enough.
69349
- let placesStale = false;
69350
- // Where the last slot holding an owner stands: an owner arriving at or after
69351
- // it is placed at the end without going over the others — a whole first
69352
- // render, rows arriving in order, costs each row nothing but itself.
69353
- let rankOwnedLast = -1;
69354
- const rebuildWalk = () => {
69355
- slotWalk.length = 0;
69356
- rankBySlot.clear();
69357
- const visit = parentSlotId => {
69358
- const slotIds = slotIdsByParent.get(parentSlotId);
69359
- if (!slotIds) {
69360
- return;
69361
- }
69362
- for (const slotId of slotIds) {
69363
- rankBySlot.set(slotId, slotWalk.length);
69364
- slotWalk.push(slotId);
69365
- visit(slotId);
69366
- }
69367
- };
69368
- visit(null);
69369
- };
69370
- // Every place, in one go: a place is the sum of what stands before it, so
69371
- // there is nothing to hand out one at a time. Writing a place that did not
69372
- // change wakes nobody — a signal ignores a value equal to its own.
69373
- const refreshPlaces = () => {
69374
- placesStale = false;
69375
- let index = 0;
69376
- let rank = 0;
69377
- rankOwnedLast = -1;
69378
- while (rank < slotWalk.length) {
69379
- const ownerIds = ownerIdsBySlot.get(slotWalk[rank]);
69380
- if (ownerIds) {
69381
- for (const ownerId of ownerIds) {
69382
- const owner = ownerById.get(ownerId);
69383
- owner.placeSignal.value = index;
69384
- index += owner.rowCount;
69385
- }
69386
- rankOwnedLast = rank;
69387
- }
69388
- rank++;
69389
- }
69390
- rowTotal = index;
69391
- totalSignal.value = index;
69392
- };
69393
- const addToSlot = (slotId, ownerId) => {
69394
- const ownerIds = ownerIdsBySlot.get(slotId);
69395
- if (ownerIds) {
69396
- ownerIds.push(ownerId);
69397
- } else {
69398
- ownerIdsBySlot.set(slotId, [ownerId]);
69399
- }
69400
- warnIfEveryRowInOneSlot(slotId);
69401
- };
69402
- // Rows that all stand in the same slot keep the order they first mounted in:
69403
- // the walk is over the children the list is given, and a component holding
69404
- // them is one child however many rows it renders. Everything about a place
69405
- // then stops following what the caller writes — a search reordering the rows
69406
- // moves nothing. Said once, and only for the shape that can be nothing else:
69407
- // the list's whole content is one child, and several rows came out of it.
69408
- let everyRowInOneSlotWarned = false;
69409
- const warnIfEveryRowInOneSlot = slotId => {
69410
- if (everyRowInOneSlotWarned) {
69411
- return;
69412
- }
69413
- const rootSlotIds = slotIdsByParent.get(null);
69414
- if (!rootSlotIds || rootSlotIds.length !== 1 || rootSlotIds[0] !== slotId) {
69415
- return;
69416
- }
69417
- if (ownerIdsBySlot.get(slotId).length < 2) {
69418
- return;
69419
- }
69420
- everyRowInOneSlotWarned = true;
69421
- 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.`);
69422
- };
69423
- const removeFromSlot = (slotId, ownerId) => {
69424
- const ownerIds = ownerIdsBySlot.get(slotId);
69425
- if (!ownerIds) {
69426
- return;
69427
- }
69428
- const index = ownerIds.indexOf(ownerId);
69429
- if (index !== -1) {
69430
- ownerIds.splice(index, 1);
69431
- }
69432
- if (ownerIds.length === 0) {
69433
- ownerIdsBySlot.delete(slotId);
69434
- }
69435
- };
69436
- // A slot the walk no longer names: whatever stood in it is gone, and so is
69437
- // whatever a walk inside it had declared.
69438
- const dropSlot = slotId => {
69439
- const ownerIds = ownerIdsBySlot.get(slotId);
69440
- if (ownerIds) {
69441
- for (const ownerId of ownerIds) {
69442
- ownerById.delete(ownerId);
69443
- }
69444
- ownerIdsBySlot.delete(slotId);
69445
- }
69446
- const childSlotIds = slotIdsByParent.get(slotId);
69447
- if (childSlotIds) {
69448
- slotIdsByParent.delete(slotId);
69449
- for (const childSlotId of childSlotIds) {
69450
- dropSlot(childSlotId);
69451
- }
69452
- }
69453
- };
69454
- const virtual = {
69455
- totalSignal,
69456
- pagesSignal,
69457
- refreshingSignal,
69458
- // What a run needs to know about the list it lives in: how many rows the
69459
- // list is willing to draw at once, which end it opens on, and how much
69460
- // room one row is given — a row whose content has not arrived must take
69461
- // exactly that, or the rows drawn would not reach where the list says they
69462
- // are.
69463
- renderBudget: 0,
69464
- scrolled: "start",
69465
- // The list is on its way somewhere: what the window frames is not what it
69466
- // is about to frame, so a run must not fetch for it (see holdWindow).
69467
- holdPending: false,
69468
- // Called by a run just before rows land in it: what is on screen must not
69469
- // move because something arrived above it. Set by the list itself.
69470
- captureAnchor: () => {},
69471
- horizontal: false,
69472
- virtualItemSizeSignal: null,
69473
- renderSkeleton: undefined,
69474
- // The children a walk stands over, in order — said in one call, before any
69475
- // of them renders, so that what a child asks next is answered against the
69476
- // whole picture and not against the children that happened to render
69477
- // first. Said again on every render of the walk, and heard only when
69478
- // something moved.
69479
- declareSlots: (parentSlotId, slotIds) => {
69480
- const slotIdsPrevious = slotIdsByParent.get(parentSlotId);
69481
- if (slotIdsPrevious && sameSlotIds(slotIdsPrevious, slotIds)) {
69482
- return;
69483
- }
69484
- if (slotIdsPrevious) {
69485
- const slotIdSet = new Set(slotIds);
69486
- for (const slotId of slotIdsPrevious) {
69487
- if (!slotIdSet.has(slotId)) {
69488
- dropSlot(slotId);
69489
- }
69490
- }
69491
- }
69492
- slotIdsByParent.set(parentSlotId, slotIds);
69493
- rebuildWalk();
69494
- refreshPlaces();
69495
- },
69496
- // Whether something has taken this slot for its own: what it renders
69497
- // inside is then its to place (a run draws its groups with their rows
69498
- // already placed), and no walk inside it has anything to declare.
69499
- slotHasOwner: slotId => ownerIdsBySlot.has(slotId),
69500
- // Whether any run of rows lives in this list: what makes a render window
69501
- // mean anything (see List's renderBudget).
69502
- hasRuns: () => locatorByOwner.size > 0,
69503
- setRowLocator: (ownerId, locate) => {
69504
- locatorByOwner.set(ownerId, locate);
69505
- },
69506
- dropRowLocator: ownerId => {
69507
- locatorByOwner.delete(ownerId);
69508
- },
69509
- // Where the row named by that id sits, asked of whoever holds it.
69510
- locateRow: id => {
69511
- for (const locate of locatorByOwner.values()) {
69512
- const index = locate(id);
69513
- if (index !== null) {
69514
- return index;
69515
- }
69516
- }
69517
- return null;
69518
- },
69519
- // The place the owner's rows start at — read from a signal, so that the
69520
- // owner is rendered again when it moves (see createListVirtual). Asked on
69521
- // every render, and answered without a second look for as long as the
69522
- // owner stands in the same slot for the same number of rows.
69523
- take: (ownerId, rowCount, slotId) => {
69524
- let owner = ownerById.get(ownerId);
69525
- if (owner) {
69526
- if (owner.slotId !== slotId || owner.rowCount !== rowCount) {
69527
- removeFromSlot(owner.slotId, ownerId);
69528
- addToSlot(slotId, ownerId);
69529
- owner.slotId = slotId;
69530
- owner.rowCount = rowCount;
69531
- placesStale = true;
69532
- }
69533
- if (placesStale) {
69534
- refreshPlaces();
69535
- }
69536
- return owner.placeSignal.value;
69537
- }
69538
- if (placesStale) {
69539
- refreshPlaces();
69540
- }
69541
- const rank = rankBySlot.get(slotId);
69542
- addToSlot(slotId, ownerId);
69543
- if (rank !== undefined && rank >= rankOwnedLast) {
69544
- owner = {
69545
- slotId,
69546
- rowCount,
69547
- placeSignal: signal(rowTotal)
69548
- };
69549
- ownerById.set(ownerId, owner);
69550
- rowTotal += rowCount;
69551
- rankOwnedLast = rank;
69552
- totalSignal.value = rowTotal;
69553
- return owner.placeSignal.value;
69554
- }
69555
- owner = {
69556
- slotId,
69557
- rowCount,
69558
- placeSignal: signal(0)
69559
- };
69560
- ownerById.set(ownerId, owner);
69561
- refreshPlaces();
69562
- return owner.placeSignal.value;
69563
- },
69564
- // The owner stands for no row of the collection: it was filtered out by a
69565
- // search, or it is gone.
69566
- drop: ownerId => {
69567
- const owner = ownerById.get(ownerId);
69568
- if (!owner) {
69569
- return;
69570
- }
69571
- ownerById.delete(ownerId);
69572
- removeFromSlot(owner.slotId, ownerId);
69573
- if (placesStale) {
69574
- return;
69575
- }
69576
- placesStale = true;
69577
- queueMicrotask(() => {
69578
- if (placesStale) {
69579
- refreshPlaces();
69580
- }
69581
- });
69582
- }
69583
- };
69584
- return virtual;
69585
- };
69586
- const sameSlotIds = (left, right) => {
69587
- if (left.length !== right.length) {
69588
- return false;
69589
- }
69590
- let index = 0;
69591
- while (index < left.length) {
69592
- if (left[index] !== right[index]) {
69593
- return false;
69594
- }
69595
- index++;
69596
- }
69597
- return true;
69598
- };
69599
-
69600
- // Which of a run's rows mount, and which of them comes first. A run draws
69601
- // every row of its window, and only the row itself knows, once it renders,
69602
- // that it renders nothing (filtered out by a search, see ListItemUI). The
69603
- // separator a row wears is the one at the gap above it, so the first row that
69604
- // mounts wears none — and "first" is read off the rows that mount, not off
69605
- // the collection. Rows say so as they render, in order, and the answer is a
69606
- // signal: a row rendered from a kept vnode is rendered again when the row
69607
- // before it leaves or comes back. Grouped rows are counted per group, the gap
69608
- // above a group's first row being the group wrapper's own.
69609
- const createRunRows = () => {
69610
- // rowId → { index, groupKey }
69611
- const rowById = new Map();
69612
- // groupKey (undefined outside groups) → the index of the group's first
69613
- // mounted row, -1 when none.
69614
- const firstSignalByGroup = new Map();
69615
- // Where the window starts: a run cut by the window has rows above its first
69616
- // drawn one, and so does a run standing after declared rows.
69617
- const windowFromSignal = signal(0);
69618
- // Groups whose first row left: recounted on the next ask, or at the end of
69619
- // the frame, whichever comes first.
69620
- const staleGroupKeys = new Set();
69621
- const firstSignalOf = groupKey => {
69622
- let firstSignal = firstSignalByGroup.get(groupKey);
69623
- if (!firstSignal) {
69624
- firstSignal = signal(-1);
69625
- firstSignalByGroup.set(groupKey, firstSignal);
69626
- }
69627
- return firstSignal;
69628
- };
69629
- const refresh = groupKey => {
69630
- staleGroupKeys.delete(groupKey);
69631
- let first = -1;
69632
- for (const row of rowById.values()) {
69633
- if (row.groupKey === groupKey && (first === -1 || row.index < first)) {
69634
- first = row.index;
69635
- }
69636
- }
69637
- firstSignalOf(groupKey).value = first;
69638
- };
69639
- const leave = row => {
69640
- if (firstSignalOf(row.groupKey).peek() !== row.index) {
69641
- return;
69642
- }
69643
- staleGroupKeys.add(row.groupKey);
69644
- queueMicrotask(() => {
69645
- if (staleGroupKeys.has(row.groupKey)) {
69646
- refresh(row.groupKey);
69647
- }
69648
- });
69649
- };
69650
- return {
69651
- setWindowFrom: windowFrom => {
69652
- windowFromSignal.value = windowFrom;
69653
- },
69654
- mount: (rowId, index, groupKey) => {
69655
- const row = rowById.get(rowId);
69656
- if (row) {
69657
- if (row.index === index && row.groupKey === groupKey) {
69658
- return;
69659
- }
69660
- leave(row);
69661
- row.index = index;
69662
- row.groupKey = groupKey;
69663
- } else {
69664
- rowById.set(rowId, {
69665
- index,
69666
- groupKey
69667
- });
69668
- }
69669
- const firstSignal = firstSignalOf(groupKey);
69670
- const first = firstSignal.peek();
69671
- if (first === -1 || index < first) {
69672
- firstSignal.value = index;
69673
- }
69674
- },
69675
- unmount: rowId => {
69676
- const row = rowById.get(rowId);
69677
- if (!row) {
69678
- return;
69679
- }
69680
- rowById.delete(rowId);
69681
- leave(row);
69682
- },
69683
- isFirst: (index, groupKey) => {
69684
- if (staleGroupKeys.has(groupKey)) {
69685
- refresh(groupKey);
69686
- }
69687
- if (firstSignalOf(groupKey).value !== index) {
69688
- return false;
69689
- }
69690
- return groupKey !== undefined || windowFromSignal.value === 0;
69691
- }
69692
- };
69693
- };
69694
-
69695
- // The walk that gives the list's children their places: a slot for each of
69696
- // them, declared to the list's virtual all at once before any child renders,
69697
- // and handed to the child through a provider of its own — which is what lets
69698
- // the row reach it however deep the caller buried it in components of theirs.
69699
- //
69700
- // A slot is named the way preact tells the child apart: by key when it has
69701
- // one, by position otherwise, and inside the array it was given in — a nested
69702
- // array is one child to preact, so what follows the array keeps its name
69703
- // however many rows the array holds. A child preact would not render (null,
69704
- // a boolean) has no slot: it is not there.
69705
- const ListDeclaredChildren = ({
69706
- children
69707
- }) => {
69708
- const virtual = useContext(ListVirtualContext);
69709
- const parentSlotId = useContext(ListSlotContext);
69710
- if (parentSlotId !== null && virtual.slotHasOwner(parentSlotId)) {
69711
- return children;
69712
- }
69713
- const slotIds = [];
69714
- const declared = [];
69715
- declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
69716
- virtual.declareSlots(parentSlotId, slotIds);
69717
- return jsx(Fragment, {
69718
- children: declared
69719
- });
69720
- };
69721
- const declareChildren = (children, prefix, slotIds, declared) => {
69722
- const childArray = Array.isArray(children) ? children : [children];
69723
- let index = 0;
69724
- for (const child of childArray) {
69725
- if (Array.isArray(child)) {
69726
- declareChildren(child, `${prefix}${index}/`, slotIds, declared);
69727
- } else if (child !== null && child !== undefined && child !== false && child !== true) {
69728
- const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
69729
- slotIds.push(slotId);
69730
- declared.push(jsx(ListSlotContext.Provider, {
69731
- value: slotId,
69732
- children: child
69733
- }, slotId));
69498
+ // A slot is named the way preact tells the child apart: by key when it has
69499
+ // one, by position otherwise, and inside the array it was given in — a nested
69500
+ // array is one child to preact, so what follows the array keeps its name
69501
+ // however many rows the array holds. A child preact would not render (null,
69502
+ // a boolean) has no slot: it is not there.
69503
+ const ListDeclaredChildren = ({
69504
+ children
69505
+ }) => {
69506
+ const listRows = useContext(ListRowsContext);
69507
+ const parentSlotId = useContext(ListSlotContext);
69508
+ if (parentSlotId !== null && listRows.slotHasOwner(parentSlotId)) {
69509
+ return children;
69510
+ }
69511
+ const slotIds = [];
69512
+ const declared = [];
69513
+ declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
69514
+ listRows.declareSlots(parentSlotId, slotIds);
69515
+ return jsx(Fragment, {
69516
+ children: declared
69517
+ });
69518
+ };
69519
+ const declareChildren = (children, prefix, slotIds, declared) => {
69520
+ const childArray = Array.isArray(children) ? children : [children];
69521
+ let index = 0;
69522
+ for (const child of childArray) {
69523
+ if (Array.isArray(child)) {
69524
+ declareChildren(child, `${prefix}${index}/`, slotIds, declared);
69525
+ } else if (child !== null && child !== undefined && child !== false && child !== true) {
69526
+ const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
69527
+ slotIds.push(slotId);
69528
+ declared.push(jsx(ListSlotContext.Provider, {
69529
+ value: slotId,
69530
+ children: child
69531
+ }, slotId));
69734
69532
  }
69735
69533
  index++;
69736
69534
  }
@@ -69872,15 +69670,10 @@ const ListItems = ({
69872
69670
  onRequestStateChange
69873
69671
  }) => {
69874
69672
  const ownerId = useId();
69875
- const virtual = useContext(ListVirtualContext);
69673
+ const listRows = useContext(ListRowsContext);
69876
69674
  const slotId = useContext(ListSlotContext);
69877
69675
  const renderWindow = useContext(RenderWindowContext);
69878
69676
  const separator = useContext(SeparatorContext);
69879
- const runRowsRef = useRef(null);
69880
- if (!runRowsRef.current) {
69881
- runRowsRef.current = createRunRows();
69882
- }
69883
- const runRows = runRowsRef.current;
69884
69677
  // The vnode drawn for a row, kept by item: a run rendering again (its window
69885
69678
  // moving, its first paint's budget giving way to the full one) hands preact
69886
69679
  // the same vnode for a row that has not changed, and preact leaves that
@@ -69903,7 +69696,7 @@ const ListItems = ({
69903
69696
  memoryBudget,
69904
69697
  onRequestStateChange
69905
69698
  });
69906
- const renderRowSkeleton = renderSkeleton === undefined ? virtual.renderSkeleton : renderSkeleton;
69699
+ const renderRowSkeleton = renderSkeleton === undefined ? listRows.renderSkeleton : renderSkeleton;
69907
69700
  // A row on its way takes the room the list reserves for it: anything else
69908
69701
  // and the rows drawn stop short of where the scroll says they are. Read
69909
69702
  // where a row is actually missing, and not before: the size settles after
@@ -69915,9 +69708,9 @@ const ListItems = ({
69915
69708
  return skeletonRow;
69916
69709
  }
69917
69710
  skeletonRow = {};
69918
- const virtualItemSize = virtual.virtualItemSizeSignal.value;
69711
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
69919
69712
  if (virtualItemSize) {
69920
- if (virtual.horizontal) {
69713
+ if (listRows.horizontal) {
69921
69714
  skeletonRow.rowMinWidth = `${virtualItemSize}px`;
69922
69715
  } else {
69923
69716
  skeletonRow.rowMinHeight = `${virtualItemSize}px`;
@@ -69925,7 +69718,7 @@ const ListItems = ({
69925
69718
  }
69926
69719
  return skeletonRow;
69927
69720
  };
69928
- const runStart = virtual.take(ownerId, store.rowCount, slotId);
69721
+ const runStart = listRows.take(ownerId, store.rowCount, slotId);
69929
69722
  const runEnd = runStart + store.rowCount;
69930
69723
  // The two ways to count the same row. The list numbers its rows from its own
69931
69724
  // first one, whatever draws it; the store numbers the collection's, straight
@@ -69940,7 +69733,7 @@ const ListItems = ({
69940
69733
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
69941
69734
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
69942
69735
  store.forget(rankOf(windowFrom), rankOf(windowTo));
69943
- runRows.setWindowFrom(windowFrom);
69736
+ listRows.declareWindow(ownerId, windowFrom, windowTo);
69944
69737
 
69945
69738
  // The row answers to its own id when the item carries one — that is what
69946
69739
  // addresses it from outside (--navi-select, --navi-scroll, startAt) — and
@@ -69950,7 +69743,7 @@ const ListItems = ({
69950
69743
  // Where a row named from outside actually sits. Only the run can answer:
69951
69744
  // rows it holds but does not draw are nowhere else — a list only knows the
69952
69745
  // rows it has drawn (they register themselves, see ListItemUI).
69953
- virtual.setRowLocator(ownerId, id => {
69746
+ listRows.setRowLocator(ownerId, id => {
69954
69747
  let found = null;
69955
69748
  store.eachHeld((item, rank) => {
69956
69749
  const rowIndex = rowOf(rank);
@@ -69962,8 +69755,8 @@ const ListItems = ({
69962
69755
  });
69963
69756
  useLayoutEffect(() => {
69964
69757
  return () => {
69965
- virtual.dropRowLocator(ownerId);
69966
- virtual.drop(ownerId);
69758
+ listRows.dropRowLocator(ownerId);
69759
+ listRows.drop(ownerId);
69967
69760
  };
69968
69761
  }, []);
69969
69762
 
@@ -69989,7 +69782,7 @@ const ListItems = ({
69989
69782
  let askStart = missingStart;
69990
69783
  let askEnd = missingEnd;
69991
69784
  if (missingStart !== -1) {
69992
- const rowsPerPage = pageSize || virtual.renderBudget;
69785
+ const rowsPerPage = pageSize || listRows.renderBudget;
69993
69786
  const holeSize = missingEnd - missingStart + 1;
69994
69787
  if (holeSize < rowsPerPage) {
69995
69788
  // Which way the page grows: away from the rows already held, which is
@@ -70104,7 +69897,7 @@ const ListItems = ({
70104
69897
  rows.push(jsx("li", {
70105
69898
  className: "navi_list_failed_rows",
70106
69899
  style: {
70107
- "--size-to-fill": `${failedRowCount * virtual.virtualItemSizeSignal.value}px`
69900
+ "--size-to-fill": `${failedRowCount * listRows.virtualItemSizeSignal.value}px`
70108
69901
  },
70109
69902
  children: renderError ? renderError({
70110
69903
  error: store.failure.error,
@@ -70142,13 +69935,12 @@ const ListItems = ({
70142
69935
  }
70143
69936
  if (rowVnode) {
70144
69937
  pushRow(jsx(ListRunSkeletonRow, {
70145
- run: runRows,
70146
69938
  row: {
70147
69939
  id: key,
70148
69940
  index: rowIndex,
69941
+ ownerId,
70149
69942
  ...getSkeletonRow()
70150
69943
  },
70151
- groupKey: groupKey,
70152
69944
  separator: separator,
70153
69945
  children: rowVnode
70154
69946
  }, key), item, rowIndex, groupKey);
@@ -70159,7 +69951,7 @@ const ListItems = ({
70159
69951
  let rowVnode;
70160
69952
  let rowContextValue;
70161
69953
  const rowVnodeKept = rowVnodesByItem.get(item);
70162
- if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing && rowVnodeKept.rowContextValue.groupKey === groupKey) {
69954
+ if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
70163
69955
  rowVnode = rowVnodeKept.vnode;
70164
69956
  rowContextValue = rowVnodeKept.rowContextValue;
70165
69957
  } else {
@@ -70172,8 +69964,7 @@ const ListItems = ({
70172
69964
  id: key,
70173
69965
  index: rowIndex,
70174
69966
  item,
70175
- run: runRows,
70176
- groupKey
69967
+ ownerId
70177
69968
  };
70178
69969
  rowVnodesByItem.set(item, {
70179
69970
  vnode: rowVnode,
@@ -70200,28 +69991,37 @@ const ListItems = ({
70200
69991
  return rows;
70201
69992
  };
70202
69993
 
70203
- // A run's row that has not arrived, standing where the real one will: it
70204
- // mounts like any row (see createRunRows) and wears the separator of the gap
70205
- // above it, the way a real row does in ListItemUI.
69994
+ // A run's row that has not arrived, standing where the real one will. It never
69995
+ // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
69996
+ // rows here, and wears the separator of the gap above it the way a real row
69997
+ // does there.
69998
+ const SKELETON_ROW_DATA = {
69999
+ skeleton: true
70000
+ };
70206
70001
  const ListRunSkeletonRow = ({
70207
- run,
70208
70002
  row,
70209
- groupKey,
70210
70003
  separator,
70211
70004
  children
70212
70005
  }) => {
70006
+ const listRows = useContext(ListRowsContext);
70007
+ const groupId = useContext(ListGroupContext);
70213
70008
  const rowId = useId();
70214
- run.mount(rowId, row.index, groupKey);
70009
+ listRows.draw(rowId, {
70010
+ ownerId: row.ownerId,
70011
+ place: row.index,
70012
+ groupId,
70013
+ data: SKELETON_ROW_DATA
70014
+ });
70215
70015
  useLayoutEffect(() => {
70216
70016
  return () => {
70217
- run.unmount(rowId);
70017
+ listRows.erase(rowId);
70218
70018
  };
70219
70019
  }, []);
70220
70020
  const rowVnode = jsx(ListRowContext.Provider, {
70221
70021
  value: row,
70222
70022
  children: children
70223
70023
  });
70224
- if (!separator || run.isFirst(row.index, groupKey)) {
70024
+ if (!separator || listRows.isFirst(rowId)) {
70225
70025
  return rowVnode;
70226
70026
  }
70227
70027
  return jsxs(Fragment, {
@@ -70374,7 +70174,7 @@ const useItemStore = ({
70374
70174
  // where the hole is, and cleared by a retry — which is what makes the same
70375
70175
  // range askable again (see the request memory just above).
70376
70176
  const [failure, setFailure] = useState(null);
70377
- const virtual = useContext(ListVirtualContext);
70177
+ const listRows = useContext(ListRowsContext);
70378
70178
  // The rows are there, which is what the list waits for to place itself on the
70379
70179
  // row it is held at (see placeWhereHeld). Said from an effect: a signal read
70380
70180
  // during this very render must not be written during it.
@@ -70383,12 +70183,12 @@ const useItemStore = ({
70383
70183
  return;
70384
70184
  }
70385
70185
  itemsHeldRef.current = true;
70386
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
70186
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
70387
70187
  });
70388
70188
  // Before the first answer a run does not know how many rows it stands for.
70389
70189
  // It stands for a windowful of them: a list that is about to be filled looks
70390
70190
  // like rows on their way, not like an empty list.
70391
- const rowCount = pages.count ?? count ?? virtual.renderBudget;
70191
+ const rowCount = pages.count ?? count ?? listRows.renderBudget;
70392
70192
  // A run that never received anything has nothing to keep on screen: asking
70393
70193
  // again is its first ask, not a refresh.
70394
70194
  if (staleRef.current && pages.count === undefined) {
@@ -70398,9 +70198,9 @@ const useItemStore = ({
70398
70198
  if (!refreshing) {
70399
70199
  return null;
70400
70200
  }
70401
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() + 1;
70201
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() + 1;
70402
70202
  return () => {
70403
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
70203
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() - 1;
70404
70204
  };
70405
70205
  }, [refreshing]);
70406
70206
 
@@ -70499,7 +70299,7 @@ const useItemStore = ({
70499
70299
  // how many rows there are, so it asks for the rows the list would open
70500
70300
  // on — counting back from the end when that is where it opens, the way
70501
70301
  // an HTTP range does.
70502
- const budget = virtual.renderBudget;
70302
+ const budget = listRows.renderBudget;
70503
70303
  let start = missingStart;
70504
70304
  let end = missingEnd;
70505
70305
  let around;
@@ -70510,11 +70310,11 @@ const useItemStore = ({
70510
70310
  // The list is held on a row nothing on screen leads to: the rows it holds
70511
70311
  // do not contain it, so no window it could draw will ever bring it. Only
70512
70312
  // asking for it by name does.
70513
- const wanted = virtual.scrolled;
70313
+ const wanted = listRows.scrolled;
70514
70314
  const askingAroundWantedRow = revalidating &&
70515
70315
  // Only while the hold stands: once the user has taken the list over,
70516
70316
  // the reading position is where they are, not where it opened.
70517
- virtual.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && virtual.locateRow(wanted.id) === null;
70317
+ listRows.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && listRows.locateRow(wanted.id) === null;
70518
70318
  if (askingAroundWantedRow) {
70519
70319
  around = wanted.id;
70520
70320
  // Where it stood when it was written down is enough to frame the ask;
@@ -70537,7 +70337,7 @@ const useItemStore = ({
70537
70337
  around = firstHeld.id;
70538
70338
  }
70539
70339
  } else if (pages.count === undefined) {
70540
- const scrolled = virtual.scrolled;
70340
+ const scrolled = listRows.scrolled;
70541
70341
  if (scrolled === "end") {
70542
70342
  // Counting back from the end, the way an HTTP range does: a list
70543
70343
  // opening on its last rows asks for them before it knows how many
@@ -70568,7 +70368,7 @@ const useItemStore = ({
70568
70368
  // way somewhere the window does not frame yet, `count` that it knows
70569
70369
  // how many rows it stands for.
70570
70370
  const debugAsk = outcome => {
70571
- debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${virtual.holdPending} count=${pages.count})`);
70371
+ debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${listRows.holdPending} count=${pages.count})`);
70572
70372
  };
70573
70373
  if (start === -1) {
70574
70374
  // Nothing missing and nothing to revalidate: the run has what it
@@ -70576,7 +70376,7 @@ const useItemStore = ({
70576
70376
  debugAsk("nothing missing");
70577
70377
  return;
70578
70378
  }
70579
- if (virtual.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
70379
+ if (listRows.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
70580
70380
  // The one ask a hold lets through: the row the list is held on is
70581
70381
  // what would lift the hold, and nothing else is going to bring it.
70582
70382
  debugAsk("held on a row not reached yet");
@@ -70667,7 +70467,7 @@ const useItemStore = ({
70667
70467
  const pageCount = Array.isArray(page) ? pageItems.length : page.count ?? pageStart + pageItems.length;
70668
70468
  // Before the rows land: what is on screen has to stay where it is,
70669
70469
  // and the DOM still shows the state to hold onto.
70670
- virtual.captureAnchor();
70470
+ listRows.captureAnchor();
70671
70471
  if (revalidating) {
70672
70472
  // The rows held stood for a composition that has moved on; the
70673
70473
  // ones outside the window are forgotten and asked for again if the
@@ -70689,7 +70489,7 @@ const useItemStore = ({
70689
70489
  replace: revalidating
70690
70490
  });
70691
70491
  }
70692
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
70492
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
70693
70493
  setPageVersion(version => version + 1);
70694
70494
  };
70695
70495
  const failed = error => {
@@ -70739,7 +70539,7 @@ const useItemStore = ({
70739
70539
  };
70740
70540
 
70741
70541
  /**
70742
- * ListGroup — a labeled group of list items.
70542
+ * List.Group — a labeled group of list items.
70743
70543
  *
70744
70544
  * Renders a <li role="presentation"> wrapper containing a label span
70745
70545
  * (accessible via aria-labelledby) and a <ul role="group"> for the items.
@@ -70757,10 +70557,16 @@ const ListItemGroup = ({
70757
70557
  ...rest
70758
70558
  }) => {
70759
70559
  const groupId = useId();
70760
- const groupTracker = useItemTracker();
70560
+ const listRows = useContext(ListRowsContext);
70561
+ const group = listRows.group(groupId);
70562
+ useLayoutEffect(() => {
70563
+ return () => {
70564
+ listRows.dropGroup(groupId);
70565
+ };
70566
+ }, []);
70761
70567
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
70762
- const groupItemCount = groupTracker.countSignal.value;
70763
- const groupNoMatchCount = groupTracker.noMatchCountSignal.value;
70568
+ const groupItemCount = group.countSignal.value;
70569
+ const groupNoMatchCount = group.noMatchCountSignal.value;
70764
70570
  // Every row of this group failed the search: the label has nothing left to
70765
70571
  // title. "remove" empties the group on its own (and hiddenWhileEmpty takes it
70766
70572
  // out of the flow), "muted" keeps the rows readable so the label stays useful
@@ -70804,8 +70610,8 @@ const ListItemGroup = ({
70804
70610
  className: "navi_list_item_group_list",
70805
70611
  role: "group",
70806
70612
  "aria-labelledby": groupId,
70807
- children: jsx(GroupItemTrackerContext.Provider, {
70808
- value: groupTracker,
70613
+ children: jsx(ListGroupContext.Provider, {
70614
+ value: groupId,
70809
70615
  children: jsx(ListDeclaredChildren, {
70810
70616
  children: children
70811
70617
  })
@@ -71069,7 +70875,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71069
70875
  */
71070
70876
  const List = /*#__PURE__*/Object.assign(ListResolved, {
71071
70877
  Item: ListItem,
71072
- Items: ListItems
70878
+ Items: ListItems,
70879
+ Group: ListItemGroup
71073
70880
  });
71074
70881
 
71075
70882
  const PickerNaviTime = props => {
@@ -76194,414 +76001,812 @@ const SplitButton = props => {
76194
76001
  });
76195
76002
  };
76196
76003
 
76197
- // What the Picker's popup answers to — Picker's own popup props, named here so
76198
- // a caller reaches all of them through the split button (see picker.jsx's JSDoc
76199
- // for what each one says).
76200
- 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"]);
76201
- const splitPopupProps = props => {
76202
- const popupProps = {};
76203
- const boxProps = {};
76204
- for (const key of Object.keys(props)) {
76205
- if (POPUP_PROP_SET.has(key)) {
76206
- popupProps[key] = props[key];
76004
+ // What the Picker's popup answers to — Picker's own popup props, named here so
76005
+ // a caller reaches all of them through the split button (see picker.jsx's JSDoc
76006
+ // for what each one says).
76007
+ 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"]);
76008
+ const splitPopupProps = props => {
76009
+ const popupProps = {};
76010
+ const boxProps = {};
76011
+ for (const key of Object.keys(props)) {
76012
+ if (POPUP_PROP_SET.has(key)) {
76013
+ popupProps[key] = props[key];
76014
+ } else {
76015
+ boxProps[key] = props[key];
76016
+ }
76017
+ }
76018
+ return [popupProps, boxProps];
76019
+ };
76020
+
76021
+ /**
76022
+ * applySearch — matches value against searchText.
76023
+ *
76024
+ * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
76025
+ * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
76026
+ * Multi-word: if searchText contains spaces, each word must appear somewhere in
76027
+ * the value for it to match. Ranges for all words are returned.
76028
+ *
76029
+ * Score table:
76030
+ *
76031
+ * Situation Score
76032
+ * ─────────────────────────────────────── ───────────────────────────
76033
+ * phrase at start of value 1
76034
+ * multi-word, one word at start (all match) 0.75
76035
+ * phrase / word at word boundary 0.625
76036
+ * phrase / words mid-word 0.5
76037
+ * + case-exact bonus +0.125
76038
+ * multi-word partial: score × (matched/total)
76039
+ *
76040
+ * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
76041
+ * Intended to be passed to useSearch as the matchFn parameter.
76042
+ */
76043
+ const applySearch = (searchText, value) => {
76044
+ if (!searchText) {
76045
+ return { match: true, matchScore: 0, matchRanges: [] };
76046
+ }
76047
+ if (searchText.length > 100) {
76048
+ searchText = searchText.slice(0, 100);
76049
+ }
76050
+ const str = String(value);
76051
+ const foldedStr = foldAccents(str).toLowerCase();
76052
+ const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
76053
+
76054
+ // Try exact phrase match first (gives best score).
76055
+ const phraseRanges = [];
76056
+ let phraseIdx = foldedStr.indexOf(foldedSearch);
76057
+ while (phraseIdx !== -1) {
76058
+ phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
76059
+ phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
76060
+ }
76061
+ if (phraseRanges.length > 0) {
76062
+ const atStart = foldedStr.startsWith(foldedSearch);
76063
+ const atWordBoundary = phraseRanges.some(([start]) =>
76064
+ isWordBoundary(foldedStr, start),
76065
+ );
76066
+ const caseExact = str.includes(searchText);
76067
+ let baseScore;
76068
+ if (atStart) {
76069
+ baseScore = SCORE_PHRASE_AT_START;
76070
+ } else if (atWordBoundary) {
76071
+ baseScore = SCORE_AT_WORD_BOUNDARY;
76072
+ } else {
76073
+ baseScore = SCORE_MID_WORD;
76074
+ }
76075
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76076
+ return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
76077
+ }
76078
+
76079
+ // Multi-word OR: split on whitespace, any word matching contributes to the score.
76080
+ // Items where all words match rank higher than partial matches.
76081
+ // Note: words always has at least 1 element here (searchText is non-empty and
76082
+ // foldedSearch.split filters empty strings). This path also handles the case
76083
+ // where searchText has trailing/leading spaces: the phrase match above tries
76084
+ // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
76085
+ // here to try each word individually (e.g. "tc" matches "tca").
76086
+ const matchRanges = [];
76087
+ let matchedWordCount = 0;
76088
+ let anyWordAtStart = false;
76089
+ let anyWordAtWordBoundary = false;
76090
+ let allMatchedWordsExact = true;
76091
+ for (let w = 0; w < words.length; w++) {
76092
+ const word = words[w];
76093
+ const originalWord = originalWords[w];
76094
+ let idx = foldedStr.indexOf(word);
76095
+ if (idx === -1) {
76096
+ continue;
76097
+ }
76098
+ matchedWordCount++;
76099
+ let wordHasExactMatch = false;
76100
+ while (idx !== -1) {
76101
+ matchRanges.push([idx, idx + word.length]);
76102
+ if (idx === 0) {
76103
+ anyWordAtStart = true;
76104
+ anyWordAtWordBoundary = true;
76105
+ } else if (isWordBoundary(foldedStr, idx)) {
76106
+ anyWordAtWordBoundary = true;
76107
+ }
76108
+ if (str.slice(idx, idx + word.length) === originalWord) {
76109
+ wordHasExactMatch = true;
76110
+ }
76111
+ idx = foldedStr.indexOf(word, idx + 1);
76112
+ }
76113
+ if (!wordHasExactMatch) {
76114
+ allMatchedWordsExact = false;
76115
+ }
76116
+ }
76117
+ if (matchedWordCount === 0) {
76118
+ return tryAcronymMatch(foldedStr, str, searchText);
76119
+ }
76120
+ const wordRatio = matchedWordCount / words.length;
76121
+ let baseScore;
76122
+ if (anyWordAtStart) {
76123
+ baseScore = SCORE_MULTI_WORD_AT_START;
76124
+ } else if (anyWordAtWordBoundary) {
76125
+ baseScore = SCORE_AT_WORD_BOUNDARY;
76126
+ } else {
76127
+ baseScore = SCORE_MID_WORD;
76128
+ }
76129
+ const matchScore =
76130
+ (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
76131
+ wordRatio;
76132
+ return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
76133
+ };
76134
+
76135
+ // Returns true when position idx in str is at a word boundary,
76136
+ // meaning it is either the start of the string or the preceding character
76137
+ // is not a Unicode letter or digit.
76138
+ const isWordBoundary = (str, idx) => {
76139
+ if (idx === 0) {
76140
+ return true;
76141
+ }
76142
+ return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
76143
+ };
76144
+
76145
+ // Strip diacritics for accent-insensitive matching.
76146
+ // NFC normalization first ensures precomposed characters (é → single code unit),
76147
+ // so the folded string has the same length as the NFC source — ranges computed
76148
+ // on the folded string map 1:1 to positions in the original string.
76149
+ const foldAccents = (str) => {
76150
+ return str
76151
+ .normalize("NFC")
76152
+ .normalize("NFD")
76153
+ .replace(/\p{Mn}/gu, "");
76154
+ };
76155
+
76156
+ const SCORE_PHRASE_AT_START = 1;
76157
+ const SCORE_MULTI_WORD_AT_START = 0.75;
76158
+ const SCORE_AT_WORD_BOUNDARY = 0.625;
76159
+ const SCORE_MID_WORD = 0.5;
76160
+ const SCORE_ACRONYM = 0.4;
76161
+ const SCORE_BONUS_CASE_EXACT = 0.125;
76162
+
76163
+ // Acronym match: each char of searchText (spaces stripped) must be the first
76164
+ // letter of a word in value, in order (greedy subsequence on word-starts).
76165
+ // e.g. "TC" matches "Total Count" highlighting the T and C.
76166
+ const tryAcronymMatch = (foldedStr, str, searchText) => {
76167
+ const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
76168
+ if (acronymChars.length < 2) {
76169
+ // Single-char acronym is too ambiguous — skip.
76170
+ return { match: false, matchScore: 0, matchRanges: [] };
76171
+ }
76172
+ const wordStarts = [];
76173
+ for (let i = 0; i < foldedStr.length; i++) {
76174
+ if (isWordBoundary(foldedStr, i)) {
76175
+ wordStarts.push(i);
76176
+ }
76177
+ }
76178
+ const matchedPositions = [];
76179
+ let wordIdx = 0;
76180
+ const originalAcronym = searchText.replace(/\s/g, "");
76181
+ for (let si = 0; si < acronymChars.length; si++) {
76182
+ const ch = acronymChars[si];
76183
+ let found = false;
76184
+ while (wordIdx < wordStarts.length) {
76185
+ const pos = wordStarts[wordIdx];
76186
+ wordIdx++;
76187
+ if (foldedStr[pos] === ch) {
76188
+ matchedPositions.push(pos);
76189
+ found = true;
76190
+ break;
76191
+ }
76192
+ }
76193
+ if (!found) {
76194
+ return { match: false, matchScore: 0, matchRanges: [] };
76195
+ }
76196
+ }
76197
+ const atStart = matchedPositions[0] === 0;
76198
+ const caseExact = matchedPositions.every(
76199
+ (p, i) => str[p] === originalAcronym[i],
76200
+ );
76201
+ const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
76202
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76203
+ const matchRanges = matchedPositions.map((p) => [p, p + 1]);
76204
+ return { match: true, matchScore, matchRanges };
76205
+ };
76206
+
76207
+ // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
76208
+ // for the same searchText across all items in a list render.
76209
+ const searchCache = new Map();
76210
+ const SEARCH_CACHE_MAX_SIZE = 20;
76211
+ const getSearchInfo = (searchText) => {
76212
+ if (searchCache.has(searchText)) {
76213
+ const cached = searchCache.get(searchText);
76214
+ searchCache.delete(searchText);
76215
+ searchCache.set(searchText, cached);
76216
+ return cached;
76217
+ }
76218
+ const foldedSearch = foldAccents(searchText).toLowerCase();
76219
+ const words = foldedSearch.split(/\s+/).filter(Boolean);
76220
+ const originalWords = searchText.split(/\s+/).filter(Boolean);
76221
+ const info = { foldedSearch, words, originalWords };
76222
+ searchCache.set(searchText, info);
76223
+ if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
76224
+ searchCache.delete(searchCache.keys().next().value);
76225
+ }
76226
+ return info;
76227
+ };
76228
+
76229
+ // Merge overlapping or adjacent [start, end] ranges (sorted by start).
76230
+ const mergeRanges = (ranges) => {
76231
+ if (ranges.length < 2) {
76232
+ return ranges;
76233
+ }
76234
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
76235
+ const merged = [sorted[0]];
76236
+ for (let i = 1; i < sorted.length; i++) {
76237
+ const last = merged[merged.length - 1];
76238
+ const current = sorted[i];
76239
+ if (current[0] <= last[1]) {
76240
+ if (current[1] > last[1]) {
76241
+ last[1] = current[1];
76242
+ }
76243
+ } else {
76244
+ merged.push(current);
76245
+ }
76246
+ }
76247
+ return merged;
76248
+ };
76249
+
76250
+ /**
76251
+ * createSearch — builds a matchFn compatible with useSearch that searches
76252
+ * across multiple named fields of an item, each with its own DOM selector
76253
+ * and optional priority weight.
76254
+ *
76255
+ * Usage:
76256
+ * ```js
76257
+ * const searchPerson = createSearch({
76258
+ * name: {
76259
+ * getter: (item) => item.name,
76260
+ * domSelector: ".name",
76261
+ * },
76262
+ * address: {
76263
+ * getter: (item) => item.address,
76264
+ * domSelector: ".address",
76265
+ * priority: 1.5,
76266
+ * },
76267
+ * });
76268
+ *
76269
+ * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
76270
+ * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
76271
+ * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
76272
+ * // — ListItem handles the per-selector object format for matchRanges.
76273
+ * ```
76274
+ *
76275
+ * Each field config:
76276
+ * - getter(item): string — extracts the text to search
76277
+ * - domSelector: string — CSS selector used by ListItem to find the target element
76278
+ * - priority?: number — multiplier applied to the field's score (default 1)
76279
+ * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
76280
+ * defaults to applySearch
76281
+ */
76282
+ const createSearch = (fields) => {
76283
+ return (searchText, item) => {
76284
+ if (!searchText) {
76285
+ return { match: true, matchScore: 0, matchRanges: {} };
76286
+ }
76287
+ let totalScore = 0;
76288
+ const matchRanges = {};
76289
+ for (const [
76290
+ ,
76291
+ { getter, domSelector, priority = 1, matchFn = applySearch },
76292
+ ] of Object.entries(fields)) {
76293
+ const fieldValue = getter(item);
76294
+ const result = matchFn(searchText, fieldValue);
76295
+ if (result.match && result.matchRanges.length > 0) {
76296
+ totalScore += result.matchScore * priority;
76297
+ matchRanges[domSelector] = result.matchRanges;
76298
+ }
76299
+ }
76300
+ if (totalScore === 0) {
76301
+ return { match: false, matchScore: 0, matchRanges: {} };
76302
+ }
76303
+ return { match: true, matchScore: totalScore, matchRanges };
76304
+ };
76305
+ };
76306
+
76307
+ /**
76308
+ * useSearch — reorders items so matched ones come first (sorted by score desc),
76309
+ * followed by non-matched items in their natural order. No item is hidden.
76310
+ * Returns [orderedItems, getItemMatchInfo].
76311
+ * - orderedItems: all items, reordered
76312
+ * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
76313
+ * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
76314
+ * there is no need to destructure the three fields by hand.
76315
+ *
76316
+ * When searchText is empty, natural order is preserved and all items match with score 0.
76317
+ *
76318
+ * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
76319
+ * to each ListItem. The list's matchFallback will be shown when all items are hidden.
76320
+ */
76321
+ const useSearchText = (searchText, items, matchFn = applySearch) => {
76322
+ if (typeof searchText !== "string" && searchText !== undefined) {
76323
+ throw new TypeError(
76324
+ "useSearchText: searchText must be a string or undefined",
76325
+ );
76326
+ }
76327
+ if (items === undefined) {
76328
+ throw new TypeError("useSearch: items is undefined");
76329
+ }
76330
+ const { orderedItems, matchInfoMap } = useMemo(() => {
76331
+ const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
76332
+ searchText,
76333
+ items,
76334
+ matchFn,
76335
+ );
76336
+ const orderedItems = [];
76337
+ for (const [, bucket] of scoreEntries) {
76338
+ for (const { item } of bucket) {
76339
+ orderedItems.push(item);
76340
+ }
76341
+ }
76342
+ for (const { item } of nonMatched) {
76343
+ orderedItems.push(item);
76344
+ }
76345
+ return { orderedItems, matchInfoMap };
76346
+ }, [items, searchText, matchFn]);
76347
+
76348
+ // The same function for as long as the map is the same: a `renderItem`
76349
+ // reading it is stable only if this is, and a run keeps the rows it drew
76350
+ // only for a stable `renderItem` (see List.Items).
76351
+ const getItemMatchInfo = useCallback(
76352
+ (item) => matchInfoMap.get(item),
76353
+ [matchInfoMap],
76354
+ );
76355
+
76356
+ return [orderedItems, getItemMatchInfo];
76357
+ };
76358
+
76359
+ const buildMatchInfo = (searchText, items, matchFn) => {
76360
+ // scoreEntries: [score, bucket][] kept sorted desc by score.
76361
+ // New distinct score values are inserted via bisect — O(1) in practice
76362
+ // since there are very few distinct scores (today just 0 and 1).
76363
+ const scoreEntries = []; // [score, bucket][]
76364
+ const nonMatched = [];
76365
+
76366
+ for (const item of items) {
76367
+ const result = matchFn(searchText, item);
76368
+ if (!result.match) {
76369
+ nonMatched.push({
76370
+ item,
76371
+ matchScore: result.matchScore,
76372
+ matchRanges: result.matchRanges,
76373
+ });
76374
+ continue;
76375
+ }
76376
+ const score = result.matchScore;
76377
+ // Find existing bucket or insert a new entry in desc order.
76378
+ let lo = 0;
76379
+ let hi = scoreEntries.length;
76380
+ while (lo < hi) {
76381
+ const mid = (lo + hi) >> 1;
76382
+ if (scoreEntries[mid][0] > score) {
76383
+ lo = mid + 1;
76384
+ } else if (scoreEntries[mid][0] < score) {
76385
+ hi = mid;
76386
+ } else {
76387
+ lo = mid;
76388
+ hi = mid; // exact match — found the bucket
76389
+ }
76390
+ }
76391
+ if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
76392
+ scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
76207
76393
  } else {
76208
- boxProps[key] = props[key];
76394
+ scoreEntries.splice(lo, 0, [
76395
+ score,
76396
+ [{ item, matchRanges: result.matchRanges }],
76397
+ ]);
76209
76398
  }
76210
76399
  }
76211
- return [popupProps, boxProps];
76400
+
76401
+ const matchInfoMap = new Map();
76402
+ for (const [score, bucket] of scoreEntries) {
76403
+ for (const { item, matchRanges } of bucket) {
76404
+ matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
76405
+ }
76406
+ }
76407
+ for (const { item, matchScore, matchRanges } of nonMatched) {
76408
+ matchInfoMap.set(item, { match: false, matchScore, matchRanges });
76409
+ }
76410
+
76411
+ return { scoreEntries, nonMatched, matchInfoMap };
76212
76412
  };
76213
76413
 
76214
- /**
76215
- * applySearchmatches value against searchText.
76414
+ /*
76415
+ * useItemTracker()hook that creates a stable item tracker for the lifetime
76416
+ * of the host component.
76216
76417
  *
76217
- * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
76218
- * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
76219
- * Multi-word: if searchText contains spaces, each word must appear somewhere in
76220
- * the value for it to match. Ranges for all words are returned.
76418
+ * USAGE:
76419
+ * ```jsx
76420
+ * function ListControlled({ items }) {
76421
+ * const tracker = useItemTracker({
76422
+ * onChange: () => console.log("items changed"),
76423
+ * });
76221
76424
  *
76222
- * Score table:
76425
+ * return (
76426
+ * <ul>
76427
+ * {items.map((item, i) => (
76428
+ * <Row key={item.id} id={item.id} index={i} hidden={item.hidden} value={item.value} tracker={tracker} />
76429
+ * ))}
76430
+ * <Count tracker={tracker} />
76431
+ * </ul>
76432
+ * );
76433
+ * }
76223
76434
  *
76224
- * Situation Score
76225
- * ─────────────────────────────────────── ───────────────────────────
76226
- * phrase at start of value 1
76227
- * multi-word, one word at start (all match) 0.75
76228
- * phrase / word at word boundary 0.625
76229
- * phrase / words mid-word 0.5
76230
- * + case-exact bonus +0.125
76231
- * multi-word partial: score × (matched/total)
76435
+ * function Row({ id, index, hidden, value, tracker }) {
76436
+ * const visibleIndex = tracker.useTrackItem({ id, index, hidden, value });
76437
+ * if (visibleIndex === -1) return null;
76438
+ * return <li>{value}</li>;
76439
+ * }
76232
76440
  *
76233
- * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
76234
- * Intended to be passed to useSearch as the matchFn parameter.
76441
+ * function Count({ tracker }) {
76442
+ * const count = tracker.visibleCountSignal.value; // re-renders only when count changes
76443
+ * return <span>{count} items</span>;
76444
+ * }
76445
+ * ```
76446
+ *
76447
+ * INTERNALS:
76448
+ * - registrations: Map key → data, contains only visible items
76449
+ * - idToKey: Map id → key, stable across renders
76450
+ * - orderedKeys: number[] of visible item keys sorted by explicit order
76451
+ * - keyToOrderedIndex: Map key → orderedKeys index, gives O(1) indexOf equivalent
76452
+ * - keyToExplicitOrder: Map key → explicitly passed index, used to maintain sort order
76453
+ * - allItemsSignal: signal(array), all items including hidden, ordered by explicit index
76454
+ * - visibleItemsSignal: signal(array), non-hidden items only
76455
+ * - countSignal: signal(number), count of all items including hidden
76456
+ * - visibleCountSignal: signal(number), updated in microtask batch, only when count changes
76457
+ * - propSignals: Map propName → signal(array), updated in microtask batch with element equality
76458
+ * - onChangeRef: holds the latest onChange callback, called once per microtask batch
76459
+ *
76460
+ * useTrackItem(id, data, index): registers the item with an explicitly provided index
76461
+ * that determines its position among siblings. The caller (e.g. items.map) knows the
76462
+ * correct order and passes it directly — no render-sequence deduction needed.
76463
+ * Returns the visible rank (position among non-hidden items), or -1 when hidden.
76464
+ * Signals and onChange are deferred to a microtask so multiple items updating
76465
+ * in one commit cause only one notification.
76466
+ *
76467
+ * getTrackedItemByIndex(index): synchronous O(1) lookup of a visible item by
76468
+ * its visible rank. Returns undefined when index is out of range.
76469
+ *
76470
+ * peekItems(): the items as they stand right now, without waiting for the
76471
+ * deferred notification — what a sibling rendering after the items must read
76472
+ * to paint them in the same commit.
76235
76473
  */
76236
- const applySearch = (searchText, value) => {
76237
- if (!searchText) {
76238
- return { match: true, matchScore: 0, matchRanges: [] };
76239
- }
76240
- if (searchText.length > 100) {
76241
- searchText = searchText.slice(0, 100);
76242
- }
76243
- const str = String(value);
76244
- const foldedStr = foldAccents(str).toLowerCase();
76245
- const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
76246
76474
 
76247
- // Try exact phrase match first (gives best score).
76248
- const phraseRanges = [];
76249
- let phraseIdx = foldedStr.indexOf(foldedSearch);
76250
- while (phraseIdx !== -1) {
76251
- phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
76252
- phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
76253
- }
76254
- if (phraseRanges.length > 0) {
76255
- const atStart = foldedStr.startsWith(foldedSearch);
76256
- const atWordBoundary = phraseRanges.some(([start]) =>
76257
- isWordBoundary(foldedStr, start),
76258
- );
76259
- const caseExact = str.includes(searchText);
76260
- let baseScore;
76261
- if (atStart) {
76262
- baseScore = SCORE_PHRASE_AT_START;
76263
- } else if (atWordBoundary) {
76264
- baseScore = SCORE_AT_WORD_BOUNDARY;
76265
- } else {
76266
- baseScore = SCORE_MID_WORD;
76267
- }
76268
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76269
- return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
76475
+ const useItemTracker = ({ onChange } = {}) => {
76476
+ const onChangeRef = useRef(onChange);
76477
+ onChangeRef.current = onChange;
76478
+ const trackerRef = useRef(null);
76479
+ let tracker = trackerRef.current;
76480
+ if (!tracker) {
76481
+ trackerRef.current = tracker = createItemTracker((items) => {
76482
+ onChangeRef.current?.(items);
76483
+ });
76270
76484
  }
76485
+ // When code in useLayoutEffect of the caller wants to run the tracker must be in sync
76486
+ // without this layout effect the tracker might not have been synced yet and preact would call layout effect
76487
+ // before we had time to sync
76488
+ useLayoutEffect(() => {
76489
+ tracker._flushSync();
76490
+ });
76491
+ return tracker;
76492
+ };
76493
+
76494
+ const createItemTracker = (onChange) => {
76495
+ const registrations = new Map(); // key → data (visible items only)
76496
+ const idToKey = new Map(); // id → insertion key (stable, auto-incremented)
76497
+ let keyCounter = 0;
76498
+ // orderedKeys: visible item keys sorted by their explicitly provided index.
76499
+ const orderedKeys = []; // number[]
76500
+ // keyToOrderedIndex: O(1) equivalent of orderedKeys.indexOf(key).
76501
+ const keyToOrderedIndex = new Map(); // key → index in orderedKeys
76502
+ const allKeys = new Set(); // all registered keys including hidden
76503
+ const keyToExplicitOrder = new Map(); // key → explicitly passed index
76504
+
76505
+ const allRegistrations = new Map(); // key → data (all items including hidden)
76506
+ const allOrderedKeys = []; // all item keys sorted by explicit order
76507
+ const keyToAllOrderedIndex = new Map(); // key → index in allOrderedKeys
76508
+
76509
+ const itemsSignal = signal([]);
76510
+ const visibleItemsSignal = signal([]);
76511
+ const countSignal = signal(0);
76512
+ const visibleCountSignal = signal(0);
76513
+ const noMatchCountSignal = signal(0);
76514
+
76515
+ let notifyScheduled = false;
76516
+ const runNotify = () => {
76517
+ batch(() => {
76518
+ let someChange = false;
76519
+
76520
+ const newCount = allKeys.size;
76521
+ const countModified = countSignal.peek() !== newCount;
76522
+ if (countModified) {
76523
+ countSignal.value = newCount;
76524
+ someChange = true;
76525
+ }
76526
+
76527
+ // Build allItems and visibleItems in a single pass over allOrderedKeys.
76528
+ // Visible items are those without data.hidden or data.filtered — same
76529
+ // relative order as orderedKeys (syncItem already excludes both from
76530
+ // orderedKeys; this must match or consumers relying on visibleCountSignal
76531
+ // would count filtered-out items as if they still took up space).
76532
+ const prevAllItems = itemsSignal.peek();
76533
+ const prevVisibleItems = visibleItemsSignal.peek();
76534
+ let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
76535
+ let visibleItemsChanged = false;
76536
+ const allItems = [];
76537
+ const visibleItems = [];
76538
+ let newNoMatchCount = 0;
76539
+ for (let i = 0; i < allOrderedKeys.length; i++) {
76540
+ const key = allOrderedKeys[i];
76541
+ const item = allRegistrations.get(key);
76542
+ allItems.push(item);
76543
+ // Compare by reference: catches any prop change (id, selected, disabled, …)
76544
+ if (!allItemsChanged && item !== prevAllItems[i]) {
76545
+ allItemsChanged = true;
76546
+ }
76547
+ if (item.match === false) {
76548
+ newNoMatchCount++;
76549
+ }
76550
+ if (!item.hidden && !item.filtered) {
76551
+ const visibleIdx = visibleItems.length;
76552
+ visibleItems.push(item);
76553
+ if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
76554
+ visibleItemsChanged = true;
76555
+ }
76556
+ }
76557
+ }
76558
+
76559
+ const newVisibleCount = visibleItems.length;
76560
+ const visibleCountModified =
76561
+ visibleCountSignal.peek() !== newVisibleCount;
76562
+ if (visibleCountModified) {
76563
+ visibleCountSignal.value = newVisibleCount;
76564
+ someChange = true;
76565
+ }
76566
+ if (allItemsChanged) {
76567
+ itemsSignal.value = allItems;
76568
+ someChange = true;
76569
+ }
76570
+ if (visibleItemsChanged) {
76571
+ visibleItemsSignal.value = visibleItems;
76572
+ someChange = true;
76573
+ }
76574
+ const noMatchCountModified =
76575
+ noMatchCountSignal.peek() !== newNoMatchCount;
76576
+ if (noMatchCountModified) {
76577
+ noMatchCountSignal.value = newNoMatchCount;
76578
+ someChange = true;
76579
+ }
76580
+ if (someChange) {
76581
+ onChange?.();
76582
+ }
76583
+ });
76584
+ };
76271
76585
 
76272
- // Multi-word OR: split on whitespace, any word matching contributes to the score.
76273
- // Items where all words match rank higher than partial matches.
76274
- // Note: words always has at least 1 element here (searchText is non-empty and
76275
- // foldedSearch.split filters empty strings). This path also handles the case
76276
- // where searchText has trailing/leading spaces: the phrase match above tries
76277
- // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
76278
- // here to try each word individually (e.g. "tc" matches "tca").
76279
- const matchRanges = [];
76280
- let matchedWordCount = 0;
76281
- let anyWordAtStart = false;
76282
- let anyWordAtWordBoundary = false;
76283
- let allMatchedWordsExact = true;
76284
- for (let w = 0; w < words.length; w++) {
76285
- const word = words[w];
76286
- const originalWord = originalWords[w];
76287
- let idx = foldedStr.indexOf(word);
76288
- if (idx === -1) {
76289
- continue;
76586
+ const notify = () => {
76587
+ if (notifyScheduled) {
76588
+ return;
76290
76589
  }
76291
- matchedWordCount++;
76292
- let wordHasExactMatch = false;
76293
- while (idx !== -1) {
76294
- matchRanges.push([idx, idx + word.length]);
76295
- if (idx === 0) {
76296
- anyWordAtStart = true;
76297
- anyWordAtWordBoundary = true;
76298
- } else if (isWordBoundary(foldedStr, idx)) {
76299
- anyWordAtWordBoundary = true;
76590
+ notifyScheduled = true;
76591
+ queueMicrotask(() => {
76592
+ if (!notifyScheduled) {
76593
+ return; // was already flushed synchronously
76300
76594
  }
76301
- if (str.slice(idx, idx + word.length) === originalWord) {
76302
- wordHasExactMatch = true;
76595
+ notifyScheduled = false;
76596
+ runNotify();
76597
+ });
76598
+ };
76599
+
76600
+ const _flushSync = () => {
76601
+ if (!notifyScheduled) {
76602
+ return;
76603
+ }
76604
+ notifyScheduled = false;
76605
+ runNotify();
76606
+ };
76607
+
76608
+ // Insert key into orderedKeys at the correct position based on explicitOrder.
76609
+ // Uses binary search for O(log n) insertion.
76610
+ const insertKey = (key, explicitOrder) => {
76611
+ let lo = 0;
76612
+ let hi = orderedKeys.length;
76613
+ while (lo < hi) {
76614
+ const mid = (lo + hi) >> 1;
76615
+ if (keyToExplicitOrder.get(orderedKeys[mid]) <= explicitOrder) {
76616
+ lo = mid + 1;
76617
+ } else {
76618
+ hi = mid;
76303
76619
  }
76304
- idx = foldedStr.indexOf(word, idx + 1);
76305
76620
  }
76306
- if (!wordHasExactMatch) {
76307
- allMatchedWordsExact = false;
76621
+ orderedKeys.splice(lo, 0, key);
76622
+ for (let i = lo; i < orderedKeys.length; i++) {
76623
+ keyToOrderedIndex.set(orderedKeys[i], i);
76308
76624
  }
76309
- }
76310
- if (matchedWordCount === 0) {
76311
- return tryAcronymMatch(foldedStr, str, searchText);
76312
- }
76313
- const wordRatio = matchedWordCount / words.length;
76314
- let baseScore;
76315
- if (anyWordAtStart) {
76316
- baseScore = SCORE_MULTI_WORD_AT_START;
76317
- } else if (anyWordAtWordBoundary) {
76318
- baseScore = SCORE_AT_WORD_BOUNDARY;
76319
- } else {
76320
- baseScore = SCORE_MID_WORD;
76321
- }
76322
- const matchScore =
76323
- (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
76324
- wordRatio;
76325
- return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
76326
- };
76327
-
76328
- // Returns true when position idx in str is at a word boundary,
76329
- // meaning it is either the start of the string or the preceding character
76330
- // is not a Unicode letter or digit.
76331
- const isWordBoundary = (str, idx) => {
76332
- if (idx === 0) {
76333
- return true;
76334
- }
76335
- return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
76336
- };
76337
-
76338
- // Strip diacritics for accent-insensitive matching.
76339
- // NFC normalization first ensures precomposed characters (é → single code unit),
76340
- // so the folded string has the same length as the NFC source — ranges computed
76341
- // on the folded string map 1:1 to positions in the original string.
76342
- const foldAccents = (str) => {
76343
- return str
76344
- .normalize("NFC")
76345
- .normalize("NFD")
76346
- .replace(/\p{Mn}/gu, "");
76347
- };
76348
-
76349
- const SCORE_PHRASE_AT_START = 1;
76350
- const SCORE_MULTI_WORD_AT_START = 0.75;
76351
- const SCORE_AT_WORD_BOUNDARY = 0.625;
76352
- const SCORE_MID_WORD = 0.5;
76353
- const SCORE_ACRONYM = 0.4;
76354
- const SCORE_BONUS_CASE_EXACT = 0.125;
76625
+ };
76355
76626
 
76356
- // Acronym match: each char of searchText (spaces stripped) must be the first
76357
- // letter of a word in value, in order (greedy subsequence on word-starts).
76358
- // e.g. "TC" matches "Total Count" highlighting the T and C.
76359
- const tryAcronymMatch = (foldedStr, str, searchText) => {
76360
- const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
76361
- if (acronymChars.length < 2) {
76362
- // Single-char acronym is too ambiguous — skip.
76363
- return { match: false, matchScore: 0, matchRanges: [] };
76364
- }
76365
- const wordStarts = [];
76366
- for (let i = 0; i < foldedStr.length; i++) {
76367
- if (isWordBoundary(foldedStr, i)) {
76368
- wordStarts.push(i);
76369
- }
76370
- }
76371
- const matchedPositions = [];
76372
- let wordIdx = 0;
76373
- const originalAcronym = searchText.replace(/\s/g, "");
76374
- for (let si = 0; si < acronymChars.length; si++) {
76375
- const ch = acronymChars[si];
76376
- let found = false;
76377
- while (wordIdx < wordStarts.length) {
76378
- const pos = wordStarts[wordIdx];
76379
- wordIdx++;
76380
- if (foldedStr[pos] === ch) {
76381
- matchedPositions.push(pos);
76382
- found = true;
76383
- break;
76627
+ const insertAllKey = (key, explicitOrder) => {
76628
+ let lo = 0;
76629
+ let hi = allOrderedKeys.length;
76630
+ while (lo < hi) {
76631
+ const mid = (lo + hi) >> 1;
76632
+ if (keyToExplicitOrder.get(allOrderedKeys[mid]) <= explicitOrder) {
76633
+ lo = mid + 1;
76634
+ } else {
76635
+ hi = mid;
76384
76636
  }
76385
76637
  }
76386
- if (!found) {
76387
- return { match: false, matchScore: 0, matchRanges: [] };
76638
+ allOrderedKeys.splice(lo, 0, key);
76639
+ for (let i = lo; i < allOrderedKeys.length; i++) {
76640
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76388
76641
  }
76389
- }
76390
- const atStart = matchedPositions[0] === 0;
76391
- const caseExact = matchedPositions.every(
76392
- (p, i) => str[p] === originalAcronym[i],
76393
- );
76394
- const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
76395
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
76396
- const matchRanges = matchedPositions.map((p) => [p, p + 1]);
76397
- return { match: true, matchScore, matchRanges };
76398
- };
76642
+ };
76399
76643
 
76400
- // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
76401
- // for the same searchText across all items in a list render.
76402
- const searchCache = new Map();
76403
- const SEARCH_CACHE_MAX_SIZE = 20;
76404
- const getSearchInfo = (searchText) => {
76405
- if (searchCache.has(searchText)) {
76406
- const cached = searchCache.get(searchText);
76407
- searchCache.delete(searchText);
76408
- searchCache.set(searchText, cached);
76409
- return cached;
76410
- }
76411
- const foldedSearch = foldAccents(searchText).toLowerCase();
76412
- const words = foldedSearch.split(/\s+/).filter(Boolean);
76413
- const originalWords = searchText.split(/\s+/).filter(Boolean);
76414
- const info = { foldedSearch, words, originalWords };
76415
- searchCache.set(searchText, info);
76416
- if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
76417
- searchCache.delete(searchCache.keys().next().value);
76418
- }
76419
- return info;
76420
- };
76644
+ const removeAllKey = (key) => {
76645
+ const idx = keyToAllOrderedIndex.get(key);
76646
+ if (idx !== undefined) {
76647
+ allOrderedKeys.splice(idx, 1);
76648
+ keyToAllOrderedIndex.delete(key);
76649
+ for (let i = idx; i < allOrderedKeys.length; i++) {
76650
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76651
+ }
76652
+ }
76653
+ };
76421
76654
 
76422
- // Merge overlapping or adjacent [start, end] ranges (sorted by start).
76423
- const mergeRanges = (ranges) => {
76424
- if (ranges.length < 2) {
76425
- return ranges;
76426
- }
76427
- const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
76428
- const merged = [sorted[0]];
76429
- for (let i = 1; i < sorted.length; i++) {
76430
- const last = merged[merged.length - 1];
76431
- const current = sorted[i];
76432
- if (current[0] <= last[1]) {
76433
- if (current[1] > last[1]) {
76434
- last[1] = current[1];
76655
+ // Register or update an item. data.hidden controls visibility.
76656
+ // explicitOrder is the caller-provided index that determines sort position.
76657
+ const syncItem = (key, index, data) => {
76658
+ if (data.role === "presentation") {
76659
+ registrations.delete(key);
76660
+ const idx = keyToOrderedIndex.get(key);
76661
+ if (idx !== undefined) {
76662
+ orderedKeys.splice(idx, 1);
76663
+ keyToOrderedIndex.delete(key);
76664
+ for (let i = idx; i < orderedKeys.length; i++) {
76665
+ keyToOrderedIndex.set(orderedKeys[i], i);
76666
+ }
76667
+ }
76668
+ keyToExplicitOrder.delete(key);
76669
+ allRegistrations.delete(key);
76670
+ removeAllKey(key);
76671
+ allKeys.delete(key);
76672
+ return;
76673
+ }
76674
+
76675
+ // Maintain allRegistrations and allOrderedKeys for all non-presentation items.
76676
+ allRegistrations.set(key, data);
76677
+ allKeys.add(key);
76678
+ const currentAllIdx = keyToAllOrderedIndex.get(key);
76679
+ const previousOrder = keyToExplicitOrder.get(key);
76680
+ keyToExplicitOrder.set(key, index);
76681
+ if (currentAllIdx === undefined) {
76682
+ insertAllKey(key, index);
76683
+ } else if (previousOrder !== index) {
76684
+ allOrderedKeys.splice(currentAllIdx, 1);
76685
+ keyToAllOrderedIndex.delete(key);
76686
+ for (let i = currentAllIdx; i < allOrderedKeys.length; i++) {
76687
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
76435
76688
  }
76436
- } else {
76437
- merged.push(current);
76689
+ insertAllKey(key, index);
76438
76690
  }
76439
- }
76440
- return merged;
76441
- };
76442
76691
 
76443
- /**
76444
- * createSearch — builds a matchFn compatible with useSearch that searches
76445
- * across multiple named fields of an item, each with its own DOM selector
76446
- * and optional priority weight.
76447
- *
76448
- * Usage:
76449
- * ```js
76450
- * const searchPerson = createSearch({
76451
- * name: {
76452
- * getter: (item) => item.name,
76453
- * domSelector: ".name",
76454
- * },
76455
- * address: {
76456
- * getter: (item) => item.address,
76457
- * domSelector: ".address",
76458
- * priority: 1.5,
76459
- * },
76460
- * });
76461
- *
76462
- * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
76463
- * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
76464
- * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
76465
- * // — ListItem handles the per-selector object format for matchRanges.
76466
- * ```
76467
- *
76468
- * Each field config:
76469
- * - getter(item): string — extracts the text to search
76470
- * - domSelector: string — CSS selector used by ListItem to find the target element
76471
- * - priority?: number — multiplier applied to the field's score (default 1)
76472
- * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
76473
- * defaults to applySearch
76474
- */
76475
- const createSearch = (fields) => {
76476
- return (searchText, item) => {
76477
- if (!searchText) {
76478
- return { match: true, matchScore: 0, matchRanges: {} };
76479
- }
76480
- let totalScore = 0;
76481
- const matchRanges = {};
76482
- for (const [
76483
- ,
76484
- { getter, domSelector, priority = 1, matchFn = applySearch },
76485
- ] of Object.entries(fields)) {
76486
- const fieldValue = getter(item);
76487
- const result = matchFn(searchText, fieldValue);
76488
- if (result.match && result.matchRanges.length > 0) {
76489
- totalScore += result.matchScore * priority;
76490
- matchRanges[domSelector] = result.matchRanges;
76692
+ if (data.filtered || data.hidden) {
76693
+ registrations.delete(key);
76694
+ const idx = keyToOrderedIndex.get(key);
76695
+ if (idx !== undefined) {
76696
+ orderedKeys.splice(idx, 1);
76697
+ keyToOrderedIndex.delete(key);
76698
+ for (let i = idx; i < orderedKeys.length; i++) {
76699
+ keyToOrderedIndex.set(orderedKeys[i], i);
76700
+ }
76491
76701
  }
76702
+ return;
76492
76703
  }
76493
- if (totalScore === 0) {
76494
- return { match: false, matchScore: 0, matchRanges: {} };
76704
+
76705
+ registrations.set(key, data);
76706
+ const currentIdx = keyToOrderedIndex.get(key);
76707
+ if (currentIdx === undefined) {
76708
+ insertKey(key, index);
76709
+ return;
76495
76710
  }
76496
- return { match: true, matchScore: totalScore, matchRanges };
76711
+ if (previousOrder === index) {
76712
+ return;
76713
+ }
76714
+ orderedKeys.splice(currentIdx, 1);
76715
+ keyToOrderedIndex.delete(key);
76716
+ for (let i = currentIdx; i < orderedKeys.length; i++) {
76717
+ keyToOrderedIndex.set(orderedKeys[i], i);
76718
+ }
76719
+ insertKey(key, index);
76497
76720
  };
76498
- };
76499
76721
 
76500
- /**
76501
- * useSearch — reorders items so matched ones come first (sorted by score desc),
76502
- * followed by non-matched items in their natural order. No item is hidden.
76503
- * Returns [orderedItems, getItemMatchInfo].
76504
- * - orderedItems: all items, reordered
76505
- * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
76506
- * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
76507
- * there is no need to destructure the three fields by hand.
76508
- *
76509
- * When searchText is empty, natural order is preserved and all items match with score 0.
76510
- *
76511
- * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
76512
- * to each ListItem. The list's matchFallback will be shown when all items are hidden.
76513
- */
76514
- const useSearchText = (searchText, items, matchFn = applySearch) => {
76515
- if (typeof searchText !== "string" && searchText !== undefined) {
76516
- throw new TypeError(
76517
- "useSearchText: searchText must be a string or undefined",
76518
- );
76519
- }
76520
- if (items === undefined) {
76521
- throw new TypeError("useSearch: items is undefined");
76522
- }
76523
- const { orderedItems, matchInfoMap } = useMemo(() => {
76524
- const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
76525
- searchText,
76526
- items,
76527
- matchFn,
76528
- );
76529
- const orderedItems = [];
76530
- for (const [, bucket] of scoreEntries) {
76531
- for (const { item } of bucket) {
76532
- orderedItems.push(item);
76722
+ const unregisterKey = (key) => {
76723
+ registrations.delete(key);
76724
+ const idx = keyToOrderedIndex.get(key);
76725
+ if (idx !== undefined) {
76726
+ orderedKeys.splice(idx, 1);
76727
+ keyToOrderedIndex.delete(key);
76728
+ for (let i = idx; i < orderedKeys.length; i++) {
76729
+ keyToOrderedIndex.set(orderedKeys[i], i);
76533
76730
  }
76534
76731
  }
76535
- for (const { item } of nonMatched) {
76536
- orderedItems.push(item);
76732
+ keyToExplicitOrder.delete(key);
76733
+ allRegistrations.delete(key);
76734
+ removeAllKey(key);
76735
+ allKeys.delete(key);
76736
+ };
76737
+
76738
+ const keyForId = (id) => {
76739
+ if (!idToKey.has(id)) {
76740
+ idToKey.set(id, keyCounter++);
76537
76741
  }
76538
- return { orderedItems, matchInfoMap };
76539
- }, [items, searchText, matchFn]);
76742
+ return idToKey.get(id);
76743
+ };
76540
76744
 
76541
- // The same function for as long as the map is the same: a `renderItem`
76542
- // reading it is stable only if this is, and a run keeps the rows it drew
76543
- // only for a stable `renderItem` (see List.Items).
76544
- const getItemMatchInfo = useCallback(
76545
- (item) => matchInfoMap.get(item),
76546
- [matchInfoMap],
76547
- );
76745
+ // Register an item. data.hidden controls visibility.
76746
+ // explicitOrder is the caller-provided index (e.g. from items.map((item, i) => ...))
76747
+ // that determines this item's position among siblings.
76748
+ // Returns the item's visible rank among non-hidden items, or -1 when hidden.
76749
+ const useTrackItem = (data) => {
76750
+ const { id, index } = data;
76751
+ const key = keyForId(id);
76548
76752
 
76549
- return [orderedItems, getItemMatchInfo];
76550
- };
76753
+ syncItem(key, index, data);
76754
+ notify();
76551
76755
 
76552
- const buildMatchInfo = (searchText, items, matchFn) => {
76553
- // scoreEntries: [score, bucket][] kept sorted desc by score.
76554
- // New distinct score values are inserted via bisect — O(1) in practice
76555
- // since there are very few distinct scores (today just 0 and 1).
76556
- const scoreEntries = []; // [score, bucket][]
76557
- const nonMatched = [];
76756
+ useLayoutEffect(() => {
76757
+ return () => {
76758
+ unregisterKey(key);
76759
+ notify();
76760
+ };
76761
+ }, []);
76558
76762
 
76559
- for (const item of items) {
76560
- const result = matchFn(searchText, item);
76561
- if (!result.match) {
76562
- nonMatched.push({
76563
- item,
76564
- matchScore: result.matchScore,
76565
- matchRanges: result.matchRanges,
76566
- });
76567
- continue;
76568
- }
76569
- const score = result.matchScore;
76570
- // Find existing bucket or insert a new entry in desc order.
76571
- let lo = 0;
76572
- let hi = scoreEntries.length;
76573
- while (lo < hi) {
76574
- const mid = (lo + hi) >> 1;
76575
- if (scoreEntries[mid][0] > score) {
76576
- lo = mid + 1;
76577
- } else if (scoreEntries[mid][0] < score) {
76578
- hi = mid;
76579
- } else {
76580
- lo = mid;
76581
- hi = mid; // exact match — found the bucket
76582
- }
76763
+ if (data.filtered || data.hidden || data.role === "presentation") {
76764
+ return -1;
76583
76765
  }
76584
- if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
76585
- scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
76586
- } else {
76587
- scoreEntries.splice(lo, 0, [
76588
- score,
76589
- [{ item, matchRanges: result.matchRanges }],
76590
- ]);
76766
+ return keyToOrderedIndex.get(key) ?? -1;
76767
+ };
76768
+
76769
+ const getTrackedItemByIndex = (index) => {
76770
+ const key = orderedKeys[index];
76771
+ if (key === undefined) {
76772
+ return undefined;
76591
76773
  }
76592
- }
76774
+ return registrations.get(key);
76775
+ };
76593
76776
 
76594
- const matchInfoMap = new Map();
76595
- for (const [score, bucket] of scoreEntries) {
76596
- for (const { item, matchRanges } of bucket) {
76597
- matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
76777
+ // The items as they stand right now, notification pending or not — same
76778
+ // content as itemsSignal, minus the wait.
76779
+ //
76780
+ // Items register during their own render, while the signal is only updated
76781
+ // on a deferred microtask (see notify): a sibling rendering after them would
76782
+ // otherwise paint from an empty list and correct itself a frame later. That
76783
+ // frame is visible whenever the painted size feeds a layout decision — a
76784
+ // dialog sizing itself on its content measures the empty version and shifts
76785
+ // once the real one lands. Reading this instead makes the first paint the
76786
+ // right one. Callers must still subscribe to itemsSignal to re-render on
76787
+ // LATER changes; this is the value to display, not the notification.
76788
+ const peekItems = () => {
76789
+ if (!notifyScheduled) {
76790
+ return itemsSignal.peek();
76598
76791
  }
76599
- }
76600
- for (const { item, matchScore, matchRanges } of nonMatched) {
76601
- matchInfoMap.set(item, { match: false, matchScore, matchRanges });
76602
- }
76792
+ const items = [];
76793
+ for (const key of allOrderedKeys) {
76794
+ items.push(allRegistrations.get(key));
76795
+ }
76796
+ return items;
76797
+ };
76603
76798
 
76604
- return { scoreEntries, nonMatched, matchInfoMap };
76799
+ return {
76800
+ useTrackItem,
76801
+ getTrackedItemByIndex,
76802
+ peekItems,
76803
+ itemsSignal,
76804
+ visibleItemsSignal,
76805
+ countSignal,
76806
+ visibleCountSignal,
76807
+ noMatchCountSignal,
76808
+ _flushSync,
76809
+ };
76605
76810
  };
76606
76811
 
76607
76812
  installImportMetaCssBuild(import.meta);