Skip to content
Merged
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
12 changes: 6 additions & 6 deletions docs/reference/generated/autocomplete-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,17 @@
"description": "Whether to automatically highlight the first item while filtering.",
"detailedType": "boolean | undefined"
},
"cols": {
"type": "number",
"default": "1",
"description": "The maximum number of columns present when the items are rendered in grid layout.\nA value of more than `1` turns the listbox into a grid.",
"detailedType": "number | undefined"
},
"filter": {
"type": "((itemValue: Value, query: string, itemToStringLabel: ((itemValue: Value) => string) | undefined) => boolean) | null",
"description": "Filter function used to match items vs input query.\nThe `itemToStringLabel` function is provided to help convert items to strings for comparison.",
"detailedType": "| ((\n itemValue: Value,\n query: string,\n itemToStringLabel:\n | ((itemValue: Value) => string)\n | undefined,\n ) => boolean)\n| null\n| undefined"
},
"grid": {
"type": "boolean",
"default": "false",
"description": "Whether list items are presented in a grid layout.\nWhen enabled, arrow keys navigate across rows and columns inferred from DOM rows.",
"detailedType": "boolean | undefined"
},
"itemToStringValue": {
"type": "((itemValue: Value) => string)",
"description": "When items' values are objects, converts its value to a string representation for display in the input.",
Expand Down
12 changes: 6 additions & 6 deletions docs/reference/generated/combobox-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@
"description": "Whether to automatically highlight the first item while filtering.",
"detailedType": "boolean | undefined"
},
"cols": {
"type": "number",
"default": "1",
"description": "The maximum number of columns present when the items are rendered in grid layout.\nA value of more than `1` turns the listbox into a grid.",
"detailedType": "number | undefined"
},
"defaultInputValue": {
"type": "string | number | string[]",
"description": "The uncontrolled input value when initially rendered.",
Expand All @@ -65,6 +59,12 @@
"description": "Filter function used to match items vs input query.\nThe `itemToStringLabel` function is provided to help convert items to strings for comparison.",
"detailedType": "| ((\n itemValue: any,\n query: string,\n itemToStringLabel:\n | ((itemValue: any) => string)\n | undefined,\n ) => boolean)\n| null\n| undefined"
},
"grid": {
"type": "boolean",
"default": "false",
"description": "Whether list items are presented in a grid layout.\nWhen enabled, arrow keys navigate across rows and columns inferred from DOM rows.",
"detailedType": "boolean | undefined"
},
"inputValue": {
"type": "string | number | string[]",
"description": "The input value of the combobox.",
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/combobox-row.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ComboboxRow",
"description": "Displays a single row of items in a grid list.\nSpecify `cols` on the root component to indicate the number of columns.\nRenders a `<div>` element.",
"description": "Displays a single row of items in a grid list.\nEnable `grid` on the root component to turn the listbox into a grid.\nRenders a `<div>` element.",
"props": {
"className": {
"type": "string | ((state: Combobox.Row.State) => string)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function ExampleEmojiPicker() {

<Autocomplete.Root
items={emojiGroups}
cols={COLUMNS}
grid
open={pickerOpen}
onOpenChange={setPickerOpen}
onOpenChangeComplete={() => setSearchValue('')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default function ExampleEmojiPicker() {

<Autocomplete.Root
items={emojiGroups}
cols={COLUMNS}
grid
open={pickerOpen}
onOpenChange={setPickerOpen}
onOpenChangeComplete={() => setSearchValue('')}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/combobox/list/ComboboxList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const ComboboxList = React.forwardRef(function ComboboxList(
const hasPositionerContext = Boolean(useComboboxPositionerContext(true));

const selectionMode = useStore(store, selectors.selectionMode);
const cols = useStore(store, selectors.cols);
const grid = useStore(store, selectors.grid);
const popupRef = useStore(store, selectors.popupRef);
const popupProps = useStore(store, selectors.popupProps);
const disabled = useStore(store, selectors.disabled);
Expand Down Expand Up @@ -73,7 +73,7 @@ export const ComboboxList = React.forwardRef(function ComboboxList(
children: resolvedChildren,
tabIndex: -1,
id: floatingRootContext.floatingId,
role: cols > 1 ? 'grid' : 'listbox',
role: grid ? 'grid' : 'listbox',
'aria-multiselectable': multiple ? 'true' : undefined,
onKeyDown(event) {
if (disabled || readOnly) {
Expand Down
142 changes: 137 additions & 5 deletions packages/react/src/combobox/root/ComboboxRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1308,10 +1308,10 @@ describe('<Combobox.Root />', () => {
});
});

describe('prop: cols', () => {
it('sets grid roles when cols > 1 and rows are used', async () => {
describe('prop: grid', () => {
it('sets grid roles when grid is enabled and rows are used', async () => {
await render(
<Combobox.Root cols={3} defaultOpen>
<Combobox.Root grid defaultOpen>
<Combobox.Input data-testid="input" />
<Combobox.Portal>
<Combobox.Positioner>
Expand Down Expand Up @@ -1340,10 +1340,10 @@ describe('<Combobox.Root />', () => {
expect(cells).to.have.length(6);
});

it('Arrow keys navigate by columns across the grid', async () => {
it('arrow keys navigate across rows and columns in grid mode', async () => {
const onItemHighlighted = spy();
const { user } = await render(
<Combobox.Root cols={3} onItemHighlighted={onItemHighlighted} defaultOpen>
<Combobox.Root grid onItemHighlighted={onItemHighlighted} defaultOpen>
<Combobox.Input data-testid="input" />
<Combobox.Portal>
<Combobox.Positioner>
Expand Down Expand Up @@ -1388,6 +1388,138 @@ describe('<Combobox.Root />', () => {
await user.keyboard('{ArrowUp}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('2'));
});

it('supports uneven rows navigation', async () => {
const onItemHighlighted = spy();
const { user } = await render(
<Combobox.Root grid onItemHighlighted={onItemHighlighted} defaultOpen>
<Combobox.Input data-testid="input" />
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.List>
<Combobox.Row>
<Combobox.Item value="1">1</Combobox.Item>
<Combobox.Item value="2">2</Combobox.Item>
<Combobox.Item value="3">3</Combobox.Item>
</Combobox.Row>
<Combobox.Row>
<Combobox.Item value="4">4</Combobox.Item>
<Combobox.Item value="5">5</Combobox.Item>
</Combobox.Row>
<Combobox.Row>
<Combobox.Item value="6">6</Combobox.Item>
<Combobox.Item value="7">7</Combobox.Item>
<Combobox.Item value="8">8</Combobox.Item>
<Combobox.Item value="9">9</Combobox.Item>
<Combobox.Item value="10">10</Combobox.Item>
</Combobox.Row>
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>,
);

const input = screen.getByTestId('input');
await user.click(input);
await waitFor(() => expect(screen.getByRole('grid')).not.to.equal(null));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('1'));

await user.keyboard('{ArrowRight}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('2'));

await user.keyboard('{ArrowRight}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('3'));

// Down from last col (3) to shorter row should clamp to last item (5)
await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('5'));

// Up from clamped item (5) should return to same column in previous row (2)
await user.keyboard('{ArrowUp}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('2'));

// From 2, move down to 5 (same column), then down to 7 in the longer row
await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('5'));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('7'));

// Left within last row goes to 6, up to first col in previous row (4)
await user.keyboard('{ArrowLeft}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('6'));

await user.keyboard('{ArrowUp}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('4'));
});

it('supports uneven rows navigation within groups', async () => {
const onItemHighlighted = spy();
const { user } = await render(
<Combobox.Root grid onItemHighlighted={onItemHighlighted} defaultOpen>
<Combobox.Input data-testid="input" />
<Combobox.Portal>
<Combobox.Positioner>
<Combobox.Popup>
<Combobox.List>
<Combobox.Group>
<Combobox.Row>
<Combobox.Item value="1">1</Combobox.Item>
<Combobox.Item value="2">2</Combobox.Item>
<Combobox.Item value="3">3</Combobox.Item>
</Combobox.Row>
</Combobox.Group>
<Combobox.Group>
<Combobox.Row>
<Combobox.Item value="4">4</Combobox.Item>
<Combobox.Item value="5">5</Combobox.Item>
</Combobox.Row>
</Combobox.Group>
<Combobox.Group>
<Combobox.Row>
<Combobox.Item value="6">6</Combobox.Item>
<Combobox.Item value="7">7</Combobox.Item>
<Combobox.Item value="8">8</Combobox.Item>
<Combobox.Item value="9">9</Combobox.Item>
<Combobox.Item value="10">10</Combobox.Item>
</Combobox.Row>
</Combobox.Group>
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>,
);

const input = screen.getByTestId('input');
await user.click(input);
await waitFor(() => expect(screen.getByRole('grid')).not.to.equal(null));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('1'));

await user.keyboard('{ArrowRight}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('2'));

await user.keyboard('{ArrowRight}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('3'));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('5'));

await user.keyboard('{ArrowUp}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('2'));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('5'));

await user.keyboard('{ArrowDown}');
await waitFor(() => expect(onItemHighlighted.lastCall.args[0]).to.equal('7'));
});
});

describe('prop: multiple', () => {
Expand Down
26 changes: 15 additions & 11 deletions packages/react/src/combobox/root/ComboboxRootInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
readOnly = false,
required = false,
inputRef: inputRefProp,
cols = 1,
grid = false,
items,
filter: filterProp,
openOnInputClick = true,
Expand Down Expand Up @@ -312,7 +312,7 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
readOnly,
required,
fieldControlValidation,
cols,
grid,
isGrouped,
virtualized,
openOnInputClick,
Expand Down Expand Up @@ -786,7 +786,7 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
let ariaHasPopup: 'grid' | 'listbox' | undefined;
let ariaExpanded: 'true' | 'false' | undefined;
if (!inline) {
ariaHasPopup = cols > 1 ? 'grid' : 'listbox';
ariaHasPopup = grid ? 'grid' : 'listbox';
ariaExpanded = open ? 'true' : 'false';
}

Expand Down Expand Up @@ -857,8 +857,12 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
loop: true,
allowEscape: !autoHighlight,
focusItemOnOpen: queryChangedAfterOpen || selectionMode === 'none' ? false : 'auto',
cols,
orientation: cols > 1 ? 'horizontal' : undefined,
// `cols` > 1 enables grid navigation.
// Since <Combobox.Row> infers column sizes (and is required when building a grid),
// it works correctly even with a value of `2`.
// Floating UI tests don't require `role="row"` wrappers, so retains the number API.
cols: grid ? 2 : 1,
orientation: grid ? 'horizontal' : undefined,
disabledIndices: virtualized
? (index) => index < 0 || index >= flatFilteredItems.length
: EMPTY_ARRAY,
Expand Down Expand Up @@ -939,7 +943,7 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
readOnly,
required,
fieldControlValidation,
cols,
grid,
isGrouped,
virtualized,
onOpenChangeComplete,
Expand Down Expand Up @@ -971,7 +975,7 @@ export function ComboboxRootInternal<Value = any, Mode extends SelectionMode = '
readOnly,
required,
fieldControlValidation,
cols,
grid,
isGrouped,
virtualized,
onOpenChangeComplete,
Expand Down Expand Up @@ -1213,11 +1217,11 @@ interface ComboboxRootProps<ItemValue> {
*/
inputRef?: React.RefObject<HTMLInputElement>;
/**
* The maximum number of columns present when the items are rendered in grid layout.
* A value of more than `1` turns the listbox into a grid.
* @default 1
* Whether list items are presented in a grid layout.
* When enabled, arrow keys navigate across rows and columns inferred from DOM rows.
* @default false
*/
cols?: number;
grid?: boolean;
/**
* The items to be displayed in the list.
* Can be either a flat array of items or an array of groups with items.
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/combobox/row/ComboboxRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ComboboxRowContext } from './ComboboxRowContext';

/**
* Displays a single row of items in a grid list.
* Specify `cols` on the root component to indicate the number of columns.
* Enable `grid` on the root component to turn the listbox into a grid.
* Renders a `<div>` element.
*/
export const ComboboxRow = React.forwardRef(function ComboboxRow(
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/combobox/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export type State = {
readOnly: boolean;
required: boolean;
fieldControlValidation: ReturnType<typeof useFieldControlValidation>;
cols: number;
grid: boolean;
isGrouped: boolean;
virtualized: boolean;
onOpenChangeComplete: (open: boolean) => void;
Expand Down Expand Up @@ -147,7 +147,7 @@ export const selectors = {
readOnly: createSelector((state: State) => state.readOnly),
required: createSelector((state: State) => state.required),
fieldControlValidation: createSelector((state: State) => state.fieldControlValidation),
cols: createSelector((state: State) => state.cols),
grid: createSelector((state: State) => state.grid),
isGrouped: createSelector((state: State) => state.isGrouped),
virtualized: createSelector((state: State) => state.virtualized),
onOpenChangeComplete: createSelector((state: State) => state.onOpenChangeComplete),
Expand Down
Loading