import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — like `` but with no `selected` state. */ import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, onCleanup, Show, untrack, } from "solid-js"; import { findItemIndex } from "../../combobox/internals/item-equality"; import { useComboboxRootContext } from "../../combobox/root/combobox-root-context"; 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"; export interface AutocompleteItemState { disabled: boolean; highlighted: boolean; } export interface AutocompleteItemProps 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 AutocompleteItem( props: AutocompleteItemProps, ): 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, ); 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 `useListNavigation`'s pointer path // (`listRef.indexOf(currentTarget)`) returns -1 and hover highlighting // is dead. Keyboard nav is unaffected (it drives `activeIndex` // numerically). The `valuesRef` / `allValuesRef` bookkeeping below // stays items-mode-excluded — the root owns it from the items list. 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; if (root.listRef.current[idx] === itemRef) { delete root.listRef.current[idx]; } if (!hasItems()) { delete root.valuesRef.current[idx]; } }); 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(), 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() ?? ""; }, onPointerDown, onMouseDown, onClick, // itemStateAttributesMapping projection. 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` synchronously). It only feeds an unused // `-fui-option` id here, 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(); }, selected: false, }), ), internal, ); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); // In items-mode an item is visible iff it survived filtering // (`resolvedIndex() !== -1`). In static-children mode there is no // `flatFilteredItems`, so the item filters itself against the live query via // `root.matchesQuery` (mode-aware: `inline` / `none` keep all items visible). 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`; empty derives from `flatFilteredItems` there). const visibilityToken = Symbol("autocomplete-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), )} /> ); }