import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, onCleanup, Show, untrack, } from "solid-js"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useCompositeListItem } from "../../internals/composite/composite-list"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps, NonNativeButtonProps, } from "../../internals/types"; import { useButton } from "../../internals/use-button/use-button"; import { compareItemEquality, findItemIndex } from "../internals/item-equality"; import { useComboboxRootContext } from "../root/combobox-root-context"; import { ComboboxItemContext } from "./item-context"; export interface ComboboxItemState { disabled: boolean; selected: boolean; highlighted: boolean; } export interface ComboboxItemProps extends NonNativeButtonProps, Omit, "id"> { as?: ValidComponent; value?: Value | null | undefined; index?: number | undefined; disabled?: boolean | undefined; children?: JSX.Element; } /** * An individual item in the list. Renders a `
` with `role="option"`. */ export function ComboboxItem( props: ComboboxItemProps, ): JSX.Element { const root = useComboboxRootContext(); const itemValue = (): Value | null => (props.value ?? null) as Value | null; const disabled = (): boolean => props.disabled ?? false; const nativeButton = (): boolean => props.nativeButton ?? false; const listItem = useCompositeListItem(); const hasItems = (): boolean => root.items() !== undefined; const resolvedIndex = createMemo(() => { if (props.index != null) return props.index; if (hasItems()) { const iv = itemValue(); if (iv == null) return -1; return findItemIndex( root.flatFilteredItems(), iv, root.isItemEqualToValue(), ); } return listItem.index(); }); const highlighted = createMemo( () => root.activeIndex() === resolvedIndex() && resolvedIndex() !== -1, ); // P8: value-based equality for selected state. const selected = createMemo(() => { const sv = root.selectedValue(); if (sv == null) return false; const iv = itemValue(); if (iv == null) return false; if (Array.isArray(sv)) { return (sv as Value[]).some((entry) => compareItemEquality(iv, entry, root.isItemEqualToValue()), ); } return compareItemEquality(iv, sv as Value, root.isItemEqualToValue()); }); let itemRef: HTMLElement | null = null; createEffect( () => ({ has: hasItems(), idx: resolvedIndex(), iv: itemValue(), }), ({ has, idx, iv }) => { if (idx === -1 || iv == null) return; // Register the rendered node into `listRef` in BOTH modes. In // `items`-mode the root only SIZES `listRef` (`.length = flat.length`) // and never fills the nodes, so without this the array is all holes and // `useListNavigation`'s pointer path (`listRef.indexOf(currentTarget)`) // returns -1 — hover highlighting is dead. Keyboard nav is unaffected // because it drives `activeIndex` numerically. The `valuesRef` / // `allValuesRef` bookkeeping below stays items-mode-excluded: the root // owns those from `flatFilteredItems`. if (itemRef) root.listRef.current[idx] = itemRef; if (has) return; const list = root.valuesRef.current; list[idx] = iv; if (!root.allValuesRef.current.includes(iv)) { root.allValuesRef.current.push(iv); } }, ); onCleanup(() => { const idx = resolvedIndex(); if (idx === -1) return; // Mirror the both-modes node registration above: drop our node from // `listRef` only if it's still the one we registered (a re-rendered // sibling may have already claimed the slot). The `valuesRef` / // `allValuesRef` teardown stays items-mode-excluded — the root owns it. if (root.listRef.current[idx] === itemRef) { delete root.listRef.current[idx]; } if (!hasItems()) { delete root.valuesRef.current[idx]; const iv = itemValue(); if (iv != null) { const all = root.allValuesRef.current; const pos = all.indexOf(iv); if (pos !== -1) all.splice(pos, 1); } } }); const id = createMemo(() => { const rid = root.id(); const idx = resolvedIndex(); if (rid == null || idx === -1) return undefined; return `${rid}-${idx}`; }); const state = createMemo(() => ({ disabled: disabled(), selected: selected(), highlighted: highlighted(), })); const { ref: buttonRef } = useButton({ disabled, focusableWhenDisabled: () => true, native: nativeButton, composite: true, }); function onPointerDown(event: PointerEvent): void { event.preventDefault(); } function onMouseDown(event: MouseEvent): void { event.preventDefault(); } function onClick(event: MouseEvent): void { if (disabled() || root.readOnly()) return; const iv = itemValue(); if (iv == null) return; root.handleSelection(event, iv); } const internal = { role: "option" as const, get id() { return id() ?? ""; }, get "aria-selected"() { return selected() ? ("true" as const) : ("false" as const); }, onPointerDown, onMouseDown, onClick, // itemStateAttributesMapping: selected/highlighted/disabled → presence. get "data-selected"() { return state().selected ? "" : undefined; }, get "data-highlighted"() { return state().highlighted ? "" : undefined; }, get "data-disabled"() { return state().disabled ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "value", "index", "disabled", "nativeButton", ); // `getItemProps` resolves the interaction `item` bags eagerly (the `useRole` // item callback reads `active`/`selected` synchronously). Those only feed an // unused `-fui-option` id and an `aria-selected` that `internal` re-provides // reactively below, so this is a one-shot snapshot — untrack the read to // avoid `[STRICT_READ_UNTRACKED]`. let merged: Record = mergeRootProps( untrack(() => root.interactions.getItemProps({ get active() { return highlighted(); }, get selected() { return selected(); }, }), ), internal, ); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); // In items-mode an item is visible iff it survived filtering (its value is // present in `flatFilteredItems`, i.e. `resolvedIndex() !== -1`). In // static-children mode there is no `flatFilteredItems`, so the item filters // itself against the live query via `root.matchesQuery` (the cmdk pattern). const visible = createMemo(() => { if (hasItems()) return resolvedIndex() !== -1; const iv = itemValue(); if (iv == null) return true; return root.matchesQuery(iv); }); // Report live visibility so `` / `` can // reflect the count of matching static children. Inert in items-mode // (always reports `false` so the token is never counted; empty derives from // `flatFilteredItems` there). The `visibleItemTick` signal the root bumps is // `ownedWrite`, so writing it from this effect-apply scope is allowed. const visibilityToken = Symbol("combobox-item-visible"); createEffect( () => (hasItems() ? false : visible()), (reportVisible) => { root.setItemVisible(visibilityToken, reportVisible); }, ); onCleanup(() => { root.setItemVisible(visibilityToken, false); }); const Tag = dynamic(() => props.as ?? "div"); return ( { itemRef = el; }, userRef(props), )} /> ); }