import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, onCleanup, untrack } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useCompositeListItem } from "../../internals/composite/composite-list"; import type { MutableRef } from "../../internals/floating-ui/types"; 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 type { SelectValue } from "../select-root-context"; import { useSelectRootContext } from "../select-root-context"; import { compareItemEquality, removeItem } from "../utils/item-equality"; import { SelectItemContext, type SelectItemContextValue, } from "./select-item-context"; export interface SelectItemState { disabled: boolean; selected: boolean; highlighted: boolean; } export interface SelectItemProps extends NonNativeButtonProps, Omit, "id"> { as?: ValidComponent; value?: SelectValue; disabled?: boolean | undefined; label?: string | undefined; } export function SelectItem(props: SelectItemProps): JSX.Element { const root = useSelectRootContext(); const itemValue = (): SelectValue => ("value" in props ? props.value : null); const disabled = (): boolean => props.disabled ?? false; const nativeButton = (): boolean => props.nativeButton ?? false; const textRef: MutableRef = { current: null }; const listItem = useCompositeListItem({ label: props.label, textRef, }); const hasRegistered = createMemo(() => listItem.index() !== -1); createEffect( () => ({ idx: listItem.index(), value: itemValue() }), ({ idx, value }) => { if (idx === -1) return; const values = root.valuesRef.current; values[idx] = value; }, ); onCleanup(() => { const idx = listItem.index(); if (idx !== -1) { delete root.valuesRef.current[idx]; } }); createEffect( () => ({ idx: listItem.index(), value: itemValue(), storeValue: root.value(), equals: root.isItemEqualToValue(), }), ({ idx, value, storeValue, equals }) => { if (idx === -1) return; let candidate = storeValue; if ( root.multiple() && Array.isArray(storeValue) && storeValue.length > 0 ) { candidate = storeValue[storeValue.length - 1]; } if ( candidate !== undefined && compareItemEquality(value, candidate, equals) ) { root.setSelectedIndex(idx); if (textRef.current) { root.selectedItemTextRef.current = textRef.current; } } }, ); const highlighted = createMemo( () => root.activeIndex() === listItem.index(), ); // P8: value-based equality for selected state. const selected = createMemo(() => { const comparer = root.isItemEqualToValue(); const storeValue = root.value(); const iv = itemValue(); if (root.multiple()) { return ( Array.isArray(storeValue) && storeValue.some((sv) => compareItemEquality(iv, sv, comparer)) ); } return compareItemEquality(iv, storeValue, comparer); }); const selectedByFocus = createMemo( () => root.selectedIndex() === listItem.index(), ); const indexRef: MutableRef = { current: -1 }; createEffect( () => listItem.index(), (i) => { indexRef.current = i; }, ); const { props: buttonProps, ref: buttonRef } = useButton({ disabled, focusableWhenDisabled: () => true, native: nativeButton, }); function commitSelection(event: MouseEvent | KeyboardEvent): void { const storeValue = root.value(); if (root.multiple()) { const currentValue = Array.isArray(storeValue) ? storeValue : []; const nextValue = selected() ? removeItem(currentValue, itemValue(), root.isItemEqualToValue()) : [...currentValue, itemValue()]; root.setValue( nextValue, createChangeEventDetails(REASONS.itemPress, event), ); } else { root.setValue( itemValue(), createChangeEventDetails(REASONS.itemPress, event), ); root.setOpen(false, createChangeEventDetails(REASONS.itemPress, event)); } } const state = createMemo(() => ({ disabled: disabled(), selected: selected(), highlighted: highlighted(), })); let lastKey: string | null = null; let didPointerDown = false; let pointerType: string | null = null; const contextValue: SelectItemContextValue = { selected, selectedByFocus, hasRegistered, indexRef, textRef, }; const internal = { role: "option", get "aria-selected"() { return selected() ? "true" : "false"; }, get tabindex() { return highlighted() ? 0 : -1; }, onTouchStart(): void { root.selectionRef.current = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, }; }, onKeyDown(event: KeyboardEvent) { lastKey = event.key; root.setActiveIndex(listItem.index()); if (event.key === " " && root.typingRef.current) { event.preventDefault(); } }, onClick(event: MouseEvent & { type: string }) { didPointerDown = false; if (event.type === "keydown" && lastKey === null) return; if (disabled()) return; if ( event.type === "keydown" && lastKey === " " && root.typingRef.current ) { return; } lastKey = null; commitSelection(event); }, onPointerEnter(event: PointerEvent): void { pointerType = event.pointerType; }, onPointerDown(event: PointerEvent): void { pointerType = event.pointerType; didPointerDown = true; }, onMouseUp(event: MouseEvent): void { if (disabled()) return; if (didPointerDown) { didPointerDown = false; return; } const guard = root.selectionRef.current; const disallowSelectedMouseUp = !guard.allowSelectedMouseUp && selected(); const disallowUnselectedMouseUp = !guard.allowUnselectedMouseUp && !selected(); if ( disallowSelectedMouseUp || disallowUnselectedMouseUp || (pointerType !== "touch" && !highlighted()) ) { return; } const target = event.currentTarget as HTMLElement | null; target?.click(); }, // Default projection: disabled/selected/highlighted → presence. get "data-disabled"() { return state().disabled ? "" : undefined; }, get "data-selected"() { return state().selected ? "" : undefined; }, get "data-highlighted"() { return state().highlighted ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "value", "disabled", "label", "nativeButton", ); // `getItemProps` resolves the interaction `item` bags eagerly. The // `active`/`selected` it consumes (via `useRole`) 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({ active: highlighted(), selected: selected(), }), ), buttonProps, ); merged = mergeRootProps(merged, internal); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }