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
6 changes: 6 additions & 0 deletions .changeset/combobox-scroll-into-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Selector/MultiSelector/Typeahead: the highlighted option is now scrolled into view during keyboard navigation, so arrow keys no longer move the highlight off-screen in long lists (matches CommandPalette) (#3343).
@cixzhang
34 changes: 34 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,5 +734,39 @@ describe('MultiSelector', () => {
const clear = screen.getByRole('button', {name: 'Clear all Fruit'});
expect(clear).not.toHaveAttribute('tabIndex', '-1');
});

it('scrolls the highlighted option into view during arrow navigation', async () => {
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
try {
const user = userEvent.setup();
const longOptions = Array.from(
{length: 20},
(_, i) => `Option ${i + 1}`,
);
render(
<MultiSelector
label="Fruit"
options={longOptions}
value={[]}
onChange={() => {}}
/>,
);

const trigger = screen.getByRole('combobox');
await user.click(trigger);
scrollIntoView.mockClear();
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');

expect(scrollIntoView).toHaveBeenCalledWith({block: 'nearest'});
} finally {
delete (HTMLElement.prototype as unknown as {scrollIntoView?: unknown})
.scrollIntoView;
}
});
});
});
17 changes: 14 additions & 3 deletions packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,19 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
listboxId,
});

// Keep the highlighted option visible during keyboard navigation. The
// listbox is a fixed-height scroll container, so without this the virtual
// cursor walks off-screen once navigation passes the visible window. Mirrors
// CommandPaletteItem's scrollIntoView({block: 'nearest'}) behavior.
useEffect(() => {
if (!popover.isOpen || highlightedIndex < 0) {
return;
}
document
.getElementById(getItemId(highlightedIndex))
?.scrollIntoView?.({block: 'nearest'});
}, [popover.isOpen, highlightedIndex, getItemId]);

