Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/src/app/(docs)/react/components/combobox/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ import { Combobox } from '@base-ui/react/combobox';
## TypeScript

Combobox infers the item type from the `defaultValue` or `value` props passed to `<Combobox.Root>`.
The type of items held in the `items` array must also match the `value` prop type passed to `<Combobox.Item>`.
When the `items` array uses the full `{ value, label }` shape, `<Combobox.Item>` can use either the whole item or its primitive `value` field.
For other item shapes, the `items` array type should match the `value` prop type passed to `<Combobox.Item>`.

## Examples

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/combobox/chip/ComboboxChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const ComboboxChip = React.forwardRef(function ComboboxChip(

stopEvent(event);

store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' });
store.state.setIndices({ activeIndex: null, type: 'keyboard' });
store.state.setSelectedValue(
selectedValue.filter((_: any, i: number) => i !== index),
createChangeEventDetails(REASONS.none, event.nativeEvent),
Expand Down
15 changes: 5 additions & 10 deletions packages/react/src/combobox/clear/ComboboxClear.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,13 @@ export const ComboboxClear = React.forwardRef(function ComboboxClear(
Array.isArray(selectedValue) ? [] : null,
createChangeEventDetails(REASONS.clearPress, event.nativeEvent),
);
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
} else {
store.state.setIndices({
activeIndex: null,
type: keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
}

store.state.setIndices({
activeIndex: null,
type: keyboardActiveRef.current ? 'keyboard' : 'pointer',
});

store.state.inputRef.current?.focus();
},
},
Expand Down
9 changes: 2 additions & 7 deletions packages/react/src/combobox/input/ComboboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
: highlightedChipIndex;
// If the computed index is negative, treat it as no highlight.
nextIndex = computedNextIndex >= 0 ? computedNextIndex : undefined;
store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' });
store.state.setIndices({ activeIndex: null, type: 'keyboard' });
}
return nextIndex;
}
Expand All @@ -183,7 +183,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
event.currentTarget.value === '' &&
selectedValue.length > 0
) {
store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' });
store.state.setIndices({ activeIndex: null, type: 'keyboard' });
event.preventDefault();
}

