@jsenv/navi 0.29.343 → 0.29.345

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66030,452 +66030,666 @@ const cssVars = vars => {
66030
66030
  };
66031
66031
  const lengthValue = value => typeof value === "number" ? `${value}px` : value;
66032
66032
 
66033
- /*
66034
- * useItemTracker() — hook that creates a stable item tracker for the lifetime
66035
- * of the host component.
66036
- *
66037
- * USAGE:
66038
- * ```jsx
66039
- * function ListControlled({ items }) {
66040
- * const tracker = useItemTracker({
66041
- * onChange: () => console.log("items changed"),
66042
- * });
66043
- *
66044
- * return (
66045
- * <ul>
66046
- * {items.map((item, i) => (
66047
- * <Row key={item.id} id={item.id} index={i} hidden={item.hidden} value={item.value} tracker={tracker} />
66048
- * ))}
66049
- * <Count tracker={tracker} />
66050
- * </ul>
66051
- * );
66052
- * }
66053
- *
66054
- * function Row({ id, index, hidden, value, tracker }) {
66055
- * const visibleIndex = tracker.useTrackItem({ id, index, hidden, value });
66056
- * if (visibleIndex === -1) return null;
66057
- * return <li>{value}</li>;
66058
- * }
66059
- *
66060
- * function Count({ tracker }) {
66061
- * const count = tracker.visibleCountSignal.value; // re-renders only when count changes
66062
- * return <span>{count} items</span>;
66063
- * }
66064
- * ```
66065
- *
66066
- * INTERNALS:
66067
- * - registrations: Map key → data, contains only visible items
66068
- * - idToKey: Map id → key, stable across renders
66069
- * - orderedKeys: number[] of visible item keys sorted by explicit order
66070
- * - keyToOrderedIndex: Map key → orderedKeys index, gives O(1) indexOf equivalent
66071
- * - keyToExplicitOrder: Map key → explicitly passed index, used to maintain sort order
66072
- * - allItemsSignal: signal(array), all items including hidden, ordered by explicit index
66073
- * - visibleItemsSignal: signal(array), non-hidden items only
66074
- * - countSignal: signal(number), count of all items including hidden
66075
- * - visibleCountSignal: signal(number), updated in microtask batch, only when count changes
66076
- * - propSignals: Map propName → signal(array), updated in microtask batch with element equality
66077
- * - onChangeRef: holds the latest onChange callback, called once per microtask batch
66078
- *
66079
- * useTrackItem(id, data, index): registers the item with an explicitly provided index
66080
- * that determines its position among siblings. The caller (e.g. items.map) knows the
66081
- * correct order and passes it directly — no render-sequence deduction needed.
66082
- * Returns the visible rank (position among non-hidden items), or -1 when hidden.
66083
- * Signals and onChange are deferred to a microtask so multiple items updating
66084
- * in one commit cause only one notification.
66085
- *
66086
- * getTrackedItemByIndex(index): synchronous O(1) lookup of a visible item by
66087
- * its visible rank. Returns undefined when index is out of range.
66088
- *
66089
- * peekItems(): the items as they stand right now, without waiting for the
66090
- * deferred notification — what a sibling rendering after the items must read
66091
- * to paint them in the same commit.
66092
- */
66093
-
66094
- const useItemTracker = ({ onChange } = {}) => {
66095
- const onChangeRef = useRef(onChange);
66096
- onChangeRef.current = onChange;
66097
- const trackerRef = useRef(null);
66098
- let tracker = trackerRef.current;
66099
- if (!tracker) {
66100
- trackerRef.current = tracker = createItemTracker((items) => {
66101
- onChangeRef.current?.(items);
66102
- });
66033
+ const ListItemHeaderOrFooterResolver = props => {
66034
+ const Next = useNextResolver();
66035
+ if (props.header) {
66036
+ return renderResolver(ListItemHeader, props);
66103
66037
  }
66104
- // When code in useLayoutEffect of the caller wants to run the tracker must be in sync
66105
- // without this layout effect the tracker might not have been synced yet and preact would call layout effect
66106
- // before we had time to sync
66107
- useLayoutEffect(() => {
66108
- tracker._flushSync();
66038
+ if (props.footer) {
66039
+ return renderResolver(ListItemFooter, props);
66040
+ }
66041
+ return jsx(Next, {
66042
+ ...props
66043
+ });
66044
+ };
66045
+ const ListItemHeader = props => {
66046
+ const Next = useNextResolver();
66047
+ const {
66048
+ ref
66049
+ } = props;
66050
+ useDisplayedLayoutEffect(ref, headerEl => {
66051
+ const listContainerEl = headerEl.closest(".navi_list_container");
66052
+ const rect = headerEl.getBoundingClientRect();
66053
+ listContainerEl.style.setProperty("--list-header-height", `${rect.height}px`);
66054
+ listContainerEl.style.setProperty("--list-header-width", `${rect.width}px`);
66055
+ }, []);
66056
+ return jsx(Next, {
66057
+ ...props,
66058
+ header: undefined,
66059
+ role: "presentation",
66060
+ baseClassName: "navi_list_item_header"
66061
+ });
66062
+ };
66063
+ const ListItemFooter = props => {
66064
+ const Next = useNextResolver();
66065
+ const {
66066
+ ref
66067
+ } = props;
66068
+ useDisplayedLayoutEffect(ref, footerEl => {
66069
+ const listContainerEl = footerEl.closest(".navi_list_container");
66070
+ const rect = footerEl.getBoundingClientRect();
66071
+ listContainerEl.style.setProperty("--list-footer-height", `${rect.height}px`);
66072
+ listContainerEl.style.setProperty("--list-footer-width", `${rect.width}px`);
66073
+ }, []);
66074
+ return jsx(Next, {
66075
+ ...props,
66076
+ footer: undefined,
66077
+ role: "presentation",
66078
+ baseClassName: "navi_list_item_footer"
66109
66079
  });
66110
- return tracker;
66111
66080
  };
66112
66081
 
66113
- const createItemTracker = (onChange) => {
66114
- const registrations = new Map(); // key data (visible items only)
66115
- const idToKey = new Map(); // id insertion key (stable, auto-incremented)
66116
- let keyCounter = 0;
66117
- // orderedKeys: visible item keys sorted by their explicitly provided index.
66118
- const orderedKeys = []; // number[]
66119
- // keyToOrderedIndex: O(1) equivalent of orderedKeys.indexOf(key).
66120
- const keyToOrderedIndex = new Map(); // key index in orderedKeys
66121
- const allKeys = new Set(); // all registered keys including hidden
66122
- const keyToExplicitOrder = new Map(); // key explicitly passed index
66123
-
66124
- const allRegistrations = new Map(); // key data (all items including hidden)
66125
- const allOrderedKeys = []; // all item keys sorted by explicit order
66126
- const keyToAllOrderedIndex = new Map(); // key index in allOrderedKeys
66127
-
66128
- const itemsSignal = signal([]);
66129
- const visibleItemsSignal = signal([]);
66130
- const countSignal = signal(0);
66131
- const visibleCountSignal = signal(0);
66132
- const noMatchCountSignal = signal(0);
66133
-
66134
- let notifyScheduled = false;
66135
- const runNotify = () => {
66136
- batch(() => {
66137
- let someChange = false;
66082
+ // Everything the list knows about its rows, in one place: how many the
66083
+ // collection has and where each child's rows start (the places), which rows
66084
+ // are drawn and which of those mount and show (the rows), and what each says
66085
+ // about itself (the items). Two clocks. Places and "is anything standing
66086
+ // above me" answer synchronously, in the middle of a render pass — a row asks
66087
+ // about the rows before it, and those have rendered already. The items and
66088
+ // the counts settle once per frame (a microtask): many rows change in one
66089
+ // commit, and what reads them wants one notification.
66090
+ //
66091
+ // Why places are read off the walk and not off the renders: a child knows how
66092
+ // many rows it stands for but not what was declared before it, and it cannot
66093
+ // deduce that from when it renders a render is free to skip it. A child that
66094
+ // draws from signals and whose props are all referentially === the previous
66095
+ // ones does not render again (@preact/signals installs a shouldComponentUpdate
66096
+ // that says so), which is what any child nobody rebuilt this frame is — and
66097
+ // children numbered as they render would then slide up into the place of the
66098
+ // one that was skipped. So the list names a slot for each of its children and
66099
+ // declares them here, in order, before any of them renders (see
66100
+ // ListDeclaredChildren). A child then takes its place BY SLOT, and the place
66101
+ // is a signal: it moves when what stands before it changes — a row filtered
66102
+ // out, a run taking in rows, a slot added or moved — and the child follows,
66103
+ // rendered again for it whether or not anything else would have rendered it.
66104
+ //
66105
+ // Why "first" is a signal too: only the row itself knows, once it renders,
66106
+ // that it renders nothing (filtered out by a search), and the rows after it
66107
+ // may have been handed back unchanged. The first mounted row of a scope is
66108
+ // kept as a signal each of them reads; when it leaves, they are rendered again.
66138
66109
 
66139
- const newCount = allKeys.size;
66140
- const countModified = countSignal.peek() !== newCount;
66141
- if (countModified) {
66142
- countSignal.value = newCount;
66143
- someChange = true;
66144
- }
66110
+ const UNGROUPED = Symbol("ungrouped");
66145
66111
 
66146
- // Build allItems and visibleItems in a single pass over allOrderedKeys.
66147
- // Visible items are those without data.hidden or data.filtered — same
66148
- // relative order as orderedKeys (syncItem already excludes both from
66149
- // orderedKeys; this must match or consumers relying on visibleCountSignal
66150
- // for virtual-scroll accounting, e.g. list.jsx's filler sizing, would
66151
- // count filtered-out items as if they still took up space).
66152
- const prevAllItems = itemsSignal.peek();
66153
- const prevVisibleItems = visibleItemsSignal.peek();
66154
- let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
66155
- let visibleItemsChanged = false;
66156
- const allItems = [];
66157
- const visibleItems = [];
66158
- let newNoMatchCount = 0;
66159
- for (let i = 0; i < allOrderedKeys.length; i++) {
66160
- const key = allOrderedKeys[i];
66161
- const item = allRegistrations.get(key);
66162
- allItems.push(item);
66163
- // Compare by reference: catches any prop change (id, selected, disabled, …)
66164
- if (!allItemsChanged && item !== prevAllItems[i]) {
66165
- allItemsChanged = true;
66166
- }
66167
- if (item.match === false) {
66168
- newNoMatchCount++;
66169
- }
66170
- if (!item.hidden && !item.filtered) {
66171
- const visibleIdx = visibleItems.length;
66172
- visibleItems.push(item);
66173
- if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
66174
- visibleItemsChanged = true;
66175
- }
66176
- }
66177
- }
66112
+ const createListRows = () => {
66113
+ const totalSignal = signal(0);
66114
+ // Bumped whenever a run takes in rows. The list itself has to hear about it:
66115
+ // rows arriving outside the render window change nothing it can see (nothing
66116
+ // registers, nothing is drawn), and yet they are what it may have been
66117
+ // waiting for the row it was told to open on, for one.
66118
+ const pagesSignal = signal(0);
66119
+ // How many runs are re-reading rows they already show. The list wears it as
66120
+ // an attribute: what is drawn is from before, and the app may want to say so
66121
+ // without taking anything away.
66122
+ const refreshingSignal = signal(0);
66123
+ // The slots each walk declared, in order, by the slot the walk stands in
66124
+ // (null for the list's own children). Together they are a tree: a group's
66125
+ // rows live inside the group's slot.
66126
+ const slotIdsByParent = new Map();
66127
+ // Who took a place — a row, or a run of rows — and how many rows of the
66128
+ // collection it stands for. The place itself is a signal, see take.
66129
+ const ownerById = new Map();
66130
+ // The owners standing in each slot, in the order they took their place.
66131
+ // One, as a rule; a child that renders several rows keeps them in the order
66132
+ // they first rendered, which is all it can be told.
66133
+ const ownerIdsBySlot = new Map();
66134
+ const locatorByOwner = new Map();
66135
+ // The slots as the tree reads, first to last, and where each stands in it.
66136
+ // Rebuilt once a walk has changed the tree, read to place the owners.
66137
+ const slotWalk = [];
66138
+ const rankBySlot = new Map();
66139
+ let rowTotal = 0;
66140
+ // Owners have left and the others have not been moved up yet. Done on the
66141
+ // next ask rather than on the spot: rows leave many at a time (a search, a
66142
+ // list unmounting), and moving the others up once is enough.
66143
+ let placesStale = false;
66144
+ // Where the last slot holding an owner stands: an owner arriving at or after
66145
+ // it is placed at the end without going over the others — a whole first
66146
+ // render, rows arriving in order, costs each row nothing but itself.
66147
+ let rankOwnedLast = -1;
66178
66148
 
66179
- const newVisibleCount = visibleItems.length;
66180
- const visibleCountModified =
66181
- visibleCountSignal.peek() !== newVisibleCount;
66182
- if (visibleCountModified) {
66183
- visibleCountSignal.value = newVisibleCount;
66184
- someChange = true;
66185
- }
66186
- if (allItemsChanged) {
66187
- itemsSignal.value = allItems;
66188
- someChange = true;
66189
- }
66190
- if (visibleItemsChanged) {
66191
- visibleItemsSignal.value = visibleItems;
66192
- someChange = true;
66193
- }
66194
- const noMatchCountModified =
66195
- noMatchCountSignal.peek() !== newNoMatchCount;
66196
- if (noMatchCountModified) {
66197
- noMatchCountSignal.value = newNoMatchCount;
66198
- someChange = true;
66149
+ const rebuildWalk = () => {
66150
+ slotWalk.length = 0;
66151
+ rankBySlot.clear();
66152
+ const visit = (parentSlotId) => {
66153
+ const slotIds = slotIdsByParent.get(parentSlotId);
66154
+ if (!slotIds) {
66155
+ return;
66199
66156
  }
66200
- if (someChange) {
66201
- onChange?.();
66157
+ for (const slotId of slotIds) {
66158
+ rankBySlot.set(slotId, slotWalk.length);
66159
+ slotWalk.push(slotId);
66160
+ visit(slotId);
66202
66161
  }
66203
- });
66162
+ };
66163
+ visit(null);
66204
66164
  };
66205
-
66206
- const notify = () => {
66207
- if (notifyScheduled) {
66208
- return;
66209
- }
66210
- notifyScheduled = true;
66211
- queueMicrotask(() => {
66212
- if (!notifyScheduled) {
66213
- return; // was already flushed synchronously
66165
+ // Every place, in one go: a place is the sum of what stands before it, so
66166
+ // there is nothing to hand out one at a time. Writing a place that did not
66167
+ // change wakes nobody — a signal ignores a value equal to its own.
66168
+ const refreshPlaces = () => {
66169
+ placesStale = false;
66170
+ let index = 0;
66171
+ let rank = 0;
66172
+ rankOwnedLast = -1;
66173
+ while (rank < slotWalk.length) {
66174
+ const ownerIds = ownerIdsBySlot.get(slotWalk[rank]);
66175
+ if (ownerIds) {
66176
+ for (const ownerId of ownerIds) {
66177
+ const owner = ownerById.get(ownerId);
66178
+ owner.placeSignal.value = index;
66179
+ index += owner.rowCount;
66180
+ }
66181
+ rankOwnedLast = rank;
66214
66182
  }
66215
- notifyScheduled = false;
66216
- runNotify();
66217
- });
66183
+ rank++;
66184
+ }
66185
+ rowTotal = index;
66186
+ totalSignal.value = index;
66187
+ // A run's edges are places too (see refreshFirst).
66188
+ markStale(UNGROUPED);
66218
66189
  };
66219
-
66220
- const _flushSync = () => {
66221
- if (!notifyScheduled) {
66190
+ const addToSlot = (slotId, ownerId) => {
66191
+ const ownerIds = ownerIdsBySlot.get(slotId);
66192
+ if (ownerIds) {
66193
+ ownerIds.push(ownerId);
66194
+ } else {
66195
+ ownerIdsBySlot.set(slotId, [ownerId]);
66196
+ }
66197
+ warnIfEveryRowInOneSlot(slotId);
66198
+ };
66199
+ // Rows that all stand in the same slot keep the order they first mounted in:
66200
+ // the walk is over the children the list is given, and a component holding
66201
+ // them is one child however many rows it renders. Everything about a place
66202
+ // then stops following what the caller writes — a search reordering the rows
66203
+ // moves nothing. Said once, and only for the shape that can be nothing else:
66204
+ // the list's whole content is one child, and several rows came out of it.
66205
+ let everyRowInOneSlotWarned = false;
66206
+ const warnIfEveryRowInOneSlot = (slotId) => {
66207
+ if (everyRowInOneSlotWarned) {
66222
66208
  return;
66223
66209
  }
66224
- notifyScheduled = false;
66225
- runNotify();
66210
+ const rootSlotIds = slotIdsByParent.get(null);
66211
+ if (!rootSlotIds || rootSlotIds.length !== 1 || rootSlotIds[0] !== slotId) {
66212
+ return;
66213
+ }
66214
+ if (ownerIdsBySlot.get(slotId).length < 2) {
66215
+ return;
66216
+ }
66217
+ everyRowInOneSlotWarned = true;
66218
+ console.warn(
66219
+ `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.`,
66220
+ );
66226
66221
  };
66227
-
66228
- // Insert key into orderedKeys at the correct position based on explicitOrder.
66229
- // Uses binary search for O(log n) insertion.
66230
- const insertKey = (key, explicitOrder) => {
66231
- let lo = 0;
66232
- let hi = orderedKeys.length;
66233
- while (lo < hi) {
66234
- const mid = (lo + hi) >> 1;
66235
- if (keyToExplicitOrder.get(orderedKeys[mid]) <= explicitOrder) {
66236
- lo = mid + 1;
66237
- } else {
66238
- hi = mid;
66239
- }
66222
+ const removeFromSlot = (slotId, ownerId) => {
66223
+ const ownerIds = ownerIdsBySlot.get(slotId);
66224
+ if (!ownerIds) {
66225
+ return;
66240
66226
  }
66241
- orderedKeys.splice(lo, 0, key);
66242
- for (let i = lo; i < orderedKeys.length; i++) {
66243
- keyToOrderedIndex.set(orderedKeys[i], i);
66227
+ const index = ownerIds.indexOf(ownerId);
66228
+ if (index !== -1) {
66229
+ ownerIds.splice(index, 1);
66230
+ }
66231
+ if (ownerIds.length === 0) {
66232
+ ownerIdsBySlot.delete(slotId);
66244
66233
  }
66245
66234
  };
66246
-
66247
- const insertAllKey = (key, explicitOrder) => {
66248
- let lo = 0;
66249
- let hi = allOrderedKeys.length;
66250
- while (lo < hi) {
66251
- const mid = (lo + hi) >> 1;
66252
- if (keyToExplicitOrder.get(allOrderedKeys[mid]) <= explicitOrder) {
66253
- lo = mid + 1;
66254
- } else {
66255
- hi = mid;
66235
+ // A slot the walk no longer names: whatever stood in it is gone, and so is
66236
+ // whatever a walk inside it had declared.
66237
+ const dropSlot = (slotId) => {
66238
+ const ownerIds = ownerIdsBySlot.get(slotId);
66239
+ if (ownerIds) {
66240
+ for (const ownerId of ownerIds) {
66241
+ ownerById.delete(ownerId);
66256
66242
  }
66243
+ ownerIdsBySlot.delete(slotId);
66257
66244
  }
66258
- allOrderedKeys.splice(lo, 0, key);
66259
- for (let i = lo; i < allOrderedKeys.length; i++) {
66260
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
66245
+ const childSlotIds = slotIdsByParent.get(slotId);
66246
+ if (childSlotIds) {
66247
+ slotIdsByParent.delete(slotId);
66248
+ for (const childSlotId of childSlotIds) {
66249
+ dropSlot(childSlotId);
66250
+ }
66261
66251
  }
66262
66252
  };
66263
66253
 
66264
- const removeAllKey = (key) => {
66265
- const idx = keyToAllOrderedIndex.get(key);
66266
- if (idx !== undefined) {
66267
- allOrderedKeys.splice(idx, 1);
66268
- keyToAllOrderedIndex.delete(key);
66269
- for (let i = idx; i < allOrderedKeys.length; i++) {
66270
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
66271
- }
66254
+ // ---- the rows drawn ----
66255
+ // rowId { ownerId, place, groupId, data, mounted, visible, item }
66256
+ const rowById = new Map();
66257
+ // groupId → { firstSignal, countSignal, noMatchCountSignal }
66258
+ const groupById = new Map();
66259
+ // ownerId { from, to }, for the runs (see declareWindow).
66260
+ const windowByOwner = new Map();
66261
+ // The first place something stands at, outside any group: the mounted rows
66262
+ // and the rows a run holds above its window. Per group, the group's first
66263
+ // mounted row.
66264
+ const firstStandingSignal = signal(-1);
66265
+ // The scopes whose first row left: recounted on the next ask, or at the end
66266
+ // of the frame, whichever comes first. A row mounting before the first one
66267
+ // moves it at once, no recount needed — the first can only ever move up.
66268
+ const staleScopes = new Set();
66269
+ const scopeOf = (groupId) => (groupId === undefined ? UNGROUPED : groupId);
66270
+ const groupOf = (groupId) => {
66271
+ let group = groupById.get(groupId);
66272
+ if (!group) {
66273
+ group = {
66274
+ firstSignal: signal(-1),
66275
+ countSignal: signal(0),
66276
+ noMatchCountSignal: signal(0),
66277
+ };
66278
+ groupById.set(groupId, group);
66272
66279
  }
66280
+ return group;
66273
66281
  };
66274
-
66275
- // Register or update an item. data.hidden controls visibility.
66276
- // explicitOrder is the caller-provided index that determines sort position.
66277
- const syncItem = (key, index, data) => {
66278
- if (data.role === "presentation") {
66279
- registrations.delete(key);
66280
- const idx = keyToOrderedIndex.get(key);
66281
- if (idx !== undefined) {
66282
- orderedKeys.splice(idx, 1);
66283
- keyToOrderedIndex.delete(key);
66284
- for (let i = idx; i < orderedKeys.length; i++) {
66285
- keyToOrderedIndex.set(orderedKeys[i], i);
66286
- }
66282
+ const firstSignalOf = (scope) =>
66283
+ scope === UNGROUPED ? firstStandingSignal : groupOf(scope).firstSignal;
66284
+ const refreshFirst = (scope) => {
66285
+ staleScopes.delete(scope);
66286
+ let first = -1;
66287
+ const consider = (place) => {
66288
+ if (place !== undefined && (first === -1 || place < first)) {
66289
+ first = place;
66287
66290
  }
66288
- keyToExplicitOrder.delete(key);
66289
- allRegistrations.delete(key);
66290
- removeAllKey(key);
66291
- allKeys.delete(key);
66292
- return;
66293
- }
66294
-
66295
- // Maintain allRegistrations and allOrderedKeys for all non-presentation items.
66296
- allRegistrations.set(key, data);
66297
- allKeys.add(key);
66298
- const currentAllIdx = keyToAllOrderedIndex.get(key);
66299
- const previousOrder = keyToExplicitOrder.get(key);
66300
- keyToExplicitOrder.set(key, index);
66301
- if (currentAllIdx === undefined) {
66302
- insertAllKey(key, index);
66303
- } else if (previousOrder !== index) {
66304
- allOrderedKeys.splice(currentAllIdx, 1);
66305
- keyToAllOrderedIndex.delete(key);
66306
- for (let i = currentAllIdx; i < allOrderedKeys.length; i++) {
66307
- keyToAllOrderedIndex.set(allOrderedKeys[i], i);
66291
+ };
66292
+ for (const row of rowById.values()) {
66293
+ if (row.mounted && scopeOf(row.groupId) === scope) {
66294
+ consider(row.place);
66308
66295
  }
66309
- insertAllKey(key, index);
66310
66296
  }
66311
-
66312
- if (data.filtered || data.hidden) {
66313
- registrations.delete(key);
66314
- const idx = keyToOrderedIndex.get(key);
66315
- if (idx !== undefined) {
66316
- orderedKeys.splice(idx, 1);
66317
- keyToOrderedIndex.delete(key);
66318
- for (let i = idx; i < orderedKeys.length; i++) {
66319
- keyToOrderedIndex.set(orderedKeys[i], i);
66297
+ if (scope === UNGROUPED) {
66298
+ for (const [ownerId, window] of windowByOwner) {
66299
+ const owner = ownerById.get(ownerId);
66300
+ if (!owner) {
66301
+ continue;
66302
+ }
66303
+ const start = owner.placeSignal.peek();
66304
+ if (window.from > start) {
66305
+ consider(start);
66306
+ }
66307
+ if (window.to < start + owner.rowCount) {
66308
+ consider(window.to);
66320
66309
  }
66321
66310
  }
66322
- return;
66323
- }
66324
-
66325
- registrations.set(key, data);
66326
- const currentIdx = keyToOrderedIndex.get(key);
66327
- if (currentIdx === undefined) {
66328
- insertKey(key, index);
66329
- return;
66330
66311
  }
66331
- if (previousOrder === index) {
66312
+ firstSignalOf(scope).value = first;
66313
+ };
66314
+ const markStale = (scope) => {
66315
+ if (staleScopes.has(scope)) {
66332
66316
  return;
66333
66317
  }
66334
- orderedKeys.splice(currentIdx, 1);
66335
- keyToOrderedIndex.delete(key);
66336
- for (let i = currentIdx; i < orderedKeys.length; i++) {
66337
- keyToOrderedIndex.set(orderedKeys[i], i);
66318
+ staleScopes.add(scope);
66319
+ queueMicrotask(() => {
66320
+ if (staleScopes.has(scope)) {
66321
+ refreshFirst(scope);
66322
+ }
66323
+ });
66324
+ };
66325
+ const leaveFirst = (row) => {
66326
+ const scope = scopeOf(row.groupId);
66327
+ if (firstSignalOf(scope).peek() === row.place) {
66328
+ markStale(scope);
66338
66329
  }
66339
- insertKey(key, index);
66340
66330
  };
66341
66331
 
66342
- const unregisterKey = (key) => {
66343
- registrations.delete(key);
66344
- const idx = keyToOrderedIndex.get(key);
66345
- if (idx !== undefined) {
66346
- orderedKeys.splice(idx, 1);
66347
- keyToOrderedIndex.delete(key);
66348
- for (let i = idx; i < orderedKeys.length; i++) {
66349
- keyToOrderedIndex.set(orderedKeys[i], i);
66332
+ // ---- the items, settled once per frame ----
66333
+ const itemsSignal = signal([]);
66334
+ const visibleItemsSignal = signal([]);
66335
+ const countSignal = signal(0);
66336
+ const visibleCountSignal = signal(0);
66337
+ const noMatchCountSignal = signal(0);
66338
+ let notifyScheduled = false;
66339
+ const runNotify = () => {
66340
+ batch(() => {
66341
+ const itemRows = [];
66342
+ for (const row of rowById.values()) {
66343
+ if (row.item) {
66344
+ itemRows.push(row);
66345
+ }
66346
+ }
66347
+ itemRows.sort(compareRowPlaces);
66348
+ const items = [];
66349
+ const visibleItems = [];
66350
+ let noMatchCount = 0;
66351
+ const countByGroup = new Map();
66352
+ const noMatchCountByGroup = new Map();
66353
+ const prevItems = itemsSignal.peek();
66354
+ const prevVisibleItems = visibleItemsSignal.peek();
66355
+ let itemsChanged = prevItems.length !== itemRows.length;
66356
+ let visibleItemsChanged = false;
66357
+ for (const row of itemRows) {
66358
+ const item = row.data;
66359
+ // Compared by reference: any prop change (selected, disabled, …) is a
66360
+ // new props object.
66361
+ if (!itemsChanged && item !== prevItems[items.length]) {
66362
+ itemsChanged = true;
66363
+ }
66364
+ items.push(item);
66365
+ const noMatch = item.match === false;
66366
+ if (noMatch) {
66367
+ noMatchCount++;
66368
+ }
66369
+ if (row.groupId !== undefined) {
66370
+ countByGroup.set(
66371
+ row.groupId,
66372
+ (countByGroup.get(row.groupId) || 0) + 1,
66373
+ );
66374
+ if (noMatch) {
66375
+ noMatchCountByGroup.set(
66376
+ row.groupId,
66377
+ (noMatchCountByGroup.get(row.groupId) || 0) + 1,
66378
+ );
66379
+ }
66380
+ }
66381
+ if (row.visible) {
66382
+ if (
66383
+ !visibleItemsChanged &&
66384
+ item !== prevVisibleItems[visibleItems.length]
66385
+ ) {
66386
+ visibleItemsChanged = true;
66387
+ }
66388
+ visibleItems.push(item);
66389
+ }
66390
+ }
66391
+ if (visibleItems.length !== prevVisibleItems.length) {
66392
+ visibleItemsChanged = true;
66350
66393
  }
66394
+ let someChange = false;
66395
+ if (countSignal.peek() !== items.length) {
66396
+ countSignal.value = items.length;
66397
+ someChange = true;
66398
+ }
66399
+ if (visibleCountSignal.peek() !== visibleItems.length) {
66400
+ visibleCountSignal.value = visibleItems.length;
66401
+ someChange = true;
66402
+ }
66403
+ if (itemsChanged) {
66404
+ itemsSignal.value = items;
66405
+ someChange = true;
66406
+ }
66407
+ if (visibleItemsChanged) {
66408
+ visibleItemsSignal.value = visibleItems;
66409
+ someChange = true;
66410
+ }
66411
+ if (noMatchCountSignal.peek() !== noMatchCount) {
66412
+ noMatchCountSignal.value = noMatchCount;
66413
+ someChange = true;
66414
+ }
66415
+ for (const [groupId, group] of groupById) {
66416
+ group.countSignal.value = countByGroup.get(groupId) || 0;
66417
+ group.noMatchCountSignal.value = noMatchCountByGroup.get(groupId) || 0;
66418
+ }
66419
+ if (someChange && listRows.onChange) {
66420
+ listRows.onChange();
66421
+ }
66422
+ });
66423
+ };
66424
+ const notify = () => {
66425
+ if (notifyScheduled) {
66426
+ return;
66351
66427
  }
66352
- keyToExplicitOrder.delete(key);
66353
- allRegistrations.delete(key);
66354
- removeAllKey(key);
66355
- allKeys.delete(key);
66428
+ notifyScheduled = true;
66429
+ queueMicrotask(() => {
66430
+ if (!notifyScheduled) {
66431
+ return;
66432
+ }
66433
+ notifyScheduled = false;
66434
+ runNotify();
66435
+ });
66356
66436
  };
66357
66437
 
66358
- const keyForId = (id) => {
66359
- if (!idToKey.has(id)) {
66360
- idToKey.set(id, keyCounter++);
66361
- }
66362
- return idToKey.get(id);
66363
- };
66364
-
66365
- // Register an item. data.hidden controls visibility.
66366
- // explicitOrder is the caller-provided index (e.g. from items.map((item, i) => ...))
66367
- // that determines this item's position among siblings.
66368
- // Returns the item's visible rank among non-hidden items, or -1 when hidden.
66369
- const useTrackItem = (data) => {
66370
- const { id, index } = data;
66371
- const key = keyForId(id);
66372
-
66373
- syncItem(key, index, data);
66374
- notify();
66375
-
66376
- useLayoutEffect(() => {
66377
- return () => {
66378
- unregisterKey(key);
66379
- notify();
66380
- };
66381
- }, []);
66382
-
66383
- if (data.filtered || data.hidden || data.role === "presentation") {
66384
- return -1;
66385
- }
66386
- return keyToOrderedIndex.get(key) ?? -1;
66387
- };
66388
-
66389
- const getTrackedItemByIndex = (index) => {
66390
- const key = orderedKeys[index];
66391
- if (key === undefined) {
66392
- return undefined;
66393
- }
66394
- return registrations.get(key);
66395
- };
66396
-
66397
- // The items as they stand right now, notification pending or not — same
66398
- // content as itemsSignal, minus the wait.
66399
- //
66400
- // Items register during their own render, while the signal is only updated
66401
- // on a deferred microtask (see notify): a sibling rendering after them would
66402
- // otherwise paint from an empty list and correct itself a frame later. That
66403
- // frame is visible whenever the painted size feeds a layout decision — a
66404
- // dialog sizing itself on its content measures the empty version and shifts
66405
- // once the real one lands. Reading this instead makes the first paint the
66406
- // right one. Callers must still subscribe to itemsSignal to re-render on
66407
- // LATER changes; this is the value to display, not the notification.
66408
- const peekItems = () => {
66409
- if (!notifyScheduled) {
66410
- return itemsSignal.peek();
66411
- }
66412
- const items = [];
66413
- for (const key of allOrderedKeys) {
66414
- items.push(allRegistrations.get(key));
66415
- }
66416
- return items;
66417
- };
66418
-
66419
- return {
66420
- useTrackItem,
66421
- getTrackedItemByIndex,
66422
- peekItems,
66438
+ const listRows = {
66439
+ totalSignal,
66440
+ pagesSignal,
66441
+ refreshingSignal,
66442
+ // The rows that are items, in place order — every one drawn, and the ones
66443
+ // that show — and what the search made of them. Written once per frame.
66423
66444
  itemsSignal,
66424
66445
  visibleItemsSignal,
66425
66446
  countSignal,
66426
66447
  visibleCountSignal,
66427
66448
  noMatchCountSignal,
66428
- _flushSync,
66449
+ // Called once per frame in which the items changed. Set by the list.
66450
+ onChange: null,
66451
+ // What a run needs to know about the list it lives in: how many rows the
66452
+ // list is willing to draw at once, which end it opens on, and how much
66453
+ // room one row is given — a row whose content has not arrived must take
66454
+ // exactly that, or the rows drawn would not reach where the list says they
66455
+ // are.
66456
+ renderBudget: 0,
66457
+ scrolled: "start",
66458
+ // The list is on its way somewhere: what the window frames is not what it
66459
+ // is about to frame, so a run must not fetch for it (see holdWindow).
66460
+ holdPending: false,
66461
+ // Called by a run just before rows land in it: what is on screen must not
66462
+ // move because something arrived above it. Set by the list itself.
66463
+ captureAnchor: () => {},
66464
+ horizontal: false,
66465
+ virtualItemSizeSignal: null,
66466
+ renderSkeleton: undefined,
66467
+ // The children a walk stands over, in order — said in one call, before any
66468
+ // of them renders, so that what a child asks next is answered against the
66469
+ // whole picture and not against the children that happened to render
66470
+ // first. Said again on every render of the walk, and heard only when
66471
+ // something moved.
66472
+ declareSlots: (parentSlotId, slotIds) => {
66473
+ const slotIdsPrevious = slotIdsByParent.get(parentSlotId);
66474
+ if (slotIdsPrevious && sameSlotIds(slotIdsPrevious, slotIds)) {
66475
+ return;
66476
+ }
66477
+ if (slotIdsPrevious) {
66478
+ const slotIdSet = new Set(slotIds);
66479
+ for (const slotId of slotIdsPrevious) {
66480
+ if (!slotIdSet.has(slotId)) {
66481
+ dropSlot(slotId);
66482
+ }
66483
+ }
66484
+ }
66485
+ slotIdsByParent.set(parentSlotId, slotIds);
66486
+ rebuildWalk();
66487
+ refreshPlaces();
66488
+ },
66489
+ // Whether something has taken this slot for its own: what it renders
66490
+ // inside is then its to place (a run draws its groups with their rows
66491
+ // already placed), and no walk inside it has anything to declare.
66492
+ slotHasOwner: (slotId) => ownerIdsBySlot.has(slotId),
66493
+ // Whether any run of rows lives in this list: what makes a render window
66494
+ // mean anything (see List's renderBudget).
66495
+ hasRuns: () => locatorByOwner.size > 0,
66496
+ setRowLocator: (ownerId, locate) => {
66497
+ locatorByOwner.set(ownerId, locate);
66498
+ },
66499
+ dropRowLocator: (ownerId) => {
66500
+ locatorByOwner.delete(ownerId);
66501
+ },
66502
+ // Where the row named by that id sits, asked of whoever holds it.
66503
+ locateRow: (id) => {
66504
+ for (const locate of locatorByOwner.values()) {
66505
+ const index = locate(id);
66506
+ if (index !== null) {
66507
+ return index;
66508
+ }
66509
+ }
66510
+ return null;
66511
+ },
66512
+ // The place the owner's rows start at — read from a signal, so that the
66513
+ // owner is rendered again when it moves (see the top of this file). Asked on
66514
+ // every render, and answered without a second look for as long as the
66515
+ // owner stands in the same slot for the same number of rows.
66516
+ take: (ownerId, rowCount, slotId) => {
66517
+ let owner = ownerById.get(ownerId);
66518
+ if (owner) {
66519
+ if (owner.slotId !== slotId || owner.rowCount !== rowCount) {
66520
+ removeFromSlot(owner.slotId, ownerId);
66521
+ addToSlot(slotId, ownerId);
66522
+ owner.slotId = slotId;
66523
+ owner.rowCount = rowCount;
66524
+ placesStale = true;
66525
+ }
66526
+ if (placesStale) {
66527
+ refreshPlaces();
66528
+ }
66529
+ return owner.placeSignal.value;
66530
+ }
66531
+ if (placesStale) {
66532
+ refreshPlaces();
66533
+ }
66534
+ const rank = rankBySlot.get(slotId);
66535
+ addToSlot(slotId, ownerId);
66536
+ if (rank !== undefined && rank >= rankOwnedLast) {
66537
+ owner = { slotId, rowCount, placeSignal: signal(rowTotal) };
66538
+ ownerById.set(ownerId, owner);
66539
+ rowTotal += rowCount;
66540
+ rankOwnedLast = rank;
66541
+ totalSignal.value = rowTotal;
66542
+ return owner.placeSignal.value;
66543
+ }
66544
+ owner = { slotId, rowCount, placeSignal: signal(0) };
66545
+ ownerById.set(ownerId, owner);
66546
+ refreshPlaces();
66547
+ return owner.placeSignal.value;
66548
+ },
66549
+ // The owner stands for no row of the collection: it was filtered out by a
66550
+ // search, or it is gone.
66551
+ drop: (ownerId) => {
66552
+ const owner = ownerById.get(ownerId);
66553
+ if (!owner) {
66554
+ return;
66555
+ }
66556
+ ownerById.delete(ownerId);
66557
+ removeFromSlot(owner.slotId, ownerId);
66558
+ windowByOwner.delete(ownerId);
66559
+ if (placesStale) {
66560
+ return;
66561
+ }
66562
+ placesStale = true;
66563
+ queueMicrotask(() => {
66564
+ if (placesStale) {
66565
+ refreshPlaces();
66566
+ }
66567
+ });
66568
+ },
66569
+
66570
+ // ---- the rows drawn ----
66571
+
66572
+ // A run says which of its rows it draws. The others stand: rows above the
66573
+ // window are above every row drawn, whether or not any of the drawn ones
66574
+ // mounts (see refreshFirst).
66575
+ declareWindow: (ownerId, from, to) => {
66576
+ const window = windowByOwner.get(ownerId);
66577
+ if (window && window.from === from && window.to === to) {
66578
+ return;
66579
+ }
66580
+ windowByOwner.set(ownerId, { from, to });
66581
+ markStale(UNGROUPED);
66582
+ },
66583
+ // A row says what it is, where it renders: its place, the group it is
66584
+ // in, and its data — from which follows whether it mounts at all
66585
+ // (filtered out), whether it shows (hidden keeps the room, not the
66586
+ // content), and whether it is an item (a group wrapper, a skeleton, are
66587
+ // rows of the list but items of nobody). Said in the name of the
66588
+ // component, not of the item id: two components may stand for one item for
66589
+ // a moment, one leaving as the other arrives.
66590
+ draw: (rowId, { ownerId, place, groupId, data }) => {
66591
+ const mounted = !data.filtered;
66592
+ const visible = mounted && !data.hidden;
66593
+ const item = !data.skeleton && data.role !== "presentation";
66594
+ let row = rowById.get(rowId);
66595
+ if (row) {
66596
+ if (
66597
+ row.mounted &&
66598
+ (row.place !== place || row.groupId !== groupId || !mounted)
66599
+ ) {
66600
+ leaveFirst(row);
66601
+ }
66602
+ row.ownerId = ownerId;
66603
+ row.place = place;
66604
+ row.groupId = groupId;
66605
+ row.data = data;
66606
+ row.mounted = mounted;
66607
+ row.visible = visible;
66608
+ row.item = item;
66609
+ } else {
66610
+ row = { ownerId, place, groupId, data, mounted, visible, item };
66611
+ rowById.set(rowId, row);
66612
+ }
66613
+ if (mounted) {
66614
+ const firstSignal = firstSignalOf(scopeOf(groupId));
66615
+ const first = firstSignal.peek();
66616
+ if (first === -1 || place < first) {
66617
+ firstSignal.value = place;
66618
+ }
66619
+ }
66620
+ notify();
66621
+ },
66622
+ // The row is gone. A declared row is its own owner and gives its place
66623
+ // back with it; a run's row leaves the run's places alone.
66624
+ erase: (rowId) => {
66625
+ const row = rowById.get(rowId);
66626
+ if (!row) {
66627
+ return;
66628
+ }
66629
+ rowById.delete(rowId);
66630
+ if (row.mounted) {
66631
+ leaveFirst(row);
66632
+ }
66633
+ if (row.ownerId === rowId) {
66634
+ listRows.drop(rowId);
66635
+ }
66636
+ notify();
66637
+ },
66638
+ // Whether nothing of the list stands above this row: in its group, no
66639
+ // other row of the group mounts before it; outside groups, no row mounts
66640
+ // before it and no run has rows above its window before it. Answered from
66641
+ // a signal, so a row rendered from a kept vnode is rendered again when the
66642
+ // row before it leaves or comes back.
66643
+ isFirst: (rowId) => {
66644
+ const row = rowById.get(rowId);
66645
+ const scope = scopeOf(row.groupId);
66646
+ if (staleScopes.has(scope)) {
66647
+ refreshFirst(scope);
66648
+ }
66649
+ return firstSignalOf(scope).value === row.place;
66650
+ },
66651
+ // What a group knows about its rows: how many, and how many of them the
66652
+ // search left out (see ListItemGroup).
66653
+ group: (groupId) => groupOf(groupId),
66654
+ dropGroup: (groupId) => {
66655
+ groupById.delete(groupId);
66656
+ staleScopes.delete(groupId);
66657
+ },
66658
+ // Written when a frame's rows have settled (see notify); the value to act
66659
+ // on is peeked from wherever the change is heard.
66660
+ flushSync: () => {
66661
+ if (!notifyScheduled) {
66662
+ return;
66663
+ }
66664
+ notifyScheduled = false;
66665
+ runNotify();
66666
+ },
66429
66667
  };
66668
+ return listRows;
66430
66669
  };
66431
-
66432
- const ListItemHeaderOrFooterResolver = props => {
66433
- const Next = useNextResolver();
66434
- if (props.header) {
66435
- return renderResolver(ListItemHeader, props);
66670
+ const sameSlotIds = (left, right) => {
66671
+ if (left.length !== right.length) {
66672
+ return false;
66436
66673
  }
66437
- if (props.footer) {
66438
- return renderResolver(ListItemFooter, props);
66674
+ let index = 0;
66675
+ while (index < left.length) {
66676
+ if (left[index] !== right[index]) {
66677
+ return false;
66678
+ }
66679
+ index++;
66439
66680
  }
66440
- return jsx(Next, {
66441
- ...props
66442
- });
66681
+ return true;
66443
66682
  };
66444
- const ListItemHeader = props => {
66445
- const Next = useNextResolver();
66446
- const {
66447
- ref
66448
- } = props;
66449
- useDisplayedLayoutEffect(ref, headerEl => {
66450
- const listContainerEl = headerEl.closest(".navi_list_container");
66451
- const rect = headerEl.getBoundingClientRect();
66452
- listContainerEl.style.setProperty("--list-header-height", `${rect.height}px`);
66453
- listContainerEl.style.setProperty("--list-header-width", `${rect.width}px`);
66454
- }, []);
66455
- return jsx(Next, {
66456
- ...props,
66457
- header: undefined,
66458
- role: "presentation",
66459
- baseClassName: "navi_list_item_header"
66460
- });
66461
- };
66462
- const ListItemFooter = props => {
66463
- const Next = useNextResolver();
66464
- const {
66465
- ref
66466
- } = props;
66467
- useDisplayedLayoutEffect(ref, footerEl => {
66468
- const listContainerEl = footerEl.closest(".navi_list_container");
66469
- const rect = footerEl.getBoundingClientRect();
66470
- listContainerEl.style.setProperty("--list-footer-height", `${rect.height}px`);
66471
- listContainerEl.style.setProperty("--list-footer-width", `${rect.width}px`);
66472
- }, []);
66473
- return jsx(Next, {
66474
- ...props,
66475
- footer: undefined,
66476
- role: "presentation",
66477
- baseClassName: "navi_list_item_footer"
66478
- });
66683
+
66684
+ // Rows in place order; a row with no place (a declared row filtered out) last.
66685
+ const compareRowPlaces = (left, right) => {
66686
+ if (left.place === undefined) {
66687
+ return right.place === undefined ? 0 : 1;
66688
+ }
66689
+ if (right.place === undefined) {
66690
+ return -1;
66691
+ }
66692
+ return left.place - right.place;
66479
66693
  };
66480
66694
 
66481
66695
  // What a row may say it is waiting on, all of them about the row as a thing
@@ -67283,8 +67497,10 @@ const applySearchHighlight = (el, highlight) => {
67283
67497
  };
67284
67498
 
67285
67499
  installImportMetaCssBuild(import.meta);
67286
- const ListItemTrackerContext = createContext(null);
67287
- const GroupItemTrackerContext = createContext(null);
67500
+ const ListRowsContext = createContext(null);
67501
+ // The group a row is declared in, by id (see ListItemGroup): what its count
67502
+ // and its separator are scoped to.
67503
+ const ListGroupContext = createContext(undefined);
67288
67504
  const PendingScrollRefContext = createContext(null);
67289
67505
  // Controls how List.Item behaves when match=false (set via List searchNoMatchMode prop):
67290
67506
  // "remove" — remove from DOM (default)
@@ -67323,20 +67539,15 @@ const SeparatorContext = createContext(null);
67323
67539
  // Set by <List itemTransition>: each row then gets a view-transition-name of
67324
67540
  // its own, so a change wrapped in a view transition animates row by row.
67325
67541
  const ItemTransitionContext = createContext(false);
67326
- // What the list knows about the collection as a whole: how many rows it has,
67327
- // which of them it actually holds, and where each child's rows start. Filled in
67328
- // by the children as they render (see createListVirtual), read by everything
67329
- // that must reserve room for what is not rendered.
67330
- const ListVirtualContext = createContext(null);
67331
67542
  // Set around each row a run of items renders (see ListItems): which row of the
67332
- // collection it is, and where it stands among the rows the list holds. Carried
67333
- // by context rather than injected into whatever vnode renderItem returned, so
67334
- // that returning a component of one's own — instead of a bare <List.Item> —
67335
- // works the same way.
67543
+ // collection it is, where it stands among the rows the list holds, and which
67544
+ // run it belongs to. Carried by context rather than injected into whatever
67545
+ // vnode renderItem returned, so that returning a component of one's own —
67546
+ // instead of a bare <List.Item> — works the same way.
67336
67547
  const ListRowContext = createContext(null);
67337
67548
  // The slot a child of the list stands in, by id (see ListDeclaredChildren). A
67338
67549
  // row takes its place in the collection by slot: the place is then the list's
67339
- // to move, and the row's to follow — see createListVirtual.
67550
+ // to move, and the row's to follow — see list_rows.js.
67340
67551
  const ListSlotContext = createContext(null);
67341
67552
  const css$x = /* css */`@layer navi {
67342
67553
  .navi_list_container {
@@ -67827,7 +68038,7 @@ const ListUI = props => {
67827
68038
  overflow,
67828
68039
  overflowX,
67829
68040
  overflowY,
67830
- virtual,
68041
+ listRows,
67831
68042
  ...rest
67832
68043
  } = props;
67833
68044
  const scrollBoxPaddingProps = {};
@@ -67896,16 +68107,19 @@ const ListUI = props => {
67896
68107
  observer.disconnect();
67897
68108
  };
67898
68109
  }, [lockSize]);
67899
- const tracker = useItemTracker({
67900
- onChange: () => {
67901
- onListVisibleItemsChange?.(tracker.visibleItemsSignal.peek());
67902
- }
68110
+ listRows.onChange = () => {
68111
+ onListVisibleItemsChange?.(listRows.visibleItemsSignal.peek());
68112
+ };
68113
+ // Code in a layout effect of the list reads the rows as they stand after
68114
+ // the commit; the rows settle on a microtask, which preact does not wait for.
68115
+ useLayoutEffect(() => {
68116
+ listRows.flushSync();
67903
68117
  });
67904
68118
  // What the runs ask for and stand for: the steady budget, whatever the
67905
68119
  // window of the first paint draws — a run asking for the rows of the first
67906
68120
  // picture and then for the rest is two round trips for one opening.
67907
- virtual.renderBudget = renderBudgetAfterPaint;
67908
- virtual.scrolled = scrolled ?? defaultScrolled;
68121
+ listRows.renderBudget = renderBudgetAfterPaint;
68122
+ listRows.scrolled = scrolled ?? defaultScrolled;
67909
68123
  const {
67910
68124
  virtualItemSizeSignal,
67911
68125
  renderWindow,
@@ -67914,11 +68128,10 @@ const ListUI = props => {
67914
68128
  captureAnchor
67915
68129
  } = useListScrollSync({
67916
68130
  ref,
67917
- tracker,
68131
+ listRows,
67918
68132
  renderBudget,
67919
68133
  renderBudgetSteady: renderBudgetAfterPaint,
67920
68134
  virtualItemSize,
67921
- virtual,
67922
68135
  scrolled,
67923
68136
  defaultScrolled,
67924
68137
  onScrolledChange,
@@ -67937,28 +68150,28 @@ const ListUI = props => {
67937
68150
  if (props.renderBudget === undefined || renderBudgetWarnedRef.current) {
67938
68151
  return;
67939
68152
  }
67940
- if (virtual.hasRuns() || tracker.itemsSignal.peek().length === 0) {
68153
+ if (listRows.hasRuns() || listRows.itemsSignal.peek().length === 0) {
67941
68154
  return;
67942
68155
  }
67943
68156
  renderBudgetWarnedRef.current = true;
67944
68157
  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.`);
67945
68158
  });
67946
- virtual.captureAnchor = captureAnchor;
67947
- virtual.virtualItemSizeSignal = virtualItemSizeSignal;
67948
- virtual.horizontal = Boolean(horizontal);
67949
- virtual.renderSkeleton = renderSkeleton;
68159
+ listRows.captureAnchor = captureAnchor;
68160
+ listRows.virtualItemSizeSignal = virtualItemSizeSignal;
68161
+ listRows.horizontal = Boolean(horizontal);
68162
+ listRows.renderSkeleton = renderSkeleton;
67950
68163
 
67951
68164
  // A row is addressed by id from outside (--navi-scroll, --navi-select): the
67952
- // ones drawn have registered themselves with the tracker, and the ones a run
68165
+ // ones drawn have said so (see list_rows.js), and the ones a run
67953
68166
  // holds without drawing are known only to that run (see List.Items' row
67954
68167
  // locator). Both answer here, so a row is reachable whether or not the
67955
68168
  // window happens to frame it.
67956
68169
  const getItemById = itemId => {
67957
- const itemDrawn = tracker.itemsSignal.peek().find(item => item.id === itemId);
68170
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
67958
68171
  if (itemDrawn) {
67959
68172
  return itemDrawn;
67960
68173
  }
67961
- const rowIndex = virtual.locateRow(itemId);
68174
+ const rowIndex = listRows.locateRow(itemId);
67962
68175
  if (rowIndex === null) {
67963
68176
  return undefined;
67964
68177
  }
@@ -67967,13 +68180,13 @@ const ListUI = props => {
67967
68180
  index: rowIndex
67968
68181
  };
67969
68182
  };
67970
- const noMatchCount = tracker.noMatchCountSignal.value;
68183
+ const noMatchCount = listRows.noMatchCountSignal.value;
67971
68184
  // What the list stands for, which is not always what it holds: a run saying
67972
68185
  // it covers 60 rows is not an empty list while it waits for the first of
67973
68186
  // them (see List.Items).
67974
68187
  // eslint-disable-next-line no-unused-expressions
67975
- virtual.pagesSignal.value;
67976
- const itemCount = tracker.countSignal.value || virtual.totalSignal.value;
68188
+ listRows.pagesSignal.value;
68189
+ const itemCount = listRows.countSignal.value || listRows.totalSignal.value;
67977
68190
  const allNoMatch = noMatchCount > 0 && noMatchCount === itemCount;
67978
68191
  const searching = Boolean(searchText);
67979
68192
  const fallbackDisabled = fallback !== undefined && !fallback;
@@ -68071,7 +68284,7 @@ const ListUI = props => {
68071
68284
  expand: expand,
68072
68285
  "navi-nothing-to-display": nothingToDisplay ? "" : undefined,
68073
68286
  "navi-loading": loading ? "" : undefined,
68074
- "navi-refreshing": virtual.refreshingSignal.value ? "" : undefined,
68287
+ "navi-refreshing": listRows.refreshingSignal.value ? "" : undefined,
68075
68288
  "navi-error": error ? "" : undefined,
68076
68289
  styleCSSVars: LIST_STYLE_CSS_VARS,
68077
68290
  pseudoClasses: LIST_PSEUDO_CLASSES,
@@ -68107,9 +68320,8 @@ const ListUI = props => {
68107
68320
  spacing: spacing,
68108
68321
  columns: columns,
68109
68322
  itemColumns: itemColumns,
68110
- tracker: tracker,
68323
+ listRows: listRows,
68111
68324
  renderWindow: renderWindow,
68112
- virtual: virtual,
68113
68325
  pendingScrollRef: pendingScrollRef,
68114
68326
  overflow: overflow,
68115
68327
  overflowX: overflowX,
@@ -68130,11 +68342,11 @@ const ListFirstResolver = props => {
68130
68342
  props.ref = props.ref || refDefault;
68131
68343
  const idDefault = useId();
68132
68344
  props.id = props.id || idDefault;
68133
- const virtualRef = useRef(null);
68134
- if (!virtualRef.current) {
68135
- virtualRef.current = createListVirtual();
68345
+ const listRowsRef = useRef(null);
68346
+ if (!listRowsRef.current) {
68347
+ listRowsRef.current = createListRows();
68136
68348
  }
68137
- props.virtual = virtualRef.current;
68349
+ props.listRows = listRowsRef.current;
68138
68350
  const parallelGuard = useParallelGuard(props.parallelGuard ?? PARALLEL_GUARD_DEFAULT);
68139
68351
  return jsx(ParallelGuardContext.Provider, {
68140
68352
  value: parallelGuard,
@@ -68160,9 +68372,8 @@ const ListContent = ({
68160
68372
  spacing,
68161
68373
  columns,
68162
68374
  itemColumns,
68163
- tracker,
68375
+ listRows,
68164
68376
  renderWindow,
68165
- virtual,
68166
68377
  pendingScrollRef,
68167
68378
  overflow,
68168
68379
  overflowX,
@@ -68216,9 +68427,8 @@ const ListContent = ({
68216
68427
  columns: columns,
68217
68428
  itemColumns: itemColumns,
68218
68429
  ...listProps,
68219
- tracker: tracker,
68430
+ listRows: listRows,
68220
68431
  renderWindow: renderWindow,
68221
- virtual: virtual,
68222
68432
  children: children
68223
68433
  })
68224
68434
  })
@@ -68242,11 +68452,10 @@ const LIST_STYLE_CSS_VARS = {
68242
68452
  const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
68243
68453
  const useListScrollSync = ({
68244
68454
  ref,
68245
- tracker,
68455
+ listRows,
68246
68456
  renderBudget,
68247
68457
  renderBudgetSteady,
68248
68458
  virtualItemSize,
68249
- virtual,
68250
68459
  scrolled,
68251
68460
  defaultScrolled,
68252
68461
  onScrolledChange,
@@ -68256,7 +68465,7 @@ const useListScrollSync = ({
68256
68465
  }) => {
68257
68466
  const debugScroll = useDebugScroll();
68258
68467
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
68259
- virtual,
68468
+ listRows,
68260
68469
  renderBudget,
68261
68470
  scrolledWanted: scrolled ?? defaultScrolled
68262
68471
  });
@@ -68281,7 +68490,7 @@ const useListScrollSync = ({
68281
68490
  ref,
68282
68491
  scrollerElResolved,
68283
68492
  renderBudget,
68284
- totalSignal: virtual.totalSignal,
68493
+ totalSignal: listRows.totalSignal,
68285
68494
  virtualItemSizeSignal,
68286
68495
  horizontal
68287
68496
  });
@@ -68306,7 +68515,7 @@ const useListScrollSync = ({
68306
68515
  anchorRef.current = captureScrollAnchor({
68307
68516
  scrollerEl: getScroller(),
68308
68517
  listEl: getListEl(),
68309
- items: tracker.visibleItemsSignal.peek(),
68518
+ items: listRows.visibleItemsSignal.peek(),
68310
68519
  horizontal
68311
68520
  });
68312
68521
  };
@@ -68338,7 +68547,7 @@ const useListScrollSync = ({
68338
68547
  start,
68339
68548
  end
68340
68549
  } = renderWindowRef.current;
68341
- const total = virtual.totalSignal.peek();
68550
+ const total = listRows.totalSignal.peek();
68342
68551
  let framedStart = start;
68343
68552
  let framedEnd = start + renderBudget;
68344
68553
  if (total > 0 && framedEnd > total) {
@@ -68382,19 +68591,19 @@ const useListScrollSync = ({
68382
68591
  // jumped.
68383
68592
  const holdWindow = () => {
68384
68593
  if (startPlaceRef.current.userTookOver) {
68385
- virtual.holdPending = false;
68594
+ listRows.holdPending = false;
68386
68595
  return;
68387
68596
  }
68388
68597
  // Held somewhere it has not reached yet: what the window frames right now
68389
68598
  // is not what it will frame, so nothing should be fetched for it.
68390
- virtual.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
68391
- const total = virtual.totalSignal.peek();
68599
+ listRows.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
68600
+ const total = listRows.totalSignal.peek();
68392
68601
  if (total <= renderBudget) {
68393
68602
  // The whole collection is what the list draws: wherever in it the list is
68394
68603
  // held, the window is already its place. Nowhere to move to means nothing
68395
68604
  // to wait for — a hold left standing here is a list that never asks for
68396
68605
  // anything again.
68397
- virtual.holdPending = false;
68606
+ listRows.holdPending = false;
68398
68607
  return;
68399
68608
  }
68400
68609
  const half = Math.floor(renderBudget / 2);
@@ -68404,7 +68613,7 @@ const useListScrollSync = ({
68404
68613
  } else if (typeof scrolledWanted === "number") {
68405
68614
  wantedStart = scrolledWanted - half;
68406
68615
  } else if (scrolledWanted && scrolledWanted.id !== undefined) {
68407
- const rowIndex = virtual.locateRow(scrolledWanted.id);
68616
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
68408
68617
  if (rowIndex !== null) {
68409
68618
  wantedStart = rowIndex - half;
68410
68619
  } else if (typeof scrolledWanted.index === "number") {
@@ -68431,14 +68640,14 @@ const useListScrollSync = ({
68431
68640
  end
68432
68641
  } = renderWindowRef.current;
68433
68642
  if (wantedStart === start && end - start === renderBudget) {
68434
- virtual.holdPending = false;
68643
+ listRows.holdPending = false;
68435
68644
  return;
68436
68645
  }
68437
68646
  renderWindowRef.current = {
68438
68647
  start: wantedStart,
68439
68648
  end: wantedStart + renderBudget
68440
68649
  };
68441
- virtual.holdPending = false;
68650
+ listRows.holdPending = false;
68442
68651
  };
68443
68652
  const pendingScrollRef = useRef();
68444
68653
  const scrollToItem = (item, {
@@ -68449,7 +68658,7 @@ const useListScrollSync = ({
68449
68658
  if (!item) {
68450
68659
  return;
68451
68660
  }
68452
- const items = tracker.itemsSignal.peek();
68661
+ const items = listRows.itemsSignal.peek();
68453
68662
  const itemCount = items.length;
68454
68663
  if (itemCount === 0) {
68455
68664
  return;
@@ -68578,7 +68787,7 @@ const useListScrollSync = ({
68578
68787
  return;
68579
68788
  }
68580
68789
  hasBeenDisplayedRef.current = true;
68581
- const items = tracker.itemsSignal.peek();
68790
+ const items = listRows.itemsSignal.peek();
68582
68791
  const firstSelected = items.find(i => {
68583
68792
  if (i.selected) {
68584
68793
  return true;
@@ -68663,7 +68872,7 @@ const useListScrollSync = ({
68663
68872
  scrollValues: savedScroll,
68664
68873
  scrollerEl: listScrollContainerEl,
68665
68874
  listEl: getListEl(),
68666
- tracker,
68875
+ listRows,
68667
68876
  virtualItemSizeSignal,
68668
68877
  renderWindowRef,
68669
68878
  horizontal
@@ -68680,7 +68889,7 @@ const useListScrollSync = ({
68680
68889
  });
68681
68890
  return undefined;
68682
68891
  }
68683
- const visibleItems = tracker.visibleItemsSignal.peek();
68892
+ const visibleItems = listRows.visibleItemsSignal.peek();
68684
68893
  const topItems = visibleItems.slice(0, renderBudget);
68685
68894
  const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchInfo?.matchScore ?? ""}`).join(",");
68686
68895
  const currentTopMatchScore = topMatchScoresKeyRef.current;
@@ -68718,7 +68927,7 @@ const useListScrollSync = ({
68718
68927
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
68719
68928
  return;
68720
68929
  }
68721
- if (virtual.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
68930
+ if (listRows.totalSignal.peek() === 0 || virtualItemSizeSignal.peek() === 0) {
68722
68931
  return;
68723
68932
  }
68724
68933
  // Coming back to a named row: it has to be on screen to be put back where
@@ -68730,13 +68939,13 @@ const useListScrollSync = ({
68730
68939
  // Only whoever holds the rows can say where that one sits: the list
68731
68940
  // itself knows the rows it has drawn, and this one is precisely the one
68732
68941
  // it has not drawn yet.
68733
- const rowIndex = virtual.locateRow(scrolledWanted.id);
68942
+ const rowIndex = listRows.locateRow(scrolledWanted.id);
68734
68943
  if (rowIndex === null) {
68735
68944
  // Not there yet. Where it stood is enough to be roughly right in the
68736
68945
  // meantime — the scrollbar lands near its final place instead of at the
68737
68946
  // top, and the exact position is taken once the row itself can be
68738
68947
  // measured.
68739
- if (virtual.pagesSignal.peek() === 0) {
68948
+ if (listRows.pagesSignal.peek() === 0) {
68740
68949
  if (typeof scrolledWanted.index === "number") {
68741
68950
  const rowPosition = scrolledWanted.index * virtualItemSizeSignal.peek();
68742
68951
  anchorRef.current = null;
@@ -68853,7 +69062,7 @@ const useListScrollSync = ({
68853
69062
  const position = captureScrollAnchor({
68854
69063
  scrollerEl: getScroller(),
68855
69064
  listEl: getListEl(),
68856
- items: tracker.visibleItemsSignal.peek(),
69065
+ items: listRows.visibleItemsSignal.peek(),
68857
69066
  horizontal
68858
69067
  });
68859
69068
  if (!position) {
@@ -68941,7 +69150,7 @@ const useListScrollSync = ({
68941
69150
  anchorRef.current = null;
68942
69151
  return;
68943
69152
  }
68944
- const items = tracker.visibleItemsSignal.peek();
69153
+ const items = listRows.visibleItemsSignal.peek();
68945
69154
  const itemNow = items.find(i => i.id === anchor.id);
68946
69155
  if (!itemNow) {
68947
69156
  anchorRef.current = null;
@@ -68963,7 +69172,7 @@ const useListScrollSync = ({
68963
69172
  const windowSize = end - start;
68964
69173
  const startShifted = start + indexShift;
68965
69174
  let startWanted = startShifted < 0 ? 0 : startShifted;
68966
- const total = virtual.totalSignal.peek();
69175
+ const total = listRows.totalSignal.peek();
68967
69176
  // Same normalization as the scroll listener: a window running past the
68968
69177
  // last row slides back instead of framing fewer rows than its budget
68969
69178
  // allows — every row that fits in it must stay rendered.
@@ -69021,7 +69230,7 @@ const useListScrollSync = ({
69021
69230
  const windowSlidRef = useRef(false);
69022
69231
  const budgetWarnedRef = useRef(false);
69023
69232
  const evaluateWindow = reason => {
69024
- const total = virtual.totalSignal.peek();
69233
+ const total = listRows.totalSignal.peek();
69025
69234
  if (total <= renderBudget) {
69026
69235
  return;
69027
69236
  }
@@ -69043,7 +69252,7 @@ const useListScrollSync = ({
69043
69252
  },
69044
69253
  scrollerEl,
69045
69254
  listEl,
69046
- tracker,
69255
+ listRows,
69047
69256
  virtualItemSizeSignal,
69048
69257
  renderWindowRef,
69049
69258
  horizontal
@@ -69725,12 +69934,12 @@ const getScrollInfo = ({
69725
69934
  scrollValues,
69726
69935
  scrollerEl,
69727
69936
  listEl,
69728
- tracker,
69937
+ listRows,
69729
69938
  virtualItemSizeSignal,
69730
69939
  renderWindowRef,
69731
69940
  horizontal
69732
69941
  }) => {
69733
- const items = tracker.itemsSignal.peek();
69942
+ const items = listRows.itemsSignal.peek();
69734
69943
  const viewportRect = getScrollerViewportRect(scrollerEl);
69735
69944
  const listRect = listEl.getBoundingClientRect();
69736
69945
  let hitEl = null;
@@ -69855,7 +70064,7 @@ const measureItemSize = (listEl, horizontal) => {
69855
70064
  };
69856
70065
  };
69857
70066
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
69858
- virtual,
70067
+ listRows,
69859
70068
  renderBudget,
69860
70069
  scrolledWanted
69861
70070
  }) => {
@@ -69915,7 +70124,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
69915
70124
  // size is for, and a list drawing every row it has would pay a layout on
69916
70125
  // each of its renders for a number nothing reads.
69917
70126
  const sizeAlreadyKnown = virtualSizeSignal.peek() !== 0;
69918
- const rowsHeldOffScreen = virtual.totalSignal.peek() > renderBudget;
70127
+ const rowsHeldOffScreen = listRows.totalSignal.peek() > renderBudget;
69919
70128
  if (!virtualItemSizeProp && sizeAlreadyKnown && rowsHeldOffScreen && ref.current) {
69920
70129
  const listEl = ref.current.querySelector(".navi_list");
69921
70130
  const measure = listEl ? measureItemSize(listEl, horizontal) : null;
@@ -69931,7 +70140,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
69931
70140
  // screen, and a list held somewhere (placeWhereHeld) before it knows where
69932
70141
  // that is. A list drawing every row it has, opening at its start, would
69933
70142
  // pay a layout in every commit for a number nobody reads.
69934
- const sizeRead = virtual.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
70143
+ const sizeRead = listRows.totalSignal.peek() > renderBudget || scrolledWanted !== undefined && scrolledWanted !== "start";
69935
70144
  if (!sizeRead) {
69936
70145
  return undefined;
69937
70146
  }
@@ -69983,9 +70192,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
69983
70192
  // item after each commit and writes to the signal, causing only the fillers to
69984
70193
  // re-render.
69985
70194
  const UnorderedList = ({
69986
- tracker,
70195
+ listRows,
69987
70196
  renderWindow,
69988
- virtual,
69989
70197
  fallback,
69990
70198
  fallbackShown,
69991
70199
  searchFallback,
@@ -70035,17 +70243,14 @@ const UnorderedList = ({
70035
70243
  value: separator ?? null,
70036
70244
  children: jsx(ItemTransitionContext.Provider, {
70037
70245
  value: Boolean(itemTransition),
70038
- children: jsx(ListItemTrackerContext.Provider, {
70039
- value: tracker,
70040
- children: jsx(ListVirtualContext.Provider, {
70041
- value: virtual,
70042
- children: jsx(ListRowContext.Provider, {
70043
- value: null,
70044
- children: jsx(ListItemColumnsContext.Provider, {
70045
- value: columns ? null : itemColumns || null,
70046
- children: jsx(ListDeclaredChildren, {
70047
- children: children
70048
- })
70246
+ children: jsx(ListRowsContext.Provider, {
70247
+ value: listRows,
70248
+ children: jsx(ListRowContext.Provider, {
70249
+ value: null,
70250
+ children: jsx(ListItemColumnsContext.Provider, {
70251
+ value: columns ? null : itemColumns || null,
70252
+ children: jsx(ListDeclaredChildren, {
70253
+ children: children
70049
70254
  })
70050
70255
  })
70051
70256
  })
@@ -70096,8 +70301,8 @@ const VirtualFiller = ({
70096
70301
  edge,
70097
70302
  itemCount
70098
70303
  }) => {
70099
- const virtual = useContext(ListVirtualContext);
70100
- const sizeToFill = itemCount * virtual.virtualItemSizeSignal.value;
70304
+ const listRows = useContext(ListRowsContext);
70305
+ const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
70101
70306
  if (!sizeToFill) {
70102
70307
  return null;
70103
70308
  }
@@ -70161,22 +70366,12 @@ const ListItemRowResolver = props => {
70161
70366
  ...props
70162
70367
  });
70163
70368
  }
70164
- // eslint-disable-next-line no-unused-vars
70165
- const {
70166
- id,
70167
- index,
70168
- item,
70169
- rowMinHeight,
70170
- rowMinWidth,
70171
- ...rowProps
70172
- } = row;
70173
70369
  return jsx(Next, {
70174
- ...rowProps,
70175
70370
  ...props,
70176
70371
  id: props.id || row.id,
70177
70372
  index: row.index,
70178
- minHeight: props.minHeight === undefined ? rowMinHeight : props.minHeight,
70179
- minWidth: props.minWidth === undefined ? rowMinWidth : props.minWidth
70373
+ minHeight: props.minHeight === undefined ? row.rowMinHeight : props.minHeight,
70374
+ minWidth: props.minWidth === undefined ? row.rowMinWidth : props.minWidth
70180
70375
  });
70181
70376
  };
70182
70377
  const ListItemPresentationResolver = props => {
@@ -70250,12 +70445,11 @@ const ListItemUI = props => {
70250
70445
  }
70251
70446
  const idDefault = useId();
70252
70447
  props.id = props.id || idDefault;
70253
- const tracker = useContext(ListItemTrackerContext);
70254
- const virtual = useContext(ListVirtualContext);
70448
+ const listRows = useContext(ListRowsContext);
70449
+ const groupId = useContext(ListGroupContext);
70255
70450
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
70256
70451
  // The run this row belongs to, when it comes from one (see ListItems): it
70257
- // registered the row, decided it is inside the render window, and placed its
70258
- // separator. All that is left here is to draw it.
70452
+ // gave the row its place and decided it is inside the render window.
70259
70453
  const row = useContext(ListRowContext);
70260
70454
  const slotId = useContext(ListSlotContext);
70261
70455
  // There is no standalone match/matchScore/highlight prop — participation
@@ -70263,7 +70457,7 @@ const ListItemUI = props => {
70263
70457
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
70264
70458
  // matchRanges }), so there is exactly one way to wire it up.
70265
70459
  const matchInfo = props.matchInfo;
70266
- // Expose match on the tracked item: the tracker counts non-matching items via
70460
+ // Expose match on the row: the list counts non-matching rows via
70267
70461
  // `item.match === false` (drives noMatchCount → allNoMatch → the searchFallback
70268
70462
  // / hide-when-empty behavior). Without this a matchInfo-based search would
70269
70463
  // filter items out but never register them as "no match".
@@ -70289,68 +70483,47 @@ const ListItemUI = props => {
70289
70483
  // and the one leaving must give back its own place, not the newcomer's.
70290
70484
  if (!row) {
70291
70485
  if (props.filtered) {
70292
- virtual.drop(idDefault);
70486
+ listRows.drop(idDefault);
70293
70487
  } else {
70294
- props.index = virtual.take(idDefault, 1, slotId);
70488
+ props.index = listRows.take(idDefault, 1, slotId);
70295
70489
  }
70296
70490
  }
70491
+ // Every row that renders says so, whether it was declared one by one or
70492
+ // drawn by a run: what it is (its value, whether it is selected) and whether
70493
+ // it mounts at all are written where it renders, in one place.
70494
+ listRows.draw(idDefault, {
70495
+ ownerId: row ? row.ownerId : idDefault,
70496
+ place: props.index,
70497
+ groupId,
70498
+ data: props
70499
+ });
70297
70500
  useLayoutEffect(() => {
70298
70501
  return () => {
70299
- virtual.drop(idDefault);
70502
+ listRows.erase(idDefault);
70300
70503
  };
70301
70504
  }, []);
70302
- // Every row that is drawn registers itself, whether it was declared one by
70303
- // one or drawn by a run: what it says about itself (its value, whether it is
70304
- // selected) is written where it is drawn, in one place.
70305
- const item = props;
70306
- tracker.useTrackItem(item);
70307
- const groupTracker = useContext(GroupItemTrackerContext);
70308
- const groupVisibleIndex = groupTracker ? groupTracker.useTrackItem(item) : null;
70309
70505
  const separator = useContext(SeparatorContext);
70310
70506
  if (props.filtered) {
70311
70507
  return null;
70312
70508
  }
70313
- // html-hidden items: excluded from virtual scroll accounting but always in DOM
70314
- if (props.hidden) {
70315
- // Its separator stays too, and stays invisible with it: the point of
70316
- // keeping a row that matches nothing is that nothing moves, and a divider
70317
- // that leaves takes its own height away.
70318
- if (!separator || props.index === 0) {
70319
- return jsx(ListItemReal, {
70320
- ...props
70321
- });
70322
- }
70323
- return jsxs(Fragment, {
70324
- children: [cloneElement(resolveSeparatorVnode(separator, props.index - 1), {
70325
- style: VISIBILITY_HIDDEN_STYLE
70326
- }), jsx(ListItemReal, {
70327
- ...props
70328
- })]
70329
- });
70330
- }
70331
- if (row) {
70332
- return jsx(ListItemReal, {
70333
- ...props
70334
- });
70335
- }
70336
- const index = props.index;
70337
70509
  const listItemVnode = jsx(ListItemReal, {
70338
70510
  ...props
70339
70511
  });
70340
- // "Am I the first visible item?" is answered by the place the list handed
70341
- // out (virtual.take above), not by the tracker's visibleIndex: during a
70342
- // reorder render pass (items resorted by search score) the other items still
70343
- // carry stale keyToExplicitOrder values, the binary search reads them, no
70344
- // item comes out at 0 and a spurious separator appears at the top. Inside a
70345
- // group, each group has its own tracker and its items do not reorder, so
70346
- // groupVisibleIndex is reliable.
70347
- const isFirstInList = groupVisibleIndex === null ? index === 0 : groupVisibleIndex === 0;
70348
- if (!separator || isFirstInList) {
70512
+ // The separator a row wears is the one at the gap above it: none when
70513
+ // nothing of the list stands above it (see list_rows.js).
70514
+ if (!separator || listRows.isFirst(idDefault)) {
70349
70515
  return listItemVnode;
70350
70516
  }
70351
- // separatorIndex is only used as the function-form argument (gap index)
70352
- const separatorIndex = groupVisibleIndex === null ? index : groupVisibleIndex;
70353
- const separatorVnode = resolveSeparatorVnode(separator, separatorIndex - 1);
70517
+ // The gap index, only used as the function-form argument.
70518
+ let separatorVnode = resolveSeparatorVnode(separator, props.index - 1);
70519
+ if (props.hidden) {
70520
+ // A row kept in the DOM but hidden keeps its separator, hidden with it:
70521
+ // the point of keeping a row that matches nothing is that nothing moves,
70522
+ // and a divider that leaves takes its own height away.
70523
+ separatorVnode = cloneElement(separatorVnode, {
70524
+ style: VISIBILITY_HIDDEN_STYLE
70525
+ });
70526
+ }
70354
70527
  return jsxs(Fragment, {
70355
70528
  children: [separatorVnode, listItemVnode]
70356
70529
  });
@@ -70671,346 +70844,45 @@ const ListItem = /*#__PURE__*/createComponentResolver([ListItemFirstResolver, Li
70671
70844
  pure: true
70672
70845
  });
70673
70846
 
70674
- // Everything the list knows about the collection while its children are being
70675
- // rendered: how many rows it has in total, which of them are actually held, and
70676
- // where each child's rows start.
70677
- //
70678
- // A child knows how many rows it stands for but not what was declared before
70679
- // it, and it cannot deduce that from when it renders: a render is free to skip
70680
- // it. A child that draws from signals and whose props are all referentially
70681
- // === the previous ones does not render again (@preact/signals installs a
70682
- // shouldComponentUpdate that says so), which is what any child nobody rebuilt
70683
- // this frame is — and children numbered as they render would then slide up
70684
- // into the place of the one that was skipped.
70847
+ // The walk that gives the list's children their places: a slot for each of
70848
+ // them, declared to the list's rows all at once before any child renders,
70849
+ // and handed to the child through a provider of its own — which is what lets
70850
+ // the row reach it however deep the caller buried it in components of theirs.
70685
70851
  //
70686
- // So the places are read off the walk instead of off the renders: the list
70687
- // names a slot for each of its children and declares them here, in order,
70688
- // before any of them renders (see ListDeclaredChildren). A child then takes
70689
- // its place BY SLOT, and the place is a signal: it moves when what stands
70690
- // before it changes — a row filtered out, a run taking in rows, a slot added
70691
- // or moved — and the child follows, rendered again for it whether or not
70692
- // anything else would have rendered it.
70693
- const createListVirtual = () => {
70694
- const totalSignal = signal(0);
70695
- // Bumped whenever a run takes in rows. The list itself has to hear about it:
70696
- // rows arriving outside the render window change nothing it can see (nothing
70697
- // registers, nothing is drawn), and yet they are what it may have been
70698
- // waiting for — the row it was told to open on, for one.
70699
- const pagesSignal = signal(0);
70700
- // How many runs are re-reading rows they already show. The list wears it as
70701
- // an attribute: what is drawn is from before, and the app may want to say so
70702
- // without taking anything away.
70703
- const refreshingSignal = signal(0);
70704
- // The slots each walk declared, in order, by the slot the walk stands in
70705
- // (null for the list's own children). Together they are a tree: a group's
70706
- // rows live inside the group's slot.
70707
- const slotIdsByParent = new Map();
70708
- // Who took a place a row, or a run of rows — and how many rows of the
70709
- // collection it stands for. The place itself is a signal, see take.
70710
- const ownerById = new Map();
70711
- // The owners standing in each slot, in the order they took their place.
70712
- // One, as a rule; a child that renders several rows keeps them in the order
70713
- // they first rendered, which is all it can be told.
70714
- const ownerIdsBySlot = new Map();
70715
- const locatorByOwner = new Map();
70716
- // The slots as the tree reads, first to last, and where each stands in it.
70717
- // Rebuilt once a walk has changed the tree, read to place the owners.
70718
- const slotWalk = [];
70719
- const rankBySlot = new Map();
70720
- let rowTotal = 0;
70721
- // Owners have left and the others have not been moved up yet. Done on the
70722
- // next ask rather than on the spot: rows leave many at a time (a search, a
70723
- // list unmounting), and moving the others up once is enough.
70724
- let placesStale = false;
70725
- // Where the last slot holding an owner stands: an owner arriving at or after
70726
- // it is placed at the end without going over the others — a whole first
70727
- // render, rows arriving in order, costs each row nothing but itself.
70728
- let rankOwnedLast = -1;
70729
- const rebuildWalk = () => {
70730
- slotWalk.length = 0;
70731
- rankBySlot.clear();
70732
- const visit = parentSlotId => {
70733
- const slotIds = slotIdsByParent.get(parentSlotId);
70734
- if (!slotIds) {
70735
- return;
70736
- }
70737
- for (const slotId of slotIds) {
70738
- rankBySlot.set(slotId, slotWalk.length);
70739
- slotWalk.push(slotId);
70740
- visit(slotId);
70741
- }
70742
- };
70743
- visit(null);
70744
- };
70745
- // Every place, in one go: a place is the sum of what stands before it, so
70746
- // there is nothing to hand out one at a time. Writing a place that did not
70747
- // change wakes nobody — a signal ignores a value equal to its own.
70748
- const refreshPlaces = () => {
70749
- placesStale = false;
70750
- let index = 0;
70751
- let rank = 0;
70752
- rankOwnedLast = -1;
70753
- while (rank < slotWalk.length) {
70754
- const ownerIds = ownerIdsBySlot.get(slotWalk[rank]);
70755
- if (ownerIds) {
70756
- for (const ownerId of ownerIds) {
70757
- const owner = ownerById.get(ownerId);
70758
- owner.placeSignal.value = index;
70759
- index += owner.rowCount;
70760
- }
70761
- rankOwnedLast = rank;
70762
- }
70763
- rank++;
70764
- }
70765
- rowTotal = index;
70766
- totalSignal.value = index;
70767
- };
70768
- const addToSlot = (slotId, ownerId) => {
70769
- const ownerIds = ownerIdsBySlot.get(slotId);
70770
- if (ownerIds) {
70771
- ownerIds.push(ownerId);
70772
- } else {
70773
- ownerIdsBySlot.set(slotId, [ownerId]);
70774
- }
70775
- warnIfEveryRowInOneSlot(slotId);
70776
- };
70777
- // Rows that all stand in the same slot keep the order they first mounted in:
70778
- // the walk is over the children the list is given, and a component holding
70779
- // them is one child however many rows it renders. Everything about a place
70780
- // then stops following what the caller writes — a search reordering the rows
70781
- // moves nothing. Said once, and only for the shape that can be nothing else:
70782
- // the list's whole content is one child, and several rows came out of it.
70783
- let everyRowInOneSlotWarned = false;
70784
- const warnIfEveryRowInOneSlot = slotId => {
70785
- if (everyRowInOneSlotWarned) {
70786
- return;
70787
- }
70788
- const rootSlotIds = slotIdsByParent.get(null);
70789
- if (!rootSlotIds || rootSlotIds.length !== 1 || rootSlotIds[0] !== slotId) {
70790
- return;
70791
- }
70792
- if (ownerIdsBySlot.get(slotId).length < 2) {
70793
- return;
70794
- }
70795
- everyRowInOneSlotWarned = true;
70796
- 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.`);
70797
- };
70798
- const removeFromSlot = (slotId, ownerId) => {
70799
- const ownerIds = ownerIdsBySlot.get(slotId);
70800
- if (!ownerIds) {
70801
- return;
70802
- }
70803
- const index = ownerIds.indexOf(ownerId);
70804
- if (index !== -1) {
70805
- ownerIds.splice(index, 1);
70806
- }
70807
- if (ownerIds.length === 0) {
70808
- ownerIdsBySlot.delete(slotId);
70809
- }
70810
- };
70811
- // A slot the walk no longer names: whatever stood in it is gone, and so is
70812
- // whatever a walk inside it had declared.
70813
- const dropSlot = slotId => {
70814
- const ownerIds = ownerIdsBySlot.get(slotId);
70815
- if (ownerIds) {
70816
- for (const ownerId of ownerIds) {
70817
- ownerById.delete(ownerId);
70818
- }
70819
- ownerIdsBySlot.delete(slotId);
70820
- }
70821
- const childSlotIds = slotIdsByParent.get(slotId);
70822
- if (childSlotIds) {
70823
- slotIdsByParent.delete(slotId);
70824
- for (const childSlotId of childSlotIds) {
70825
- dropSlot(childSlotId);
70826
- }
70827
- }
70828
- };
70829
- const virtual = {
70830
- totalSignal,
70831
- pagesSignal,
70832
- refreshingSignal,
70833
- // What a run needs to know about the list it lives in: how many rows the
70834
- // list is willing to draw at once, which end it opens on, and how much
70835
- // room one row is given — a row whose content has not arrived must take
70836
- // exactly that, or the rows drawn would not reach where the list says they
70837
- // are.
70838
- renderBudget: 0,
70839
- scrolled: "start",
70840
- // The list is on its way somewhere: what the window frames is not what it
70841
- // is about to frame, so a run must not fetch for it (see holdWindow).
70842
- holdPending: false,
70843
- // Called by a run just before rows land in it: what is on screen must not
70844
- // move because something arrived above it. Set by the list itself.
70845
- captureAnchor: () => {},
70846
- horizontal: false,
70847
- virtualItemSizeSignal: null,
70848
- renderSkeleton: undefined,
70849
- // The children a walk stands over, in order — said in one call, before any
70850
- // of them renders, so that what a child asks next is answered against the
70851
- // whole picture and not against the children that happened to render
70852
- // first. Said again on every render of the walk, and heard only when
70853
- // something moved.
70854
- declareSlots: (parentSlotId, slotIds) => {
70855
- const slotIdsPrevious = slotIdsByParent.get(parentSlotId);
70856
- if (slotIdsPrevious && sameSlotIds(slotIdsPrevious, slotIds)) {
70857
- return;
70858
- }
70859
- if (slotIdsPrevious) {
70860
- const slotIdSet = new Set(slotIds);
70861
- for (const slotId of slotIdsPrevious) {
70862
- if (!slotIdSet.has(slotId)) {
70863
- dropSlot(slotId);
70864
- }
70865
- }
70866
- }
70867
- slotIdsByParent.set(parentSlotId, slotIds);
70868
- rebuildWalk();
70869
- refreshPlaces();
70870
- },
70871
- // Whether something has taken this slot for its own: what it renders
70872
- // inside is then its to place (a run draws its groups with their rows
70873
- // already placed), and no walk inside it has anything to declare.
70874
- slotHasOwner: slotId => ownerIdsBySlot.has(slotId),
70875
- // Whether any run of rows lives in this list: what makes a render window
70876
- // mean anything (see List's renderBudget).
70877
- hasRuns: () => locatorByOwner.size > 0,
70878
- setRowLocator: (ownerId, locate) => {
70879
- locatorByOwner.set(ownerId, locate);
70880
- },
70881
- dropRowLocator: ownerId => {
70882
- locatorByOwner.delete(ownerId);
70883
- },
70884
- // Where the row named by that id sits, asked of whoever holds it.
70885
- locateRow: id => {
70886
- for (const locate of locatorByOwner.values()) {
70887
- const index = locate(id);
70888
- if (index !== null) {
70889
- return index;
70890
- }
70891
- }
70892
- return null;
70893
- },
70894
- // The place the owner's rows start at — read from a signal, so that the
70895
- // owner is rendered again when it moves (see createListVirtual). Asked on
70896
- // every render, and answered without a second look for as long as the
70897
- // owner stands in the same slot for the same number of rows.
70898
- take: (ownerId, rowCount, slotId) => {
70899
- let owner = ownerById.get(ownerId);
70900
- if (owner) {
70901
- if (owner.slotId !== slotId || owner.rowCount !== rowCount) {
70902
- removeFromSlot(owner.slotId, ownerId);
70903
- addToSlot(slotId, ownerId);
70904
- owner.slotId = slotId;
70905
- owner.rowCount = rowCount;
70906
- placesStale = true;
70907
- }
70908
- if (placesStale) {
70909
- refreshPlaces();
70910
- }
70911
- return owner.placeSignal.value;
70912
- }
70913
- if (placesStale) {
70914
- refreshPlaces();
70915
- }
70916
- const rank = rankBySlot.get(slotId);
70917
- addToSlot(slotId, ownerId);
70918
- if (rank !== undefined && rank >= rankOwnedLast) {
70919
- owner = {
70920
- slotId,
70921
- rowCount,
70922
- placeSignal: signal(rowTotal)
70923
- };
70924
- ownerById.set(ownerId, owner);
70925
- rowTotal += rowCount;
70926
- rankOwnedLast = rank;
70927
- totalSignal.value = rowTotal;
70928
- return owner.placeSignal.value;
70929
- }
70930
- owner = {
70931
- slotId,
70932
- rowCount,
70933
- placeSignal: signal(0)
70934
- };
70935
- ownerById.set(ownerId, owner);
70936
- refreshPlaces();
70937
- return owner.placeSignal.value;
70938
- },
70939
- // The owner stands for no row of the collection: it was filtered out by a
70940
- // search, or it is gone.
70941
- drop: ownerId => {
70942
- const owner = ownerById.get(ownerId);
70943
- if (!owner) {
70944
- return;
70945
- }
70946
- ownerById.delete(ownerId);
70947
- removeFromSlot(owner.slotId, ownerId);
70948
- if (placesStale) {
70949
- return;
70950
- }
70951
- placesStale = true;
70952
- queueMicrotask(() => {
70953
- if (placesStale) {
70954
- refreshPlaces();
70955
- }
70956
- });
70957
- }
70958
- };
70959
- return virtual;
70960
- };
70961
- const sameSlotIds = (left, right) => {
70962
- if (left.length !== right.length) {
70963
- return false;
70964
- }
70965
- let index = 0;
70966
- while (index < left.length) {
70967
- if (left[index] !== right[index]) {
70968
- return false;
70969
- }
70970
- index++;
70971
- }
70972
- return true;
70973
- };
70974
-
70975
- // The walk that gives the list's children their places: a slot for each of
70976
- // them, declared to the list's virtual all at once before any child renders,
70977
- // and handed to the child through a provider of its own — which is what lets
70978
- // the row reach it however deep the caller buried it in components of theirs.
70979
- //
70980
- // A slot is named the way preact tells the child apart: by key when it has
70981
- // one, by position otherwise, and inside the array it was given in — a nested
70982
- // array is one child to preact, so what follows the array keeps its name
70983
- // however many rows the array holds. A child preact would not render (null,
70984
- // a boolean) has no slot: it is not there.
70985
- const ListDeclaredChildren = ({
70986
- children
70987
- }) => {
70988
- const virtual = useContext(ListVirtualContext);
70989
- const parentSlotId = useContext(ListSlotContext);
70990
- if (parentSlotId !== null && virtual.slotHasOwner(parentSlotId)) {
70991
- return children;
70992
- }
70993
- const slotIds = [];
70994
- const declared = [];
70995
- declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
70996
- virtual.declareSlots(parentSlotId, slotIds);
70997
- return jsx(Fragment, {
70998
- children: declared
70999
- });
71000
- };
71001
- const declareChildren = (children, prefix, slotIds, declared) => {
71002
- const childArray = Array.isArray(children) ? children : [children];
71003
- let index = 0;
71004
- for (const child of childArray) {
71005
- if (Array.isArray(child)) {
71006
- declareChildren(child, `${prefix}${index}/`, slotIds, declared);
71007
- } else if (child !== null && child !== undefined && child !== false && child !== true) {
71008
- const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
71009
- slotIds.push(slotId);
71010
- declared.push(jsx(ListSlotContext.Provider, {
71011
- value: slotId,
71012
- children: child
71013
- }, slotId));
70852
+ // A slot is named the way preact tells the child apart: by key when it has
70853
+ // one, by position otherwise, and inside the array it was given in — a nested
70854
+ // array is one child to preact, so what follows the array keeps its name
70855
+ // however many rows the array holds. A child preact would not render (null,
70856
+ // a boolean) has no slot: it is not there.
70857
+ const ListDeclaredChildren = ({
70858
+ children
70859
+ }) => {
70860
+ const listRows = useContext(ListRowsContext);
70861
+ const parentSlotId = useContext(ListSlotContext);
70862
+ if (parentSlotId !== null && listRows.slotHasOwner(parentSlotId)) {
70863
+ return children;
70864
+ }
70865
+ const slotIds = [];
70866
+ const declared = [];
70867
+ declareChildren(children, parentSlotId === null ? "" : `${parentSlotId}/`, slotIds, declared);
70868
+ listRows.declareSlots(parentSlotId, slotIds);
70869
+ return jsx(Fragment, {
70870
+ children: declared
70871
+ });
70872
+ };
70873
+ const declareChildren = (children, prefix, slotIds, declared) => {
70874
+ const childArray = Array.isArray(children) ? children : [children];
70875
+ let index = 0;
70876
+ for (const child of childArray) {
70877
+ if (Array.isArray(child)) {
70878
+ declareChildren(child, `${prefix}${index}/`, slotIds, declared);
70879
+ } else if (child !== null && child !== undefined && child !== false && child !== true) {
70880
+ const slotId = child.key === undefined || child.key === null ? `${prefix}i${index}` : `${prefix}k${child.key}`;
70881
+ slotIds.push(slotId);
70882
+ declared.push(jsx(ListSlotContext.Provider, {
70883
+ value: slotId,
70884
+ children: child
70885
+ }, slotId));
71014
70886
  }
71015
70887
  index++;
71016
70888
  }
@@ -71152,7 +71024,7 @@ const ListItems = ({
71152
71024
  onRequestStateChange
71153
71025
  }) => {
71154
71026
  const ownerId = useId();
71155
- const virtual = useContext(ListVirtualContext);
71027
+ const listRows = useContext(ListRowsContext);
71156
71028
  const slotId = useContext(ListSlotContext);
71157
71029
  const renderWindow = useContext(RenderWindowContext);
71158
71030
  const separator = useContext(SeparatorContext);
@@ -71164,10 +71036,9 @@ const ListItems = ({
71164
71036
  // row at the same index, in the same refreshing state: everything the
71165
71037
  // function is given.
71166
71038
  const rowVnodesRef = useRef(null);
71167
- if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem || rowVnodesRef.current.separator !== separator) {
71039
+ if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem) {
71168
71040
  rowVnodesRef.current = {
71169
71041
  renderItem,
71170
- separator,
71171
71042
  byItem: new Map()
71172
71043
  };
71173
71044
  }
@@ -71179,7 +71050,7 @@ const ListItems = ({
71179
71050
  memoryBudget,
71180
71051
  onRequestStateChange
71181
71052
  });
71182
- const renderRowSkeleton = renderSkeleton === undefined ? virtual.renderSkeleton : renderSkeleton;
71053
+ const renderRowSkeleton = renderSkeleton === undefined ? listRows.renderSkeleton : renderSkeleton;
71183
71054
  // A row on its way takes the room the list reserves for it: anything else
71184
71055
  // and the rows drawn stop short of where the scroll says they are. Read
71185
71056
  // where a row is actually missing, and not before: the size settles after
@@ -71191,9 +71062,9 @@ const ListItems = ({
71191
71062
  return skeletonRow;
71192
71063
  }
71193
71064
  skeletonRow = {};
71194
- const virtualItemSize = virtual.virtualItemSizeSignal.value;
71065
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
71195
71066
  if (virtualItemSize) {
71196
- if (virtual.horizontal) {
71067
+ if (listRows.horizontal) {
71197
71068
  skeletonRow.rowMinWidth = `${virtualItemSize}px`;
71198
71069
  } else {
71199
71070
  skeletonRow.rowMinHeight = `${virtualItemSize}px`;
@@ -71201,7 +71072,7 @@ const ListItems = ({
71201
71072
  }
71202
71073
  return skeletonRow;
71203
71074
  };
71204
- const runStart = virtual.take(ownerId, store.rowCount, slotId);
71075
+ const runStart = listRows.take(ownerId, store.rowCount, slotId);
71205
71076
  const runEnd = runStart + store.rowCount;
71206
71077
  // The two ways to count the same row. The list numbers its rows from its own
71207
71078
  // first one, whatever draws it; the store numbers the collection's, straight
@@ -71216,6 +71087,7 @@ const ListItems = ({
71216
71087
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
71217
71088
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
71218
71089
  store.forget(rankOf(windowFrom), rankOf(windowTo));
71090
+ listRows.declareWindow(ownerId, windowFrom, windowTo);
71219
71091
 
71220
71092
  // The row answers to its own id when the item carries one — that is what
71221
71093
  // addresses it from outside (--navi-select, --navi-scroll, startAt) — and
@@ -71225,7 +71097,7 @@ const ListItems = ({
71225
71097
  // Where a row named from outside actually sits. Only the run can answer:
71226
71098
  // rows it holds but does not draw are nowhere else — a list only knows the
71227
71099
  // rows it has drawn (they register themselves, see ListItemUI).
71228
- virtual.setRowLocator(ownerId, id => {
71100
+ listRows.setRowLocator(ownerId, id => {
71229
71101
  let found = null;
71230
71102
  store.eachHeld((item, rank) => {
71231
71103
  const rowIndex = rowOf(rank);
@@ -71237,8 +71109,8 @@ const ListItems = ({
71237
71109
  });
71238
71110
  useLayoutEffect(() => {
71239
71111
  return () => {
71240
- virtual.dropRowLocator(ownerId);
71241
- virtual.drop(ownerId);
71112
+ listRows.dropRowLocator(ownerId);
71113
+ listRows.drop(ownerId);
71242
71114
  };
71243
71115
  }, []);
71244
71116
 
@@ -71264,7 +71136,7 @@ const ListItems = ({
71264
71136
  let askStart = missingStart;
71265
71137
  let askEnd = missingEnd;
71266
71138
  if (missingStart !== -1) {
71267
- const rowsPerPage = pageSize || virtual.renderBudget;
71139
+ const rowsPerPage = pageSize || listRows.renderBudget;
71268
71140
  const holeSize = missingEnd - missingStart + 1;
71269
71141
  if (holeSize < rowsPerPage) {
71270
71142
  // Which way the page grows: away from the rows already held, which is
@@ -71339,13 +71211,9 @@ const ListItems = ({
71339
71211
  }, `${ownerId}_group_${group.key}`));
71340
71212
  group = null;
71341
71213
  };
71342
- // Which group a row belongs to, or undefined when it belongs to none. Asked
71343
- // before the row is pushed as well as while pushing it: a row opening a
71344
- // group is the one row that must not wear a separator (see below).
71214
+ // Which group a row belongs to, or undefined when it belongs to none.
71345
71215
  const groupKeyOf = (item, rowIndex) => groupBy && item !== undefined ? groupBy(item, rowIndex) : undefined;
71346
- const opensGroup = groupKey => groupKey !== undefined && (!group || group.key !== groupKey);
71347
- const pushRow = (rowNode, item, rowIndex) => {
71348
- const groupKey = groupKeyOf(item, rowIndex);
71216
+ const pushRow = (rowNode, item, rowIndex, groupKey) => {
71349
71217
  if (groupKey === undefined) {
71350
71218
  closeGroup();
71351
71219
  rows.push(rowNode);
@@ -71383,7 +71251,7 @@ const ListItems = ({
71383
71251
  rows.push(jsx("li", {
71384
71252
  className: "navi_list_failed_rows",
71385
71253
  style: {
71386
- "--size-to-fill": `${failedRowCount * virtual.virtualItemSizeSignal.value}px`
71254
+ "--size-to-fill": `${failedRowCount * listRows.virtualItemSizeSignal.value}px`
71387
71255
  },
71388
71256
  children: renderError ? renderError({
71389
71257
  error: store.failure.error,
@@ -71400,78 +71268,70 @@ const ListItems = ({
71400
71268
  }
71401
71269
  const item = getItemAt(rowIndex);
71402
71270
  const key = item === undefined ? `${ownerId}_skeleton_${rowIndex}` : idOf(item, rowIndex);
71271
+ const groupKey = groupKeyOf(item, rowIndex);
71272
+ if (item === undefined) {
71273
+ // A row on its way never reaches ListItemUI (see ListItemSkeletonResolver):
71274
+ // it is stood among the rows that mount, and given its separator, here.
71275
+ let rowVnode;
71276
+ if (renderRowSkeleton === false) {
71277
+ // The row must still take its room: without it the rows below would
71278
+ // climb up and slide back down as the answer arrives.
71279
+ rowVnode = jsx(ListItem, {
71280
+ skeleton: true,
71281
+ style: VISIBILITY_HIDDEN_STYLE
71282
+ });
71283
+ } else if (renderRowSkeleton) {
71284
+ rowVnode = renderRowSkeleton(rowIndex);
71285
+ } else {
71286
+ rowVnode = jsx(ListItem, {
71287
+ skeleton: true
71288
+ });
71289
+ }
71290
+ if (rowVnode) {
71291
+ pushRow(jsx(ListRunSkeletonRow, {
71292
+ row: {
71293
+ id: key,
71294
+ index: rowIndex,
71295
+ ownerId,
71296
+ ...getSkeletonRow()
71297
+ },
71298
+ separator: separator,
71299
+ children: rowVnode
71300
+ }, key), item, rowIndex, groupKey);
71301
+ }
71302
+ rowIndex++;
71303
+ continue;
71304
+ }
71403
71305
  let rowVnode;
71404
71306
  let rowContextValue;
71405
- let rowKept = null;
71406
- if (item !== undefined) {
71407
- const rowVnodeKept = rowVnodesByItem.get(item);
71408
- if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
71409
- rowVnode = rowVnodeKept.vnode;
71410
- rowContextValue = rowVnodeKept.rowContextValue;
71411
- rowKept = rowVnodeKept;
71412
- } else {
71413
- rowVnode = renderItem(item, rowIndex, renderItemState);
71414
- // Kept with the vnode, for the same reason: a context value that is a
71415
- // fresh object on every render forces every consumer of it to render,
71416
- // which is the row's own chain — the vnode handed back unchanged would
71417
- // then buy nothing.
71418
- rowContextValue = {
71419
- id: key,
71420
- index: rowIndex,
71421
- item
71422
- };
71423
- rowKept = {
71424
- vnode: rowVnode,
71425
- rowContextValue,
71426
- rowIndex,
71427
- refreshing: renderItemState.refreshing,
71428
- separatorVnode: null
71429
- };
71430
- rowVnodesByItem.set(item, rowKept);
71431
- }
71432
- } else if (renderRowSkeleton === false) {
71433
- // The row must still take its room: without it the rows below would
71434
- // climb up and slide back down as the answer arrives.
71435
- rowVnode = jsx(ListItem, {
71436
- skeleton: true,
71437
- style: VISIBILITY_HIDDEN_STYLE
71438
- });
71439
- } else if (renderRowSkeleton) {
71440
- rowVnode = renderRowSkeleton(rowIndex);
71307
+ const rowVnodeKept = rowVnodesByItem.get(item);
71308
+ if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
71309
+ rowVnode = rowVnodeKept.vnode;
71310
+ rowContextValue = rowVnodeKept.rowContextValue;
71441
71311
  } else {
71442
- rowVnode = jsx(ListItem, {
71443
- skeleton: true
71312
+ rowVnode = renderItem(item, rowIndex, renderItemState);
71313
+ // Kept with the vnode, for the same reason: a context value that is a
71314
+ // fresh object on every render forces every consumer of it to render,
71315
+ // which is the row's own chain — the vnode handed back unchanged would
71316
+ // then buy nothing.
71317
+ rowContextValue = {
71318
+ id: key,
71319
+ index: rowIndex,
71320
+ item,
71321
+ ownerId
71322
+ };
71323
+ rowVnodesByItem.set(item, {
71324
+ vnode: rowVnode,
71325
+ rowContextValue,
71326
+ rowIndex,
71327
+ refreshing: renderItemState.refreshing
71444
71328
  });
71445
71329
  }
71446
71330
  if (rowVnode) {
71447
- // The first row of a group wears no separator: the gap it sits at is the
71448
- // one between two groups, and that gap is the group wrapper's own — it
71449
- // is a row of the list like any other and draws its separator itself
71450
- // (see ListItemUI). Drawn here it would land inside the group instead,
71451
- // as a hairline under the label.
71452
- const drawSeparator = separator && rowIndex > 0 && !opensGroup(groupKeyOf(item, rowIndex));
71453
- if (drawSeparator) {
71454
- // Kept with the row too: a separator built again is a separator
71455
- // rendered again.
71456
- let separatorVnode = rowKept ? rowKept.separatorVnode : null;
71457
- if (!separatorVnode) {
71458
- separatorVnode = cloneElement(resolveSeparatorVnode(separator, rowIndex - 1), {
71459
- key: `${key}_separator`
71460
- });
71461
- if (rowKept) {
71462
- rowKept.separatorVnode = separatorVnode;
71463
- }
71464
- }
71465
- pushRow(separatorVnode, item, rowIndex);
71466
- }
71467
71331
  pushRow(jsx(ListRowContext.Provider, {
71468
- value: item === undefined ? {
71469
- id: key,
71470
- index: rowIndex,
71471
- ...getSkeletonRow()
71472
- } : rowContextValue,
71332
+ value: rowContextValue,
71473
71333
  children: rowVnode
71474
- }, key), item, rowIndex);
71334
+ }, key), item, rowIndex, groupKey);
71475
71335
  }
71476
71336
  rowIndex++;
71477
71337
  }
@@ -71485,6 +71345,44 @@ const ListItems = ({
71485
71345
  return rows;
71486
71346
  };
71487
71347
 
71348
+ // A run's row that has not arrived, standing where the real one will. It never
71349
+ // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
71350
+ // rows here, and wears the separator of the gap above it the way a real row
71351
+ // does there.
71352
+ const SKELETON_ROW_DATA = {
71353
+ skeleton: true
71354
+ };
71355
+ const ListRunSkeletonRow = ({
71356
+ row,
71357
+ separator,
71358
+ children
71359
+ }) => {
71360
+ const listRows = useContext(ListRowsContext);
71361
+ const groupId = useContext(ListGroupContext);
71362
+ const rowId = useId();
71363
+ listRows.draw(rowId, {
71364
+ ownerId: row.ownerId,
71365
+ place: row.index,
71366
+ groupId,
71367
+ data: SKELETON_ROW_DATA
71368
+ });
71369
+ useLayoutEffect(() => {
71370
+ return () => {
71371
+ listRows.erase(rowId);
71372
+ };
71373
+ }, []);
71374
+ const rowVnode = jsx(ListRowContext.Provider, {
71375
+ value: row,
71376
+ children: children
71377
+ });
71378
+ if (!separator || listRows.isFirst(rowId)) {
71379
+ return rowVnode;
71380
+ }
71381
+ return jsxs(Fragment, {
71382
+ children: [resolveSeparatorVnode(separator, row.index - 1), rowVnode]
71383
+ });
71384
+ };
71385
+
71488
71386
  // What is drawn where rows were asked for and never came: the sentence and the
71489
71387
  // way out, in the row itself — the rest of the list is fine, so replacing all
71490
71388
  // of it (List's own `error`) would be a lie.
@@ -71639,7 +71537,7 @@ const useItemStore = ({
71639
71537
  // where the hole is, and cleared by a retry — which is what makes the same
71640
71538
  // range askable again (see the request memory just above).
71641
71539
  const [failure, setFailure] = useState(null);
71642
- const virtual = useContext(ListVirtualContext);
71540
+ const listRows = useContext(ListRowsContext);
71643
71541
  // The rows are there, which is what the list waits for to place itself on the
71644
71542
  // row it is held at (see placeWhereHeld). Said from an effect: a signal read
71645
71543
  // during this very render must not be written during it.
@@ -71648,12 +71546,12 @@ const useItemStore = ({
71648
71546
  return;
71649
71547
  }
71650
71548
  itemsHeldRef.current = true;
71651
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
71549
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
71652
71550
  });
71653
71551
  // Before the first answer a run does not know how many rows it stands for.
71654
71552
  // It stands for a windowful of them: a list that is about to be filled looks
71655
71553
  // like rows on their way, not like an empty list.
71656
- const rowCount = pages.count ?? count ?? virtual.renderBudget;
71554
+ const rowCount = pages.count ?? count ?? listRows.renderBudget;
71657
71555
  // A run that never received anything has nothing to keep on screen: asking
71658
71556
  // again is its first ask, not a refresh.
71659
71557
  if (staleRef.current && pages.count === undefined) {
@@ -71663,9 +71561,9 @@ const useItemStore = ({
71663
71561
  if (!refreshing) {
71664
71562
  return null;
71665
71563
  }
71666
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() + 1;
71564
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() + 1;
71667
71565
  return () => {
71668
- virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
71566
+ listRows.refreshingSignal.value = listRows.refreshingSignal.peek() - 1;
71669
71567
  };
71670
71568
  }, [refreshing]);
71671
71569
 
@@ -71764,7 +71662,7 @@ const useItemStore = ({
71764
71662
  // how many rows there are, so it asks for the rows the list would open
71765
71663
  // on — counting back from the end when that is where it opens, the way
71766
71664
  // an HTTP range does.
71767
- const budget = virtual.renderBudget;
71665
+ const budget = listRows.renderBudget;
71768
71666
  let start = missingStart;
71769
71667
  let end = missingEnd;
71770
71668
  let around;
@@ -71775,11 +71673,11 @@ const useItemStore = ({
71775
71673
  // The list is held on a row nothing on screen leads to: the rows it holds
71776
71674
  // do not contain it, so no window it could draw will ever bring it. Only
71777
71675
  // asking for it by name does.
71778
- const wanted = virtual.scrolled;
71676
+ const wanted = listRows.scrolled;
71779
71677
  const askingAroundWantedRow = revalidating &&
71780
71678
  // Only while the hold stands: once the user has taken the list over,
71781
71679
  // the reading position is where they are, not where it opened.
71782
- virtual.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && virtual.locateRow(wanted.id) === null;
71680
+ listRows.holdPending && wanted && typeof wanted === "object" && wanted.id !== undefined && listRows.locateRow(wanted.id) === null;
71783
71681
  if (askingAroundWantedRow) {
71784
71682
  around = wanted.id;
71785
71683
  // Where it stood when it was written down is enough to frame the ask;
@@ -71802,7 +71700,7 @@ const useItemStore = ({
71802
71700
  around = firstHeld.id;
71803
71701
  }
71804
71702
  } else if (pages.count === undefined) {
71805
- const scrolled = virtual.scrolled;
71703
+ const scrolled = listRows.scrolled;
71806
71704
  if (scrolled === "end") {
71807
71705
  // Counting back from the end, the way an HTTP range does: a list
71808
71706
  // opening on its last rows asks for them before it knows how many
@@ -71833,7 +71731,7 @@ const useItemStore = ({
71833
71731
  // way somewhere the window does not frame yet, `count` that it knows
71834
71732
  // how many rows it stands for.
71835
71733
  const debugAsk = outcome => {
71836
- debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${virtual.holdPending} count=${pages.count})`);
71734
+ debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${listRows.holdPending} count=${pages.count})`);
71837
71735
  };
71838
71736
  if (start === -1) {
71839
71737
  // Nothing missing and nothing to revalidate: the run has what it
@@ -71841,7 +71739,7 @@ const useItemStore = ({
71841
71739
  debugAsk("nothing missing");
71842
71740
  return;
71843
71741
  }
71844
- if (virtual.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
71742
+ if (listRows.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
71845
71743
  // The one ask a hold lets through: the row the list is held on is
71846
71744
  // what would lift the hold, and nothing else is going to bring it.
71847
71745
  debugAsk("held on a row not reached yet");
@@ -71932,7 +71830,7 @@ const useItemStore = ({
71932
71830
  const pageCount = Array.isArray(page) ? pageItems.length : page.count ?? pageStart + pageItems.length;
71933
71831
  // Before the rows land: what is on screen has to stay where it is,
71934
71832
  // and the DOM still shows the state to hold onto.
71935
- virtual.captureAnchor();
71833
+ listRows.captureAnchor();
71936
71834
  if (revalidating) {
71937
71835
  // The rows held stood for a composition that has moved on; the
71938
71836
  // ones outside the window are forgotten and asked for again if the
@@ -71954,7 +71852,7 @@ const useItemStore = ({
71954
71852
  replace: revalidating
71955
71853
  });
71956
71854
  }
71957
- virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
71855
+ listRows.pagesSignal.value = listRows.pagesSignal.peek() + 1;
71958
71856
  setPageVersion(version => version + 1);
71959
71857
  };
71960
71858
  const failed = error => {
@@ -72022,10 +71920,16 @@ const ListItemGroup = ({
72022
71920
  ...rest
72023
71921
  }) => {
72024
71922
  const groupId = useId();
72025
- const groupTracker = useItemTracker();
71923
+ const listRows = useContext(ListRowsContext);
71924
+ const group = listRows.group(groupId);
71925
+ useLayoutEffect(() => {
71926
+ return () => {
71927
+ listRows.dropGroup(groupId);
71928
+ };
71929
+ }, []);
72026
71930
  const searchNoMatchMode = useContext(SearchNoMatchModeContext);
72027
- const groupItemCount = groupTracker.countSignal.value;
72028
- const groupNoMatchCount = groupTracker.noMatchCountSignal.value;
71931
+ const groupItemCount = group.countSignal.value;
71932
+ const groupNoMatchCount = group.noMatchCountSignal.value;
72029
71933
  // Every row of this group failed the search: the label has nothing left to
72030
71934
  // title. "remove" empties the group on its own (and hiddenWhileEmpty takes it
72031
71935
  // out of the flow), "muted" keeps the rows readable so the label stays useful
@@ -72069,8 +71973,8 @@ const ListItemGroup = ({
72069
71973
  className: "navi_list_item_group_list",
72070
71974
  role: "group",
72071
71975
  "aria-labelledby": groupId,
72072
- children: jsx(GroupItemTrackerContext.Provider, {
72073
- value: groupTracker,
71976
+ children: jsx(ListGroupContext.Provider, {
71977
+ value: groupId,
72074
71978
  children: jsx(ListDeclaredChildren, {
72075
71979
  children: children
72076
71980
  })
@@ -78032,414 +77936,812 @@ const SplitButton = props => {
78032
77936
  });
78033
77937
  };
78034
77938
 
78035
- // What the Picker's popup answers to — Picker's own popup props, named here so
78036
- // a caller reaches all of them through the split button (see picker.jsx's JSDoc
78037
- // for what each one says).
78038
- 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"]);
78039
- const splitPopupProps = props => {
78040
- const popupProps = {};
78041
- const boxProps = {};
78042
- for (const key of Object.keys(props)) {
78043
- if (POPUP_PROP_SET.has(key)) {
78044
- popupProps[key] = props[key];
77939
+ // What the Picker's popup answers to — Picker's own popup props, named here so
77940
+ // a caller reaches all of them through the split button (see picker.jsx's JSDoc
77941
+ // for what each one says).
77942
+ 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"]);
77943
+ const splitPopupProps = props => {
77944
+ const popupProps = {};
77945
+ const boxProps = {};
77946
+ for (const key of Object.keys(props)) {
77947
+ if (POPUP_PROP_SET.has(key)) {
77948
+ popupProps[key] = props[key];
77949
+ } else {
77950
+ boxProps[key] = props[key];
77951
+ }
77952
+ }
77953
+ return [popupProps, boxProps];
77954
+ };
77955
+
77956
+ /**
77957
+ * applySearch — matches value against searchText.
77958
+ *
77959
+ * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
77960
+ * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
77961
+ * Multi-word: if searchText contains spaces, each word must appear somewhere in
77962
+ * the value for it to match. Ranges for all words are returned.
77963
+ *
77964
+ * Score table:
77965
+ *
77966
+ * Situation Score
77967
+ * ─────────────────────────────────────── ───────────────────────────
77968
+ * phrase at start of value 1
77969
+ * multi-word, one word at start (all match) 0.75
77970
+ * phrase / word at word boundary 0.625
77971
+ * phrase / words mid-word 0.5
77972
+ * + case-exact bonus +0.125
77973
+ * multi-word partial: score × (matched/total)
77974
+ *
77975
+ * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
77976
+ * Intended to be passed to useSearch as the matchFn parameter.
77977
+ */
77978
+ const applySearch = (searchText, value) => {
77979
+ if (!searchText) {
77980
+ return { match: true, matchScore: 0, matchRanges: [] };
77981
+ }
77982
+ if (searchText.length > 100) {
77983
+ searchText = searchText.slice(0, 100);
77984
+ }
77985
+ const str = String(value);
77986
+ const foldedStr = foldAccents(str).toLowerCase();
77987
+ const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
77988
+
77989
+ // Try exact phrase match first (gives best score).
77990
+ const phraseRanges = [];
77991
+ let phraseIdx = foldedStr.indexOf(foldedSearch);
77992
+ while (phraseIdx !== -1) {
77993
+ phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
77994
+ phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
77995
+ }
77996
+ if (phraseRanges.length > 0) {
77997
+ const atStart = foldedStr.startsWith(foldedSearch);
77998
+ const atWordBoundary = phraseRanges.some(([start]) =>
77999
+ isWordBoundary(foldedStr, start),
78000
+ );
78001
+ const caseExact = str.includes(searchText);
78002
+ let baseScore;
78003
+ if (atStart) {
78004
+ baseScore = SCORE_PHRASE_AT_START;
78005
+ } else if (atWordBoundary) {
78006
+ baseScore = SCORE_AT_WORD_BOUNDARY;
78007
+ } else {
78008
+ baseScore = SCORE_MID_WORD;
78009
+ }
78010
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
78011
+ return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
78012
+ }
78013
+
78014
+ // Multi-word OR: split on whitespace, any word matching contributes to the score.
78015
+ // Items where all words match rank higher than partial matches.
78016
+ // Note: words always has at least 1 element here (searchText is non-empty and
78017
+ // foldedSearch.split filters empty strings). This path also handles the case
78018
+ // where searchText has trailing/leading spaces: the phrase match above tries
78019
+ // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
78020
+ // here to try each word individually (e.g. "tc" matches "tca").
78021
+ const matchRanges = [];
78022
+ let matchedWordCount = 0;
78023
+ let anyWordAtStart = false;
78024
+ let anyWordAtWordBoundary = false;
78025
+ let allMatchedWordsExact = true;
78026
+ for (let w = 0; w < words.length; w++) {
78027
+ const word = words[w];
78028
+ const originalWord = originalWords[w];
78029
+ let idx = foldedStr.indexOf(word);
78030
+ if (idx === -1) {
78031
+ continue;
78032
+ }
78033
+ matchedWordCount++;
78034
+ let wordHasExactMatch = false;
78035
+ while (idx !== -1) {
78036
+ matchRanges.push([idx, idx + word.length]);
78037
+ if (idx === 0) {
78038
+ anyWordAtStart = true;
78039
+ anyWordAtWordBoundary = true;
78040
+ } else if (isWordBoundary(foldedStr, idx)) {
78041
+ anyWordAtWordBoundary = true;
78042
+ }
78043
+ if (str.slice(idx, idx + word.length) === originalWord) {
78044
+ wordHasExactMatch = true;
78045
+ }
78046
+ idx = foldedStr.indexOf(word, idx + 1);
78047
+ }
78048
+ if (!wordHasExactMatch) {
78049
+ allMatchedWordsExact = false;
78050
+ }
78051
+ }
78052
+ if (matchedWordCount === 0) {
78053
+ return tryAcronymMatch(foldedStr, str, searchText);
78054
+ }
78055
+ const wordRatio = matchedWordCount / words.length;
78056
+ let baseScore;
78057
+ if (anyWordAtStart) {
78058
+ baseScore = SCORE_MULTI_WORD_AT_START;
78059
+ } else if (anyWordAtWordBoundary) {
78060
+ baseScore = SCORE_AT_WORD_BOUNDARY;
78061
+ } else {
78062
+ baseScore = SCORE_MID_WORD;
78063
+ }
78064
+ const matchScore =
78065
+ (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
78066
+ wordRatio;
78067
+ return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
78068
+ };
78069
+
78070
+ // Returns true when position idx in str is at a word boundary,
78071
+ // meaning it is either the start of the string or the preceding character
78072
+ // is not a Unicode letter or digit.
78073
+ const isWordBoundary = (str, idx) => {
78074
+ if (idx === 0) {
78075
+ return true;
78076
+ }
78077
+ return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
78078
+ };
78079
+
78080
+ // Strip diacritics for accent-insensitive matching.
78081
+ // NFC normalization first ensures precomposed characters (é → single code unit),
78082
+ // so the folded string has the same length as the NFC source — ranges computed
78083
+ // on the folded string map 1:1 to positions in the original string.
78084
+ const foldAccents = (str) => {
78085
+ return str
78086
+ .normalize("NFC")
78087
+ .normalize("NFD")
78088
+ .replace(/\p{Mn}/gu, "");
78089
+ };
78090
+
78091
+ const SCORE_PHRASE_AT_START = 1;
78092
+ const SCORE_MULTI_WORD_AT_START = 0.75;
78093
+ const SCORE_AT_WORD_BOUNDARY = 0.625;
78094
+ const SCORE_MID_WORD = 0.5;
78095
+ const SCORE_ACRONYM = 0.4;
78096
+ const SCORE_BONUS_CASE_EXACT = 0.125;
78097
+
78098
+ // Acronym match: each char of searchText (spaces stripped) must be the first
78099
+ // letter of a word in value, in order (greedy subsequence on word-starts).
78100
+ // e.g. "TC" matches "Total Count" highlighting the T and C.
78101
+ const tryAcronymMatch = (foldedStr, str, searchText) => {
78102
+ const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
78103
+ if (acronymChars.length < 2) {
78104
+ // Single-char acronym is too ambiguous — skip.
78105
+ return { match: false, matchScore: 0, matchRanges: [] };
78106
+ }
78107
+ const wordStarts = [];
78108
+ for (let i = 0; i < foldedStr.length; i++) {
78109
+ if (isWordBoundary(foldedStr, i)) {
78110
+ wordStarts.push(i);
78111
+ }
78112
+ }
78113
+ const matchedPositions = [];
78114
+ let wordIdx = 0;
78115
+ const originalAcronym = searchText.replace(/\s/g, "");
78116
+ for (let si = 0; si < acronymChars.length; si++) {
78117
+ const ch = acronymChars[si];
78118
+ let found = false;
78119
+ while (wordIdx < wordStarts.length) {
78120
+ const pos = wordStarts[wordIdx];
78121
+ wordIdx++;
78122
+ if (foldedStr[pos] === ch) {
78123
+ matchedPositions.push(pos);
78124
+ found = true;
78125
+ break;
78126
+ }
78127
+ }
78128
+ if (!found) {
78129
+ return { match: false, matchScore: 0, matchRanges: [] };
78130
+ }
78131
+ }
78132
+ const atStart = matchedPositions[0] === 0;
78133
+ const caseExact = matchedPositions.every(
78134
+ (p, i) => str[p] === originalAcronym[i],
78135
+ );
78136
+ const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
78137
+ const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
78138
+ const matchRanges = matchedPositions.map((p) => [p, p + 1]);
78139
+ return { match: true, matchScore, matchRanges };
78140
+ };
78141
+
78142
+ // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
78143
+ // for the same searchText across all items in a list render.
78144
+ const searchCache = new Map();
78145
+ const SEARCH_CACHE_MAX_SIZE = 20;
78146
+ const getSearchInfo = (searchText) => {
78147
+ if (searchCache.has(searchText)) {
78148
+ const cached = searchCache.get(searchText);
78149
+ searchCache.delete(searchText);
78150
+ searchCache.set(searchText, cached);
78151
+ return cached;
78152
+ }
78153
+ const foldedSearch = foldAccents(searchText).toLowerCase();
78154
+ const words = foldedSearch.split(/\s+/).filter(Boolean);
78155
+ const originalWords = searchText.split(/\s+/).filter(Boolean);
78156
+ const info = { foldedSearch, words, originalWords };
78157
+ searchCache.set(searchText, info);
78158
+ if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
78159
+ searchCache.delete(searchCache.keys().next().value);
78160
+ }
78161
+ return info;
78162
+ };
78163
+
78164
+ // Merge overlapping or adjacent [start, end] ranges (sorted by start).
78165
+ const mergeRanges = (ranges) => {
78166
+ if (ranges.length < 2) {
78167
+ return ranges;
78168
+ }
78169
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
78170
+ const merged = [sorted[0]];
78171
+ for (let i = 1; i < sorted.length; i++) {
78172
+ const last = merged[merged.length - 1];
78173
+ const current = sorted[i];
78174
+ if (current[0] <= last[1]) {
78175
+ if (current[1] > last[1]) {
78176
+ last[1] = current[1];
78177
+ }
78178
+ } else {
78179
+ merged.push(current);
78180
+ }
78181
+ }
78182
+ return merged;
78183
+ };
78184
+
78185
+ /**
78186
+ * createSearch — builds a matchFn compatible with useSearch that searches
78187
+ * across multiple named fields of an item, each with its own DOM selector
78188
+ * and optional priority weight.
78189
+ *
78190
+ * Usage:
78191
+ * ```js
78192
+ * const searchPerson = createSearch({
78193
+ * name: {
78194
+ * getter: (item) => item.name,
78195
+ * domSelector: ".name",
78196
+ * },
78197
+ * address: {
78198
+ * getter: (item) => item.address,
78199
+ * domSelector: ".address",
78200
+ * priority: 1.5,
78201
+ * },
78202
+ * });
78203
+ *
78204
+ * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
78205
+ * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
78206
+ * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
78207
+ * // — ListItem handles the per-selector object format for matchRanges.
78208
+ * ```
78209
+ *
78210
+ * Each field config:
78211
+ * - getter(item): string — extracts the text to search
78212
+ * - domSelector: string — CSS selector used by ListItem to find the target element
78213
+ * - priority?: number — multiplier applied to the field's score (default 1)
78214
+ * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
78215
+ * defaults to applySearch
78216
+ */
78217
+ const createSearch = (fields) => {
78218
+ return (searchText, item) => {
78219
+ if (!searchText) {
78220
+ return { match: true, matchScore: 0, matchRanges: {} };
78221
+ }
78222
+ let totalScore = 0;
78223
+ const matchRanges = {};
78224
+ for (const [
78225
+ ,
78226
+ { getter, domSelector, priority = 1, matchFn = applySearch },
78227
+ ] of Object.entries(fields)) {
78228
+ const fieldValue = getter(item);
78229
+ const result = matchFn(searchText, fieldValue);
78230
+ if (result.match && result.matchRanges.length > 0) {
78231
+ totalScore += result.matchScore * priority;
78232
+ matchRanges[domSelector] = result.matchRanges;
78233
+ }
78234
+ }
78235
+ if (totalScore === 0) {
78236
+ return { match: false, matchScore: 0, matchRanges: {} };
78237
+ }
78238
+ return { match: true, matchScore: totalScore, matchRanges };
78239
+ };
78240
+ };
78241
+
78242
+ /**
78243
+ * useSearch — reorders items so matched ones come first (sorted by score desc),
78244
+ * followed by non-matched items in their natural order. No item is hidden.
78245
+ * Returns [orderedItems, getItemMatchInfo].
78246
+ * - orderedItems: all items, reordered
78247
+ * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
78248
+ * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
78249
+ * there is no need to destructure the three fields by hand.
78250
+ *
78251
+ * When searchText is empty, natural order is preserved and all items match with score 0.
78252
+ *
78253
+ * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
78254
+ * to each ListItem. The list's matchFallback will be shown when all items are hidden.
78255
+ */
78256
+ const useSearchText = (searchText, items, matchFn = applySearch) => {
78257
+ if (typeof searchText !== "string" && searchText !== undefined) {
78258
+ throw new TypeError(
78259
+ "useSearchText: searchText must be a string or undefined",
78260
+ );
78261
+ }
78262
+ if (items === undefined) {
78263
+ throw new TypeError("useSearch: items is undefined");
78264
+ }
78265
+ const { orderedItems, matchInfoMap } = useMemo(() => {
78266
+ const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
78267
+ searchText,
78268
+ items,
78269
+ matchFn,
78270
+ );
78271
+ const orderedItems = [];
78272
+ for (const [, bucket] of scoreEntries) {
78273
+ for (const { item } of bucket) {
78274
+ orderedItems.push(item);
78275
+ }
78276
+ }
78277
+ for (const { item } of nonMatched) {
78278
+ orderedItems.push(item);
78279
+ }
78280
+ return { orderedItems, matchInfoMap };
78281
+ }, [items, searchText, matchFn]);
78282
+
78283
+ // The same function for as long as the map is the same: a `renderItem`
78284
+ // reading it is stable only if this is, and a run keeps the rows it drew
78285
+ // only for a stable `renderItem` (see List.Items).
78286
+ const getItemMatchInfo = useCallback(
78287
+ (item) => matchInfoMap.get(item),
78288
+ [matchInfoMap],
78289
+ );
78290
+
78291
+ return [orderedItems, getItemMatchInfo];
78292
+ };
78293
+
78294
+ const buildMatchInfo = (searchText, items, matchFn) => {
78295
+ // scoreEntries: [score, bucket][] kept sorted desc by score.
78296
+ // New distinct score values are inserted via bisect — O(1) in practice
78297
+ // since there are very few distinct scores (today just 0 and 1).
78298
+ const scoreEntries = []; // [score, bucket][]
78299
+ const nonMatched = [];
78300
+
78301
+ for (const item of items) {
78302
+ const result = matchFn(searchText, item);
78303
+ if (!result.match) {
78304
+ nonMatched.push({
78305
+ item,
78306
+ matchScore: result.matchScore,
78307
+ matchRanges: result.matchRanges,
78308
+ });
78309
+ continue;
78310
+ }
78311
+ const score = result.matchScore;
78312
+ // Find existing bucket or insert a new entry in desc order.
78313
+ let lo = 0;
78314
+ let hi = scoreEntries.length;
78315
+ while (lo < hi) {
78316
+ const mid = (lo + hi) >> 1;
78317
+ if (scoreEntries[mid][0] > score) {
78318
+ lo = mid + 1;
78319
+ } else if (scoreEntries[mid][0] < score) {
78320
+ hi = mid;
78321
+ } else {
78322
+ lo = mid;
78323
+ hi = mid; // exact match — found the bucket
78324
+ }
78325
+ }
78326
+ if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
78327
+ scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
78045
78328
  } else {
78046
- boxProps[key] = props[key];
78329
+ scoreEntries.splice(lo, 0, [
78330
+ score,
78331
+ [{ item, matchRanges: result.matchRanges }],
78332
+ ]);
78047
78333
  }
78048
78334
  }
78049
- return [popupProps, boxProps];
78335
+
78336
+ const matchInfoMap = new Map();
78337
+ for (const [score, bucket] of scoreEntries) {
78338
+ for (const { item, matchRanges } of bucket) {
78339
+ matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
78340
+ }
78341
+ }
78342
+ for (const { item, matchScore, matchRanges } of nonMatched) {
78343
+ matchInfoMap.set(item, { match: false, matchScore, matchRanges });
78344
+ }
78345
+
78346
+ return { scoreEntries, nonMatched, matchInfoMap };
78050
78347
  };
78051
78348
 
78052
- /**
78053
- * applySearchmatches value against searchText.
78349
+ /*
78350
+ * useItemTracker()hook that creates a stable item tracker for the lifetime
78351
+ * of the host component.
78054
78352
  *
78055
- * Accent-insensitive: "gue" matches "Guérin", "e" matches "é".
78056
- * Case-insensitive: "bob" matches "Bob", with a score bonus for case-exact matches.
78057
- * Multi-word: if searchText contains spaces, each word must appear somewhere in
78058
- * the value for it to match. Ranges for all words are returned.
78353
+ * USAGE:
78354
+ * ```jsx
78355
+ * function ListControlled({ items }) {
78356
+ * const tracker = useItemTracker({
78357
+ * onChange: () => console.log("items changed"),
78358
+ * });
78059
78359
  *
78060
- * Score table:
78360
+ * return (
78361
+ * <ul>
78362
+ * {items.map((item, i) => (
78363
+ * <Row key={item.id} id={item.id} index={i} hidden={item.hidden} value={item.value} tracker={tracker} />
78364
+ * ))}
78365
+ * <Count tracker={tracker} />
78366
+ * </ul>
78367
+ * );
78368
+ * }
78061
78369
  *
78062
- * Situation Score
78063
- * ─────────────────────────────────────── ───────────────────────────
78064
- * phrase at start of value 1
78065
- * multi-word, one word at start (all match) 0.75
78066
- * phrase / word at word boundary 0.625
78067
- * phrase / words mid-word 0.5
78068
- * + case-exact bonus +0.125
78069
- * multi-word partial: score × (matched/total)
78370
+ * function Row({ id, index, hidden, value, tracker }) {
78371
+ * const visibleIndex = tracker.useTrackItem({ id, index, hidden, value });
78372
+ * if (visibleIndex === -1) return null;
78373
+ * return <li>{value}</li>;
78374
+ * }
78070
78375
  *
78071
- * matchRanges: [start, end] pairs (exclusive end) for CSS Highlight API.
78072
- * Intended to be passed to useSearch as the matchFn parameter.
78376
+ * function Count({ tracker }) {
78377
+ * const count = tracker.visibleCountSignal.value; // re-renders only when count changes
78378
+ * return <span>{count} items</span>;
78379
+ * }
78380
+ * ```
78381
+ *
78382
+ * INTERNALS:
78383
+ * - registrations: Map key → data, contains only visible items
78384
+ * - idToKey: Map id → key, stable across renders
78385
+ * - orderedKeys: number[] of visible item keys sorted by explicit order
78386
+ * - keyToOrderedIndex: Map key → orderedKeys index, gives O(1) indexOf equivalent
78387
+ * - keyToExplicitOrder: Map key → explicitly passed index, used to maintain sort order
78388
+ * - allItemsSignal: signal(array), all items including hidden, ordered by explicit index
78389
+ * - visibleItemsSignal: signal(array), non-hidden items only
78390
+ * - countSignal: signal(number), count of all items including hidden
78391
+ * - visibleCountSignal: signal(number), updated in microtask batch, only when count changes
78392
+ * - propSignals: Map propName → signal(array), updated in microtask batch with element equality
78393
+ * - onChangeRef: holds the latest onChange callback, called once per microtask batch
78394
+ *
78395
+ * useTrackItem(id, data, index): registers the item with an explicitly provided index
78396
+ * that determines its position among siblings. The caller (e.g. items.map) knows the
78397
+ * correct order and passes it directly — no render-sequence deduction needed.
78398
+ * Returns the visible rank (position among non-hidden items), or -1 when hidden.
78399
+ * Signals and onChange are deferred to a microtask so multiple items updating
78400
+ * in one commit cause only one notification.
78401
+ *
78402
+ * getTrackedItemByIndex(index): synchronous O(1) lookup of a visible item by
78403
+ * its visible rank. Returns undefined when index is out of range.
78404
+ *
78405
+ * peekItems(): the items as they stand right now, without waiting for the
78406
+ * deferred notification — what a sibling rendering after the items must read
78407
+ * to paint them in the same commit.
78073
78408
  */
78074
- const applySearch = (searchText, value) => {
78075
- if (!searchText) {
78076
- return { match: true, matchScore: 0, matchRanges: [] };
78077
- }
78078
- if (searchText.length > 100) {
78079
- searchText = searchText.slice(0, 100);
78080
- }
78081
- const str = String(value);
78082
- const foldedStr = foldAccents(str).toLowerCase();
78083
- const { foldedSearch, words, originalWords } = getSearchInfo(searchText);
78084
78409
 
78085
- // Try exact phrase match first (gives best score).
78086
- const phraseRanges = [];
78087
- let phraseIdx = foldedStr.indexOf(foldedSearch);
78088
- while (phraseIdx !== -1) {
78089
- phraseRanges.push([phraseIdx, phraseIdx + foldedSearch.length]);
78090
- phraseIdx = foldedStr.indexOf(foldedSearch, phraseIdx + 1);
78091
- }
78092
- if (phraseRanges.length > 0) {
78093
- const atStart = foldedStr.startsWith(foldedSearch);
78094
- const atWordBoundary = phraseRanges.some(([start]) =>
78095
- isWordBoundary(foldedStr, start),
78096
- );
78097
- const caseExact = str.includes(searchText);
78098
- let baseScore;
78099
- if (atStart) {
78100
- baseScore = SCORE_PHRASE_AT_START;
78101
- } else if (atWordBoundary) {
78102
- baseScore = SCORE_AT_WORD_BOUNDARY;
78103
- } else {
78104
- baseScore = SCORE_MID_WORD;
78105
- }
78106
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
78107
- return { match: true, matchScore, matchRanges: mergeRanges(phraseRanges) };
78410
+ const useItemTracker = ({ onChange } = {}) => {
78411
+ const onChangeRef = useRef(onChange);
78412
+ onChangeRef.current = onChange;
78413
+ const trackerRef = useRef(null);
78414
+ let tracker = trackerRef.current;
78415
+ if (!tracker) {
78416
+ trackerRef.current = tracker = createItemTracker((items) => {
78417
+ onChangeRef.current?.(items);
78418
+ });
78108
78419
  }
78420
+ // When code in useLayoutEffect of the caller wants to run the tracker must be in sync
78421
+ // without this layout effect the tracker might not have been synced yet and preact would call layout effect
78422
+ // before we had time to sync
78423
+ useLayoutEffect(() => {
78424
+ tracker._flushSync();
78425
+ });
78426
+ return tracker;
78427
+ };
78428
+
78429
+ const createItemTracker = (onChange) => {
78430
+ const registrations = new Map(); // key → data (visible items only)
78431
+ const idToKey = new Map(); // id → insertion key (stable, auto-incremented)
78432
+ let keyCounter = 0;
78433
+ // orderedKeys: visible item keys sorted by their explicitly provided index.
78434
+ const orderedKeys = []; // number[]
78435
+ // keyToOrderedIndex: O(1) equivalent of orderedKeys.indexOf(key).
78436
+ const keyToOrderedIndex = new Map(); // key → index in orderedKeys
78437
+ const allKeys = new Set(); // all registered keys including hidden
78438
+ const keyToExplicitOrder = new Map(); // key → explicitly passed index
78439
+
78440
+ const allRegistrations = new Map(); // key → data (all items including hidden)
78441
+ const allOrderedKeys = []; // all item keys sorted by explicit order
78442
+ const keyToAllOrderedIndex = new Map(); // key → index in allOrderedKeys
78443
+
78444
+ const itemsSignal = signal([]);
78445
+ const visibleItemsSignal = signal([]);
78446
+ const countSignal = signal(0);
78447
+ const visibleCountSignal = signal(0);
78448
+ const noMatchCountSignal = signal(0);
78449
+
78450
+ let notifyScheduled = false;
78451
+ const runNotify = () => {
78452
+ batch(() => {
78453
+ let someChange = false;
78454
+
78455
+ const newCount = allKeys.size;
78456
+ const countModified = countSignal.peek() !== newCount;
78457
+ if (countModified) {
78458
+ countSignal.value = newCount;
78459
+ someChange = true;
78460
+ }
78461
+
78462
+ // Build allItems and visibleItems in a single pass over allOrderedKeys.
78463
+ // Visible items are those without data.hidden or data.filtered — same
78464
+ // relative order as orderedKeys (syncItem already excludes both from
78465
+ // orderedKeys; this must match or consumers relying on visibleCountSignal
78466
+ // would count filtered-out items as if they still took up space).
78467
+ const prevAllItems = itemsSignal.peek();
78468
+ const prevVisibleItems = visibleItemsSignal.peek();
78469
+ let allItemsChanged = prevAllItems.length !== allOrderedKeys.length;
78470
+ let visibleItemsChanged = false;
78471
+ const allItems = [];
78472
+ const visibleItems = [];
78473
+ let newNoMatchCount = 0;
78474
+ for (let i = 0; i < allOrderedKeys.length; i++) {
78475
+ const key = allOrderedKeys[i];
78476
+ const item = allRegistrations.get(key);
78477
+ allItems.push(item);
78478
+ // Compare by reference: catches any prop change (id, selected, disabled, …)
78479
+ if (!allItemsChanged && item !== prevAllItems[i]) {
78480
+ allItemsChanged = true;
78481
+ }
78482
+ if (item.match === false) {
78483
+ newNoMatchCount++;
78484
+ }
78485
+ if (!item.hidden && !item.filtered) {
78486
+ const visibleIdx = visibleItems.length;
78487
+ visibleItems.push(item);
78488
+ if (!visibleItemsChanged && item !== prevVisibleItems[visibleIdx]) {
78489
+ visibleItemsChanged = true;
78490
+ }
78491
+ }
78492
+ }
78493
+
78494
+ const newVisibleCount = visibleItems.length;
78495
+ const visibleCountModified =
78496
+ visibleCountSignal.peek() !== newVisibleCount;
78497
+ if (visibleCountModified) {
78498
+ visibleCountSignal.value = newVisibleCount;
78499
+ someChange = true;
78500
+ }
78501
+ if (allItemsChanged) {
78502
+ itemsSignal.value = allItems;
78503
+ someChange = true;
78504
+ }
78505
+ if (visibleItemsChanged) {
78506
+ visibleItemsSignal.value = visibleItems;
78507
+ someChange = true;
78508
+ }
78509
+ const noMatchCountModified =
78510
+ noMatchCountSignal.peek() !== newNoMatchCount;
78511
+ if (noMatchCountModified) {
78512
+ noMatchCountSignal.value = newNoMatchCount;
78513
+ someChange = true;
78514
+ }
78515
+ if (someChange) {
78516
+ onChange?.();
78517
+ }
78518
+ });
78519
+ };
78109
78520
 
78110
- // Multi-word OR: split on whitespace, any word matching contributes to the score.
78111
- // Items where all words match rank higher than partial matches.
78112
- // Note: words always has at least 1 element here (searchText is non-empty and
78113
- // foldedSearch.split filters empty strings). This path also handles the case
78114
- // where searchText has trailing/leading spaces: the phrase match above tries
78115
- // the literal (e.g. "tc " in "tc adapter"), and if that fails we fall through
78116
- // here to try each word individually (e.g. "tc" matches "tca").
78117
- const matchRanges = [];
78118
- let matchedWordCount = 0;
78119
- let anyWordAtStart = false;
78120
- let anyWordAtWordBoundary = false;
78121
- let allMatchedWordsExact = true;
78122
- for (let w = 0; w < words.length; w++) {
78123
- const word = words[w];
78124
- const originalWord = originalWords[w];
78125
- let idx = foldedStr.indexOf(word);
78126
- if (idx === -1) {
78127
- continue;
78521
+ const notify = () => {
78522
+ if (notifyScheduled) {
78523
+ return;
78128
78524
  }
78129
- matchedWordCount++;
78130
- let wordHasExactMatch = false;
78131
- while (idx !== -1) {
78132
- matchRanges.push([idx, idx + word.length]);
78133
- if (idx === 0) {
78134
- anyWordAtStart = true;
78135
- anyWordAtWordBoundary = true;
78136
- } else if (isWordBoundary(foldedStr, idx)) {
78137
- anyWordAtWordBoundary = true;
78525
+ notifyScheduled = true;
78526
+ queueMicrotask(() => {
78527
+ if (!notifyScheduled) {
78528
+ return; // was already flushed synchronously
78138
78529
  }
78139
- if (str.slice(idx, idx + word.length) === originalWord) {
78140
- wordHasExactMatch = true;
78530
+ notifyScheduled = false;
78531
+ runNotify();
78532
+ });
78533
+ };
78534
+
78535
+ const _flushSync = () => {
78536
+ if (!notifyScheduled) {
78537
+ return;
78538
+ }
78539
+ notifyScheduled = false;
78540
+ runNotify();
78541
+ };
78542
+
78543
+ // Insert key into orderedKeys at the correct position based on explicitOrder.
78544
+ // Uses binary search for O(log n) insertion.
78545
+ const insertKey = (key, explicitOrder) => {
78546
+ let lo = 0;
78547
+ let hi = orderedKeys.length;
78548
+ while (lo < hi) {
78549
+ const mid = (lo + hi) >> 1;
78550
+ if (keyToExplicitOrder.get(orderedKeys[mid]) <= explicitOrder) {
78551
+ lo = mid + 1;
78552
+ } else {
78553
+ hi = mid;
78141
78554
  }
78142
- idx = foldedStr.indexOf(word, idx + 1);
78143
78555
  }
78144
- if (!wordHasExactMatch) {
78145
- allMatchedWordsExact = false;
78556
+ orderedKeys.splice(lo, 0, key);
78557
+ for (let i = lo; i < orderedKeys.length; i++) {
78558
+ keyToOrderedIndex.set(orderedKeys[i], i);
78146
78559
  }
78147
- }
78148
- if (matchedWordCount === 0) {
78149
- return tryAcronymMatch(foldedStr, str, searchText);
78150
- }
78151
- const wordRatio = matchedWordCount / words.length;
78152
- let baseScore;
78153
- if (anyWordAtStart) {
78154
- baseScore = SCORE_MULTI_WORD_AT_START;
78155
- } else if (anyWordAtWordBoundary) {
78156
- baseScore = SCORE_AT_WORD_BOUNDARY;
78157
- } else {
78158
- baseScore = SCORE_MID_WORD;
78159
- }
78160
- const matchScore =
78161
- (baseScore + (allMatchedWordsExact ? SCORE_BONUS_CASE_EXACT : 0)) *
78162
- wordRatio;
78163
- return { match: true, matchScore, matchRanges: mergeRanges(matchRanges) };
78164
- };
78165
-
78166
- // Returns true when position idx in str is at a word boundary,
78167
- // meaning it is either the start of the string or the preceding character
78168
- // is not a Unicode letter or digit.
78169
- const isWordBoundary = (str, idx) => {
78170
- if (idx === 0) {
78171
- return true;
78172
- }
78173
- return !/[\p{L}\p{N}]/u.test(str[idx - 1]);
78174
- };
78175
-
78176
- // Strip diacritics for accent-insensitive matching.
78177
- // NFC normalization first ensures precomposed characters (é → single code unit),
78178
- // so the folded string has the same length as the NFC source — ranges computed
78179
- // on the folded string map 1:1 to positions in the original string.
78180
- const foldAccents = (str) => {
78181
- return str
78182
- .normalize("NFC")
78183
- .normalize("NFD")
78184
- .replace(/\p{Mn}/gu, "");
78185
- };
78186
-
78187
- const SCORE_PHRASE_AT_START = 1;
78188
- const SCORE_MULTI_WORD_AT_START = 0.75;
78189
- const SCORE_AT_WORD_BOUNDARY = 0.625;
78190
- const SCORE_MID_WORD = 0.5;
78191
- const SCORE_ACRONYM = 0.4;
78192
- const SCORE_BONUS_CASE_EXACT = 0.125;
78560
+ };
78193
78561
 
78194
- // Acronym match: each char of searchText (spaces stripped) must be the first
78195
- // letter of a word in value, in order (greedy subsequence on word-starts).
78196
- // e.g. "TC" matches "Total Count" highlighting the T and C.
78197
- const tryAcronymMatch = (foldedStr, str, searchText) => {
78198
- const acronymChars = foldAccents(searchText).toLowerCase().replace(/\s/g, "");
78199
- if (acronymChars.length < 2) {
78200
- // Single-char acronym is too ambiguous — skip.
78201
- return { match: false, matchScore: 0, matchRanges: [] };
78202
- }
78203
- const wordStarts = [];
78204
- for (let i = 0; i < foldedStr.length; i++) {
78205
- if (isWordBoundary(foldedStr, i)) {
78206
- wordStarts.push(i);
78207
- }
78208
- }
78209
- const matchedPositions = [];
78210
- let wordIdx = 0;
78211
- const originalAcronym = searchText.replace(/\s/g, "");
78212
- for (let si = 0; si < acronymChars.length; si++) {
78213
- const ch = acronymChars[si];
78214
- let found = false;
78215
- while (wordIdx < wordStarts.length) {
78216
- const pos = wordStarts[wordIdx];
78217
- wordIdx++;
78218
- if (foldedStr[pos] === ch) {
78219
- matchedPositions.push(pos);
78220
- found = true;
78221
- break;
78562
+ const insertAllKey = (key, explicitOrder) => {
78563
+ let lo = 0;
78564
+ let hi = allOrderedKeys.length;
78565
+ while (lo < hi) {
78566
+ const mid = (lo + hi) >> 1;
78567
+ if (keyToExplicitOrder.get(allOrderedKeys[mid]) <= explicitOrder) {
78568
+ lo = mid + 1;
78569
+ } else {
78570
+ hi = mid;
78222
78571
  }
78223
78572
  }
78224
- if (!found) {
78225
- return { match: false, matchScore: 0, matchRanges: [] };
78573
+ allOrderedKeys.splice(lo, 0, key);
78574
+ for (let i = lo; i < allOrderedKeys.length; i++) {
78575
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
78226
78576
  }
78227
- }
78228
- const atStart = matchedPositions[0] === 0;
78229
- const caseExact = matchedPositions.every(
78230
- (p, i) => str[p] === originalAcronym[i],
78231
- );
78232
- const baseScore = atStart ? SCORE_ACRONYM + 0.05 : SCORE_ACRONYM;
78233
- const matchScore = baseScore + (caseExact ? SCORE_BONUS_CASE_EXACT : 0);
78234
- const matchRanges = matchedPositions.map((p) => [p, p + 1]);
78235
- return { match: true, matchScore, matchRanges };
78236
- };
78577
+ };
78237
78578
 
78238
- // LRU cache for pre-computed search info, avoids recomputing foldAccents/toLowerCase
78239
- // for the same searchText across all items in a list render.
78240
- const searchCache = new Map();
78241
- const SEARCH_CACHE_MAX_SIZE = 20;
78242
- const getSearchInfo = (searchText) => {
78243
- if (searchCache.has(searchText)) {
78244
- const cached = searchCache.get(searchText);
78245
- searchCache.delete(searchText);
78246
- searchCache.set(searchText, cached);
78247
- return cached;
78248
- }
78249
- const foldedSearch = foldAccents(searchText).toLowerCase();
78250
- const words = foldedSearch.split(/\s+/).filter(Boolean);
78251
- const originalWords = searchText.split(/\s+/).filter(Boolean);
78252
- const info = { foldedSearch, words, originalWords };
78253
- searchCache.set(searchText, info);
78254
- if (searchCache.size > SEARCH_CACHE_MAX_SIZE) {
78255
- searchCache.delete(searchCache.keys().next().value);
78256
- }
78257
- return info;
78258
- };
78579
+ const removeAllKey = (key) => {
78580
+ const idx = keyToAllOrderedIndex.get(key);
78581
+ if (idx !== undefined) {
78582
+ allOrderedKeys.splice(idx, 1);
78583
+ keyToAllOrderedIndex.delete(key);
78584
+ for (let i = idx; i < allOrderedKeys.length; i++) {
78585
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
78586
+ }
78587
+ }
78588
+ };
78259
78589
 
78260
- // Merge overlapping or adjacent [start, end] ranges (sorted by start).
78261
- const mergeRanges = (ranges) => {
78262
- if (ranges.length < 2) {
78263
- return ranges;
78264
- }
78265
- const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
78266
- const merged = [sorted[0]];
78267
- for (let i = 1; i < sorted.length; i++) {
78268
- const last = merged[merged.length - 1];
78269
- const current = sorted[i];
78270
- if (current[0] <= last[1]) {
78271
- if (current[1] > last[1]) {
78272
- last[1] = current[1];
78590
+ // Register or update an item. data.hidden controls visibility.
78591
+ // explicitOrder is the caller-provided index that determines sort position.
78592
+ const syncItem = (key, index, data) => {
78593
+ if (data.role === "presentation") {
78594
+ registrations.delete(key);
78595
+ const idx = keyToOrderedIndex.get(key);
78596
+ if (idx !== undefined) {
78597
+ orderedKeys.splice(idx, 1);
78598
+ keyToOrderedIndex.delete(key);
78599
+ for (let i = idx; i < orderedKeys.length; i++) {
78600
+ keyToOrderedIndex.set(orderedKeys[i], i);
78601
+ }
78602
+ }
78603
+ keyToExplicitOrder.delete(key);
78604
+ allRegistrations.delete(key);
78605
+ removeAllKey(key);
78606
+ allKeys.delete(key);
78607
+ return;
78608
+ }
78609
+
78610
+ // Maintain allRegistrations and allOrderedKeys for all non-presentation items.
78611
+ allRegistrations.set(key, data);
78612
+ allKeys.add(key);
78613
+ const currentAllIdx = keyToAllOrderedIndex.get(key);
78614
+ const previousOrder = keyToExplicitOrder.get(key);
78615
+ keyToExplicitOrder.set(key, index);
78616
+ if (currentAllIdx === undefined) {
78617
+ insertAllKey(key, index);
78618
+ } else if (previousOrder !== index) {
78619
+ allOrderedKeys.splice(currentAllIdx, 1);
78620
+ keyToAllOrderedIndex.delete(key);
78621
+ for (let i = currentAllIdx; i < allOrderedKeys.length; i++) {
78622
+ keyToAllOrderedIndex.set(allOrderedKeys[i], i);
78273
78623
  }
78274
- } else {
78275
- merged.push(current);
78624
+ insertAllKey(key, index);
78276
78625
  }
78277
- }
78278
- return merged;
78279
- };
78280
78626
 
78281
- /**
78282
- * createSearch — builds a matchFn compatible with useSearch that searches
78283
- * across multiple named fields of an item, each with its own DOM selector
78284
- * and optional priority weight.
78285
- *
78286
- * Usage:
78287
- * ```js
78288
- * const searchPerson = createSearch({
78289
- * name: {
78290
- * getter: (item) => item.name,
78291
- * domSelector: ".name",
78292
- * },
78293
- * address: {
78294
- * getter: (item) => item.address,
78295
- * domSelector: ".address",
78296
- * priority: 1.5,
78297
- * },
78298
- * });
78299
- *
78300
- * const [orderedItems, getItemMatchInfo] = useSearch(search, items, searchPerson);
78301
- * // getItemMatchInfo(item).matchRanges is { ".name": [[start,end],…], ".address": [[start,end],…] }
78302
- * // Pass the whole thing: <ListItem matchInfo={getItemMatchInfo(item)} />
78303
- * // — ListItem handles the per-selector object format for matchRanges.
78304
- * ```
78305
- *
78306
- * Each field config:
78307
- * - getter(item): string — extracts the text to search
78308
- * - domSelector: string — CSS selector used by ListItem to find the target element
78309
- * - priority?: number — multiplier applied to the field's score (default 1)
78310
- * - matchFn?: function — custom match function (searchText, fieldValue) => { match, matchScore, matchRanges }
78311
- * defaults to applySearch
78312
- */
78313
- const createSearch = (fields) => {
78314
- return (searchText, item) => {
78315
- if (!searchText) {
78316
- return { match: true, matchScore: 0, matchRanges: {} };
78317
- }
78318
- let totalScore = 0;
78319
- const matchRanges = {};
78320
- for (const [
78321
- ,
78322
- { getter, domSelector, priority = 1, matchFn = applySearch },
78323
- ] of Object.entries(fields)) {
78324
- const fieldValue = getter(item);
78325
- const result = matchFn(searchText, fieldValue);
78326
- if (result.match && result.matchRanges.length > 0) {
78327
- totalScore += result.matchScore * priority;
78328
- matchRanges[domSelector] = result.matchRanges;
78627
+ if (data.filtered || data.hidden) {
78628
+ registrations.delete(key);
78629
+ const idx = keyToOrderedIndex.get(key);
78630
+ if (idx !== undefined) {
78631
+ orderedKeys.splice(idx, 1);
78632
+ keyToOrderedIndex.delete(key);
78633
+ for (let i = idx; i < orderedKeys.length; i++) {
78634
+ keyToOrderedIndex.set(orderedKeys[i], i);
78635
+ }
78329
78636
  }
78637
+ return;
78330
78638
  }
78331
- if (totalScore === 0) {
78332
- return { match: false, matchScore: 0, matchRanges: {} };
78639
+
78640
+ registrations.set(key, data);
78641
+ const currentIdx = keyToOrderedIndex.get(key);
78642
+ if (currentIdx === undefined) {
78643
+ insertKey(key, index);
78644
+ return;
78333
78645
  }
78334
- return { match: true, matchScore: totalScore, matchRanges };
78646
+ if (previousOrder === index) {
78647
+ return;
78648
+ }
78649
+ orderedKeys.splice(currentIdx, 1);
78650
+ keyToOrderedIndex.delete(key);
78651
+ for (let i = currentIdx; i < orderedKeys.length; i++) {
78652
+ keyToOrderedIndex.set(orderedKeys[i], i);
78653
+ }
78654
+ insertKey(key, index);
78335
78655
  };
78336
- };
78337
78656
 
78338
- /**
78339
- * useSearch — reorders items so matched ones come first (sorted by score desc),
78340
- * followed by non-matched items in their natural order. No item is hidden.
78341
- * Returns [orderedItems, getItemMatchInfo].
78342
- * - orderedItems: all items, reordered
78343
- * - getItemMatchInfo(item): { match, matchScore, matchRanges } — pass the
78344
- * whole thing straight to <ListItem matchInfo={getItemMatchInfo(item)} />,
78345
- * there is no need to destructure the three fields by hand.
78346
- *
78347
- * When searchText is empty, natural order is preserved and all items match with score 0.
78348
- *
78349
- * To filter (hide non-matching items), pass filtered={!getItemMatchInfo(item).match}
78350
- * to each ListItem. The list's matchFallback will be shown when all items are hidden.
78351
- */
78352
- const useSearchText = (searchText, items, matchFn = applySearch) => {
78353
- if (typeof searchText !== "string" && searchText !== undefined) {
78354
- throw new TypeError(
78355
- "useSearchText: searchText must be a string or undefined",
78356
- );
78357
- }
78358
- if (items === undefined) {
78359
- throw new TypeError("useSearch: items is undefined");
78360
- }
78361
- const { orderedItems, matchInfoMap } = useMemo(() => {
78362
- const { scoreEntries, nonMatched, matchInfoMap } = buildMatchInfo(
78363
- searchText,
78364
- items,
78365
- matchFn,
78366
- );
78367
- const orderedItems = [];
78368
- for (const [, bucket] of scoreEntries) {
78369
- for (const { item } of bucket) {
78370
- orderedItems.push(item);
78657
+ const unregisterKey = (key) => {
78658
+ registrations.delete(key);
78659
+ const idx = keyToOrderedIndex.get(key);
78660
+ if (idx !== undefined) {
78661
+ orderedKeys.splice(idx, 1);
78662
+ keyToOrderedIndex.delete(key);
78663
+ for (let i = idx; i < orderedKeys.length; i++) {
78664
+ keyToOrderedIndex.set(orderedKeys[i], i);
78371
78665
  }
78372
78666
  }
78373
- for (const { item } of nonMatched) {
78374
- orderedItems.push(item);
78667
+ keyToExplicitOrder.delete(key);
78668
+ allRegistrations.delete(key);
78669
+ removeAllKey(key);
78670
+ allKeys.delete(key);
78671
+ };
78672
+
78673
+ const keyForId = (id) => {
78674
+ if (!idToKey.has(id)) {
78675
+ idToKey.set(id, keyCounter++);
78375
78676
  }
78376
- return { orderedItems, matchInfoMap };
78377
- }, [items, searchText, matchFn]);
78677
+ return idToKey.get(id);
78678
+ };
78378
78679
 
78379
- // The same function for as long as the map is the same: a `renderItem`
78380
- // reading it is stable only if this is, and a run keeps the rows it drew
78381
- // only for a stable `renderItem` (see List.Items).
78382
- const getItemMatchInfo = useCallback(
78383
- (item) => matchInfoMap.get(item),
78384
- [matchInfoMap],
78385
- );
78680
+ // Register an item. data.hidden controls visibility.
78681
+ // explicitOrder is the caller-provided index (e.g. from items.map((item, i) => ...))
78682
+ // that determines this item's position among siblings.
78683
+ // Returns the item's visible rank among non-hidden items, or -1 when hidden.
78684
+ const useTrackItem = (data) => {
78685
+ const { id, index } = data;
78686
+ const key = keyForId(id);
78386
78687
 
78387
- return [orderedItems, getItemMatchInfo];
78388
- };
78688
+ syncItem(key, index, data);
78689
+ notify();
78389
78690
 
78390
- const buildMatchInfo = (searchText, items, matchFn) => {
78391
- // scoreEntries: [score, bucket][] kept sorted desc by score.
78392
- // New distinct score values are inserted via bisect — O(1) in practice
78393
- // since there are very few distinct scores (today just 0 and 1).
78394
- const scoreEntries = []; // [score, bucket][]
78395
- const nonMatched = [];
78691
+ useLayoutEffect(() => {
78692
+ return () => {
78693
+ unregisterKey(key);
78694
+ notify();
78695
+ };
78696
+ }, []);
78396
78697
 
78397
- for (const item of items) {
78398
- const result = matchFn(searchText, item);
78399
- if (!result.match) {
78400
- nonMatched.push({
78401
- item,
78402
- matchScore: result.matchScore,
78403
- matchRanges: result.matchRanges,
78404
- });
78405
- continue;
78406
- }
78407
- const score = result.matchScore;
78408
- // Find existing bucket or insert a new entry in desc order.
78409
- let lo = 0;
78410
- let hi = scoreEntries.length;
78411
- while (lo < hi) {
78412
- const mid = (lo + hi) >> 1;
78413
- if (scoreEntries[mid][0] > score) {
78414
- lo = mid + 1;
78415
- } else if (scoreEntries[mid][0] < score) {
78416
- hi = mid;
78417
- } else {
78418
- lo = mid;
78419
- hi = mid; // exact match — found the bucket
78420
- }
78698
+ if (data.filtered || data.hidden || data.role === "presentation") {
78699
+ return -1;
78421
78700
  }
78422
- if (lo < scoreEntries.length && scoreEntries[lo][0] === score) {
78423
- scoreEntries[lo][1].push({ item, matchRanges: result.matchRanges });
78424
- } else {
78425
- scoreEntries.splice(lo, 0, [
78426
- score,
78427
- [{ item, matchRanges: result.matchRanges }],
78428
- ]);
78701
+ return keyToOrderedIndex.get(key) ?? -1;
78702
+ };
78703
+
78704
+ const getTrackedItemByIndex = (index) => {
78705
+ const key = orderedKeys[index];
78706
+ if (key === undefined) {
78707
+ return undefined;
78429
78708
  }
78430
- }
78709
+ return registrations.get(key);
78710
+ };
78431
78711
 
78432
- const matchInfoMap = new Map();
78433
- for (const [score, bucket] of scoreEntries) {
78434
- for (const { item, matchRanges } of bucket) {
78435
- matchInfoMap.set(item, { match: true, matchScore: score, matchRanges });
78712
+ // The items as they stand right now, notification pending or not — same
78713
+ // content as itemsSignal, minus the wait.
78714
+ //
78715
+ // Items register during their own render, while the signal is only updated
78716
+ // on a deferred microtask (see notify): a sibling rendering after them would
78717
+ // otherwise paint from an empty list and correct itself a frame later. That
78718
+ // frame is visible whenever the painted size feeds a layout decision — a
78719
+ // dialog sizing itself on its content measures the empty version and shifts
78720
+ // once the real one lands. Reading this instead makes the first paint the
78721
+ // right one. Callers must still subscribe to itemsSignal to re-render on
78722
+ // LATER changes; this is the value to display, not the notification.
78723
+ const peekItems = () => {
78724
+ if (!notifyScheduled) {
78725
+ return itemsSignal.peek();
78436
78726
  }
78437
- }
78438
- for (const { item, matchScore, matchRanges } of nonMatched) {
78439
- matchInfoMap.set(item, { match: false, matchScore, matchRanges });
78440
- }
78727
+ const items = [];
78728
+ for (const key of allOrderedKeys) {
78729
+ items.push(allRegistrations.get(key));
78730
+ }
78731
+ return items;
78732
+ };
78441
78733
 
78442
- return { scoreEntries, nonMatched, matchInfoMap };
78734
+ return {
78735
+ useTrackItem,
78736
+ getTrackedItemByIndex,
78737
+ peekItems,
78738
+ itemsSignal,
78739
+ visibleItemsSignal,
78740
+ countSignal,
78741
+ visibleCountSignal,
78742
+ noMatchCountSignal,
78743
+ _flushSync,
78744
+ };
78443
78745
  };
78444
78746
 
78445
78747
  installImportMetaCssBuild(import.meta);