// Build trigger display content
const selectedLabels = useMemo(() => {
return optimisticValue.map(v => {
Expand Down Expand Up @@ -1062,9 +1075,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({

if (isDivider(option)) {
flushPending();
elements.push(
<Divider key={`divider-${i}`} xstyle={styles.divider} />,
);
elements.push(<Divider key={`divider-${i}`} xstyle={styles.divider} />);
} else if (isSection(option)) {
flushPending();
const count = option.options.length;
Expand Down
35 changes: 29 additions & 6 deletions packages/core/src/Selector/Selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ function mockSelectorRects() {

describe('Selector', () => {
it('renders with placeholder when no value', () => {
render(
<Selector label="Fruit" options={OPTIONS} placeholder="Pick one" />,
);
render(<Selector label="Fruit" options={OPTIONS} placeholder="Pick one" />);
expect(screen.getByRole('combobox')).toHaveTextContent('Pick one');
});

Expand Down Expand Up @@ -497,9 +495,7 @@ describe('Selector', () => {
it('opens and selects an option with Enter (no mouse)', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<Selector label="Fruit" options={OPTIONS} onChange={onChange} />,
);
render(<Selector label="Fruit" options={OPTIONS} onChange={onChange} />);

await user.tab();
await user.keyboard('{Enter}'); // open
Expand All @@ -522,5 +518,32 @@ describe('Selector', () => {
const clear = screen.getByRole('button', {name: 'Clear Fruit'});
expect(clear).not.toHaveAttribute('tabIndex', '-1');
});

it('scrolls the highlighted option into view during arrow navigation', async () => {
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
try {
const user = userEvent.setup();
const longOptions = Array.from(
{length: 20},
(_, i) => `Option ${i + 1}`,
);
render(<Selector label="Fruit" options={longOptions} />);

await user.tab();
await user.keyboard('{Enter}'); // open
scrollIntoView.mockClear();
await user.keyboard('{ArrowDown}'); // move highlight
await user.keyboard('{ArrowDown}');

expect(scrollIntoView).toHaveBeenCalledWith({block: 'nearest'});
} finally {
delete (HTMLElement.prototype as unknown as {scrollIntoView?: unknown})
.scrollIntoView;
}
});
});
});
57 changes: 32 additions & 25 deletions packages/core/src/Selector/Selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,34 +474,30 @@ type SelectorPropsNonClearable<
changeAction?: (value: string) => void | Promise<void>;
};

type SelectorPropsClearable<
T extends SelectorOptionType = SelectorOptionType,
> = SelectorPropsBase<T> & {
/**
* Whether to show a clear button when a value is selected.
* When clicked, resets the value to `null` and returns focus to the trigger.
*
* When enabled, `value` and `onChange` widen to include `null`.
*/
hasClear: true;
value: string | null;
onChange?: (value: string | null) => void;
changeAction?: (value: string | null) => void | Promise<void>;
};

export type SelectorProps<
T extends SelectorOptionType = SelectorOptionType,
> = SelectorPropsNonClearable<T> | SelectorPropsClearable<T>;
type SelectorPropsClearable<T extends SelectorOptionType = SelectorOptionType> =
SelectorPropsBase<T> & {
/**
* Whether to show a clear button when a value is selected.
* When clicked, resets the value to `null` and returns focus to the trigger.
*
* When enabled, `value` and `onChange` widen to include `null`.
*/
hasClear: true;
value: string | null;
onChange?: (value: string | null) => void;
changeAction?: (value: string | null) => void | Promise<void>;
};

export type SelectorProps<T extends SelectorOptionType = SelectorOptionType> =
| SelectorPropsNonClearable<T>
| SelectorPropsClearable<T>;

/**
* Default option renderer
*/
function DefaultOption({option}: {option: SelectorOptionData}) {
return (
<SelectorOption
icon={option.icon}
label={option.label ?? option.value}
/>
<SelectorOption icon={option.icon} label={option.label ?? option.value} />
);
}

Expand Down Expand Up @@ -692,6 +688,19 @@ export function Selector<T extends SelectorOptionType>(
listboxId,
});

// Keep the highlighted option visible during keyboard navigation. The
// listbox is a fixed-height scroll container, so without this the virtual
// cursor walks off-screen once navigation passes the visible window. Mirrors
// CommandPaletteItem's scrollIntoView({block: 'nearest'}) behavior.
useEffect(() => {
if (!popover.isOpen || highlightedIndex < 0) {
return;
}
document
.getElementById(getItemId(highlightedIndex))
?.scrollIntoView?.({block: 'nearest'});
}, [popover.isOpen, highlightedIndex, getItemId]);

// Handle clear button click
const handleClear = useCallback(
(e: React.MouseEvent) => {
Expand Down Expand Up @@ -812,9 +821,7 @@ export function Selector<T extends SelectorOptionType>(
const option = options[i];

if (isDivider(option)) {
elements.push(
<Divider key={`divider-${i}`} xstyle={styles.divider} />,
);
elements.push(<Divider key={`divider-${i}`} xstyle={styles.divider} />);
} else if (isSection(option)) {
const sectionItems: ReactNode[] = [];
for (const opt of option.options) {
Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/Typeahead/BaseTypeahead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ import {themeProps} from '../utils/themeProps';
// Types
// =============================================================================

export interface BaseTypeaheadProps<
T extends SearchableItem,
> extends Omit<BaseProps<HTMLElement>, 'onChange'> {
export interface BaseTypeaheadProps<T extends SearchableItem> extends Omit<
BaseProps<HTMLElement>,
'onChange'
> {
ref?: React.Ref<HTMLInputElement>;
/**
* Search source providing items.
Expand Down Expand Up @@ -280,9 +281,7 @@ const itemSizeStyles = stylex.create({
* />
* ```
*/
export const BaseTypeahead = function BaseTypeahead<
T extends SearchableItem,
>({
export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
searchSource,
value,
onChange,
Expand Down Expand Up @@ -615,6 +614,19 @@ export const BaseTypeahead = function BaseTypeahead<
[listboxId],
);

// Keep the highlighted option visible during keyboard navigation. The
// listbox is a fixed-height scroll container, so without this the virtual
// cursor walks off-screen once navigation passes the visible window. Mirrors
// CommandPaletteItem's scrollIntoView({block: 'nearest'}) behavior.
useEffect(() => {
if (!popover.isOpen || highlightedIndex < 0) {
return;
}
document
.getElementById(getItemId(highlightedIndex))
?.scrollIntoView?.({block: 'nearest'});
}, [popover.isOpen, highlightedIndex, getItemId]);

const selectedKey =
value == null ? null : getKey(value.id, () => results.indexOf(value));

Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/Typeahead/Typeahead.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,4 +573,39 @@ describe('BaseTypeahead paste behavior', () => {
expect(screen.getByText('No results found')).toBeInTheDocument();
});
});

it('scrolls the highlighted option into view during arrow navigation', async () => {
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
try {
const user = userEvent.setup();
render(
<BaseTypeahead
searchSource={fruitSource}
value={null}
onChange={() => {}}
debounceMs={0}
/>,
);

const input = screen.getByRole('combobox');
await user.click(input);
await user.paste('e'); // matches multiple fruits, opens listbox
await waitFor(() => {
expect(screen.getByRole('listbox', {hidden: true})).toBeInTheDocument();
});

scrollIntoView.mockClear();
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');

expect(scrollIntoView).toHaveBeenCalledWith({block: 'nearest'});
} finally {
delete (HTMLElement.prototype as unknown as {scrollIntoView?: unknown})
.scrollIntoView;
}
});
});
Loading