import type { ComputedRef, DefineComponent, ShallowRef } from 'vue'; import { computed, defineComponent, h, markRaw, shallowRef, triggerRef, watch, } from 'vue'; import { unrefElement, useContextFactory } from '@robonen/vue'; import { Slot } from '../primitive'; /** * Data attribute used to locate items inside a collection via `querySelectorAll`. * Rendered automatically by ``. */ const ITEM_DATA_ATTR = 'data-collection-item'; export interface CollectionItemData { /** DOM element that represents the item. */ ref: HTMLElement; /** Arbitrary `value` associated with the item via ``. */ value?: Value; } export interface CollectionContext { /** Root element of the collection (set by ``). */ collectionRef: ShallowRef; /** Raw element→data map. Mutated via `triggerRef` — do not rely on deep reactivity. */ itemMap: ShallowRef>>; /** * Returns items sorted by their DOM order. Items with `data-disabled` are * skipped unless `includeDisabled` is `true`. * * The ordering comes from `collectionRef.querySelectorAll(...)`, which means * it survives ``, `` and `v-for` reorders — unlike a * mount-order based registry. */ getItems: (includeDisabled?: boolean) => Array>; /** Reactive snapshot of all items (unsorted). Invalidated when `itemMap` changes. */ reactiveItems: ComputedRef>>; /** Reactive count of items. */ itemMapSize: ComputedRef; /** Root marker component — render at the collection's root. */ CollectionSlot: DefineComponent; /** Item marker component — wrap each focusable/selectable child. */ CollectionItem: DefineComponent<{ value?: unknown }>; } function createCollectionState(): CollectionContext { // `shallowRef` + manual `triggerRef` avoids wrapping the Map in a deep Proxy. // For collections with many items (large lists, menus, listboxes) this is // measurably cheaper than `ref(new Map())`. const collectionRef = shallowRef(); const itemMap = shallowRef( new Map>(), ); const getItems = (includeDisabled = false) => { const collectionNode = collectionRef.value; if (!collectionNode) return []; const orderedNodes = Array.from( collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`), ); const items = Array.from(itemMap.value.values()); items.sort( (a, b) => orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref), ); return includeDisabled ? items : items.filter(i => i.ref.dataset['disabled'] !== ''); }; const CollectionSlot = defineComponent({ name: 'CollectionSlot', inheritAttrs: false, setup(_, { slots, attrs }) { return () => h( Slot, { ...attrs, ref: (el: unknown) => { const element = unrefElement(el as Parameters[0]); if (element instanceof HTMLElement) { collectionRef.value = element; } }, }, slots, ); }, }) as DefineComponent; const CollectionItem = defineComponent({ name: 'CollectionItem', inheritAttrs: false, props: { value: { // Accepts any value. validator: () => true, }, }, setup(props, { slots, attrs }) { const currentElement = shallowRef(); watch( [currentElement, () => props.value], ([el], _prev, onCleanup) => { if (!el) return; // `markRaw` keeps Vue from trying to make the element reactive — // we only care about identity as a Map key. const key = markRaw(el); itemMap.value.set(key, { ref: el, value: props.value as Value }); triggerRef(itemMap); onCleanup(() => { itemMap.value.delete(key); triggerRef(itemMap); }); }, { immediate: true }, ); return () => h( Slot, { ...attrs, [ITEM_DATA_ATTR]: '', ref: (el: unknown) => { const element = unrefElement(el as Parameters[0]); if (element instanceof HTMLElement) { currentElement.value = element; } }, }, slots, ); }, }) as DefineComponent<{ value?: unknown }>; const reactiveItems = computed(() => Array.from(itemMap.value.values())); const itemMapSize = computed(() => itemMap.value.size); return { collectionRef, itemMap, getItems, reactiveItems, itemMapSize, CollectionSlot, CollectionItem, }; } const DEFAULT_COLLECTION_KEY = 'CollectionContext'; // One context factory per namespace key (`useContextFactory` mints a unique // Symbol per call). Without namespacing, a collection provider nested inside // another (e.g. `RovingFocusGroup` between `NavigationMenuRoot` and // `NavigationMenuTrigger`) shadows the outer collection for every descendant. const collectionContextFactories = new Map< string, ReturnType> >(); function getCollectionContextFactory(key: string) { let factory = collectionContextFactories.get(key); if (!factory) { factory = useContextFactory(key); collectionContextFactories.set(key, factory); } return factory; } /** * Creates a new collection state and provides it to descendants. * Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`). * * Pass a dedicated `key` when the component tree may nest another collection * provider between this one and its injectors, so they don't shadow each other. * * @example * ```ts * const { getItems, CollectionSlot } = useCollectionProvider(); * ``` */ export function useCollectionProvider( key: string = DEFAULT_COLLECTION_KEY, ): CollectionContext { const ctx = createCollectionState(); getCollectionContextFactory(key).provide(ctx as CollectionContext); return ctx; } /** * Injects the collection context from the nearest `useCollectionProvider()` * called with the same `key`. * Call this in children (e.g. `RovingFocusItem`, `ListboxItem`). * * @throws when used outside a provider. */ export function useCollectionInjector( key: string = DEFAULT_COLLECTION_KEY, ): CollectionContext { return getCollectionContextFactory(key).inject() as CollectionContext; }