Expand Down Expand Up @@ -296,7 +296,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
}
Expand All @@ -306,7 +305,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
if (open && store.state.activeIndex !== null && !shouldMaintainHighlight) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
}
Expand Down Expand Up @@ -343,7 +341,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
}
Expand All @@ -356,7 +353,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
if (open && store.state.activeIndex !== null && !autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
}
Expand Down Expand Up @@ -428,7 +424,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
// If the removed item was also the active (highlighted) item, clear highlight
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer',
});
store.state.setSelectedValue(
Expand Down
115 changes: 62 additions & 53 deletions packages/react/src/combobox/item/ComboboxItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import {
import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../internals/types';
import { useRenderElement } from '../../internals/useRenderElement';
import { ComboboxItemContext } from './ComboboxItemContext';
import { selectors } from '../store';
import { findMatchingItemIndex, selectors, type ComboboxStore, writeItemValues } from '../store';
import { useButton } from '../../internals/use-button';
import { useComboboxRowContext } from '../row/ComboboxRowContext';
import { compareItemEquality, findItemIndex } from '../../internals/itemEquality';
import { findItemIndex } from '../../internals/itemEquality';
import { ComboboxPortalContext } from '../portal/ComboboxPortalContext';

/**
* An individual item in the list.
Expand All @@ -43,28 +44,29 @@ export const ComboboxItem = React.memo(

const didPointerDownRef = React.useRef(false);
const textRef = React.useRef<HTMLElement | null>(null);
const itemMetadata = React.useMemo(() => ({ value: itemValue }), [itemValue]);
const listItem = useCompositeListItem({
index: indexProp,
metadata: itemMetadata,
textRef,
indexGuessBehavior: IndexGuessBehavior.GuessFromOrder,
});

const store = useComboboxRootContext();
const isRow = useComboboxRowContext();
const keepPortalMounted = React.useContext(ComboboxPortalContext);
const { flatFilteredItems, hasItems } = useComboboxDerivedItemsContext();

const open = useStore(store, selectors.open);
const selectionMode = useStore(store, selectors.selectionMode);
const readOnly = useStore(store, selectors.readOnly);
const virtualized = useStore(store, selectors.virtualized);
const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue);

const selectable = selectionMode !== 'none';
const index =
indexProp ??
(virtualized
? findItemIndex(flatFilteredItems, itemValue, isItemEqualToValue)
: listItem.index);
indexProp == null && virtualized
? resolveVirtualIndex(hasItems, flatFilteredItems, itemValue, isItemEqualToValue)
: (indexProp ?? listItem.index);
const hasRegistered = listItem.index !== -1;

const rootId = useStore(store, selectors.id);
Expand All @@ -73,63 +75,30 @@ export const ComboboxItem = React.memo(
const itemProps = useStore(store, selectors.itemProps);

const itemRef = React.useRef<HTMLDivElement | null>(null);

const id = rootId != null && hasRegistered ? `${rootId}-${index}` : undefined;
const selected = matchesSelectedValue && selectable;
const manuallyIndexed = indexProp != null;
const shouldRegisterVirtualItem = hasRegistered && virtualized;
const shouldRegisterManuallyIndexedItem = hasRegistered && manuallyIndexed && !virtualized;

useIsoLayoutEffect(() => {
const shouldRun = hasRegistered && (virtualized || indexProp != null);
if (!shouldRun) {
if (!shouldRegisterVirtualItem) {
return undefined;
}

const list = store.state.listRef.current;
list[index] = itemRef.current;

return () => {
delete list[index];
};
}, [hasRegistered, virtualized, index, indexProp, store]);
return registerIndexedItem(store, hasItems, index, itemValue, itemRef, keepPortalMounted);
}, [hasItems, index, itemValue, keepPortalMounted, shouldRegisterVirtualItem, store]);

useIsoLayoutEffect(() => {
if (!hasRegistered || hasItems) {
// Manual indexes opt out of the CompositeList metadata map, but still render
// inside it. Register after its layout effects so its length sync cannot
// truncate these explicit slots.
React.useEffect(() => {
if (!shouldRegisterManuallyIndexedItem) {
return undefined;
}

const visibleMap = store.state.valuesRef.current;
visibleMap[index] = itemValue;

// Stable registry that doesn't depend on filtering. Assume that no
// filtering had occurred at this point; otherwise, an `items` prop is
// required.
if (selectionMode !== 'none') {
store.state.allValuesRef.current.push(itemValue);
}

return () => {
delete visibleMap[index];
};
}, [hasRegistered, hasItems, index, itemValue, store, selectionMode]);

useIsoLayoutEffect(() => {
if (!open) {
didPointerDownRef.current = false;
return;
}

if (!hasRegistered || hasItems) {
return;
}

const selectedValue = store.state.selectedValue;
const lastSelectedValue = Array.isArray(selectedValue)
? selectedValue[selectedValue.length - 1]
: selectedValue;

if (compareItemEquality(itemValue, lastSelectedValue, isItemEqualToValue)) {
store.set('selectedIndex', index);
}
}, [hasRegistered, hasItems, open, store, index, itemValue, isItemEqualToValue]);
return registerIndexedItem(store, hasItems, index, itemValue, itemRef, keepPortalMounted);
}, [hasItems, index, itemValue, keepPortalMounted, shouldRegisterManuallyIndexedItem, store]);

const { getButtonProps, buttonRef } = useButton({
disabled,
Expand Down Expand Up @@ -213,6 +182,46 @@ export const ComboboxItem = React.memo(
}),
);

function resolveVirtualIndex(
hasItems: boolean,
flatFilteredItems: readonly any[],
itemValue: any,
isItemEqualToValue: (itemValue: any, selectedValue: any) => boolean,
) {
return hasItems
? findMatchingItemIndex(flatFilteredItems, itemValue, isItemEqualToValue)
: findItemIndex(flatFilteredItems, itemValue, isItemEqualToValue);
}

function registerIndexedItem(
store: ComboboxStore,
hasItems: boolean,
index: number,
itemValue: any,
itemRef: React.RefObject<HTMLDivElement | null>,
keepMounted: boolean | undefined,
) {
const list = store.state.listRef.current;
const visibleMap = store.state.valuesRef.current;

list[index] = itemRef.current;
visibleMap[index] = itemValue;
writeItemValues(store, hasItems, visibleMap.slice());

return () => {
delete list[index];
const registryActive =
store.state.inline || store.state.open || keepMounted || store.state.forceMounted;

if (!registryActive) {
return;
}

delete visibleMap[index];
writeItemValues(store, hasItems, visibleMap.slice());
};
}

export interface ComboboxItemState {
/**
* Whether the item should ignore user interaction.
Expand Down
48 changes: 47 additions & 1 deletion packages/react/src/combobox/list/ComboboxList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useStore } from '@base-ui/utils/store';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import type { BaseUIComponentProps } from '../../internals/types';
import { useRenderElement } from '../../internals/useRenderElement';
Expand All @@ -10,10 +11,15 @@ import {
useComboboxRootContext,
} from '../root/ComboboxRootContext';
import { useComboboxPositionerContext } from '../positioner/ComboboxPositionerContext';
import { selectors } from '../store';
import { selectors, writeItemValues } from '../store';
import { ComboboxCollection } from '../collection/ComboboxCollection';
import { CompositeList } from '../../internals/composite/list/CompositeList';
import { stopEvent } from '../../floating-ui-react/utils';
import { ComboboxPortalContext } from '../portal/ComboboxPortalContext';

interface ComboboxListItemMetadata {
value: any;
}

/**
* A list container for the items.
Expand All @@ -30,15 +36,21 @@ export const ComboboxList = React.forwardRef(function ComboboxList(
const store = useComboboxRootContext();
const floatingRootContext = useComboboxFloatingContext();
const hasPositionerContext = Boolean(useComboboxPositionerContext(true));
const keepPortalMounted = React.useContext(ComboboxPortalContext);
const { filteredItems, hasItems } = useComboboxDerivedItemsContext();

const selectionMode = useStore(store, selectors.selectionMode);
const grid = useStore(store, selectors.grid);
const popupProps = useStore(store, selectors.popupProps);
const open = useStore(store, selectors.open);
const mounted = useStore(store, selectors.mounted);
const inline = useStore(store, selectors.inline);
const forceMounted = useStore(store, selectors.forceMounted);
const virtualized = useStore(store, selectors.virtualized);

const multiple = selectionMode === 'multiple';
const empty = filteredItems.length === 0;
const registryActive = inline || open || mounted || keepPortalMounted || forceMounted;

const setPositionerElement = useStableCallback((element) => {
store.set('positionerElement', element);
Expand All @@ -48,6 +60,39 @@ export const ComboboxList = React.forwardRef(function ComboboxList(
store.set('listElement', element);
});

const handleMapChange = useStableCallback(
(map: Map<Element, ComboboxListItemMetadata | null>) => {
if (!registryActive) {
return;
}

const itemValues: any[] = [];
map.forEach((metadata) => {
if (metadata) {
itemValues.push(metadata.value);
}
});

store.state.valuesRef.current = itemValues;
writeItemValues(store, hasItems, itemValues);
},
);

useIsoLayoutEffect(() => {
if (registryActive) {
return;
}

const itemValues = store.state.itemValues;
store.state.valuesRef.current = [];
if (itemValues.length) {
if (!hasItems) {
store.set('allItemValues', itemValues);
}
store.set('itemValues', []);
}
}, [hasItems, registryActive, store]);

// Support "closed template" API: if children is a function, implicitly wrap it
// with a Combobox.Collection that reads items from context/root.
// Ensures this component's `popupProps` subscription does not cause <Combobox.Item>
Expand Down Expand Up @@ -120,6 +165,7 @@ export const ComboboxList = React.forwardRef(function ComboboxList(
<CompositeList
elementsRef={store.state.listRef}
labelsRef={hasItems ? undefined : store.state.labelsRef}
onMapChange={handleMapChange}
>
{element}
</CompositeList>
Expand Down
Loading
Loading