Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
UI: Simplify active option rings to input focus
  • Loading branch information
ciampo committed Oct 23, 2026
commit 4f042fe5a1ef038ef230903269cc87b3959d24dd
2 changes: 1 addition & 1 deletion packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
- `Avatar`: Use `color` for fallback backgrounds with contrasting text and icons ([#84824](https://github.com/WordPress/gutenberg/pull/84824)).
- `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)).
- `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)).
- `Menu`, `Select`, `Combobox`, `Autocomplete`, and searchable select controls: Use neutral highlights and a zero-offset ring for keyboard-focused items. Keep keyboard targets independent from pointer hover, and do not show option rings for typing alone. Keep open submenu parents highlighted with the same neutral colors ([#84087](https://github.com/WordPress/gutenberg/pull/84087)).
- `Menu`, `Select`, `Combobox`, `Autocomplete`, and searchable select controls: Use neutral highlights and zero-offset rings. Keep active targets independent from pointer hover. Combobox and autocomplete options show rings while the input has focus, including options highlighted by pointer opening or typing. Keep open submenu parents highlighted with the same neutral colors ([#84087](https://github.com/WordPress/gutenberg/pull/84087)).
- `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)).
- `Tabs`: Add interaction backgrounds to unselected tabs, preserve keyboard focus rings, and replace vertical chevrons with content-height selection lines without widening minimal tab lists ([#84572](https://github.com/WordPress/gutenberg/pull/84572)).
- `Dialog`: Use the WordPress compatibility overlay slot as the default portal container when available, preserving explicit container overrides, including null to delay rendering ([#84379](https://github.com/WordPress/gutenberg/pull/84379)).
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/form/primitives/autocomplete/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mergeProps } from '@base-ui/react/merge-props';
import { forwardRef } from '@wordpress/element';
import { Input as InputPrimitive } from '../input';
import type { AutocompleteInputProps } from './types';
import { useKeyboardHighlight } from '../../../utils/item-popup/keyboard-highlight-context';
import { useInputFocus } from '../../../utils/item-popup/input-focus-context';

const DEFAULT_RENDER = ( props: AutocompleteInputProps ) => (
<InputPrimitive { ...props } />
Expand All @@ -14,7 +14,7 @@ export const Input = forwardRef< HTMLInputElement, AutocompleteInputProps >(
{ render = DEFAULT_RENDER, ...restProps },
ref
) {
const { inputProps } = useKeyboardHighlight();
const { inputProps } = useInputFocus();
return (
<_Autocomplete.Input
ref={ ref }
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/form/primitives/autocomplete/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from './item-description';
import { ItemLabel } from './item-label';
import type { AutocompleteItemProps } from './types';
import { useKeyboardHighlight } from '../../../utils/item-popup/keyboard-highlight-context';
import { useInputFocus } from '../../../utils/item-popup/input-focus-context';

const ITEM_CONTENT_COMPONENTS = {
Label: ItemLabel,
Expand All @@ -33,7 +33,7 @@ export const Item = forwardRef< HTMLDivElement, AutocompleteItemProps >(
},
ref
) {
const { keyboardHighlight } = useKeyboardHighlight();
const { inputFocused } = useInputFocus();
const { contentChildren, itemAriaProps } = useItemContent(
children,
ITEM_CONTENT_COMPONENTS,
Expand All @@ -45,7 +45,7 @@ export const Item = forwardRef< HTMLDivElement, AutocompleteItemProps >(
);
return (
<_Autocomplete.Item
data-keyboard-highlight={ keyboardHighlight ? '' : undefined }
data-input-focused={ inputFocused ? '' : undefined }
className={ clsx(
defenseStyles.div,
resetStyles[ 'box-sizing' ],
Expand Down
13 changes: 6 additions & 7 deletions packages/ui/src/form/primitives/autocomplete/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { AutocompleteRootProps } from './types';
import { DirectionProvider } from '../../../utils/direction-provider';
import { AutocompleteGridContext } from './context';
import {
KeyboardHighlightContext,
useKeyboardHighlightState,
} from '../../../utils/item-popup/keyboard-highlight-context';
InputFocusContext,
useInputFocusState,
} from '../../../utils/item-popup/input-focus-context';

/**
* Low-level primitive for an autocomplete input that suggests options as
Expand All @@ -19,20 +19,19 @@ import {
export const Root: typeof _Autocomplete.Root = function Root(
props: AutocompleteRootProps
) {
const { contextValue, onOpenChange } = useKeyboardHighlightState( props );
const contextValue = useInputFocusState();

return (
<AutocompleteGridContext.Provider value={ Boolean( props.grid ) }>
<DirectionProvider>
<KeyboardHighlightContext.Provider value={ contextValue }>
<InputFocusContext.Provider value={ contextValue }>
<_Autocomplete.Root
{ ...props }
highlightItemOnHover={
props.highlightItemOnHover ?? false
}
onOpenChange={ onOpenChange }
/>
</KeyboardHighlightContext.Provider>
</InputFocusContext.Provider>
</DirectionProvider>
</AutocompleteGridContext.Provider>
);
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/form/primitives/combobox/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Input as InputPrimitive } from '../input';
import { InputLayout } from '../input-layout';
import type { ComboboxInputProps } from './types';
import styles from './style.module.css';
import { useKeyboardHighlight } from '../../../utils/item-popup/keyboard-highlight-context';
import { useInputFocus } from '../../../utils/item-popup/input-focus-context';

const DEFAULT_RENDER = ( props: ComboboxInputProps ) => (
<InputPrimitive
Expand All @@ -25,7 +25,7 @@ export const Input = forwardRef< HTMLInputElement, ComboboxInputProps >(
{ render = DEFAULT_RENDER, ...restProps },
ref
) {
const { inputProps } = useKeyboardHighlight();
const { inputProps } = useInputFocus();
return (
<_Combobox.Input
ref={ ref }
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/form/primitives/combobox/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from './item-description';
import { ItemLabel } from './item-label';
import type { ComboboxItemProps } from './types';
import { useKeyboardHighlight } from '../../../utils/item-popup/keyboard-highlight-context';
import { useInputFocus } from '../../../utils/item-popup/input-focus-context';

const ITEM_CONTENT_COMPONENTS = {
Label: ItemLabel,
Expand All @@ -36,7 +36,7 @@ export const Item = forwardRef< HTMLDivElement, ComboboxItemProps >(
},
ref
) {
const { keyboardHighlight } = useKeyboardHighlight();
const { inputFocused } = useInputFocus();
const { contentChildren, itemAriaProps } = useItemContent(
children,
ITEM_CONTENT_COMPONENTS,
Expand All @@ -49,7 +49,7 @@ export const Item = forwardRef< HTMLDivElement, ComboboxItemProps >(

return (
<_Combobox.Item
data-keyboard-highlight={ keyboardHighlight ? '' : undefined }
data-input-focused={ inputFocused ? '' : undefined }
className={ clsx(
defenseStyles.div,
resetStyles[ 'box-sizing' ],
Expand Down
14 changes: 6 additions & 8 deletions packages/ui/src/form/primitives/combobox/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Combobox as _Combobox } from '@base-ui/react/combobox';
import type { ComboboxRootProps } from './types';
import { DirectionProvider } from '../../../utils/direction-provider';
import {
KeyboardHighlightContext,
useKeyboardHighlightState,
} from '../../../utils/item-popup/keyboard-highlight-context';
InputFocusContext,
useInputFocusState,
} from '../../../utils/item-popup/input-focus-context';

/**
* Low-level primitive for a combobox that has an associated selection state.
Expand All @@ -17,18 +17,16 @@ export function Root<
Multiple extends boolean | undefined = false,
Item = Value,
>( props: ComboboxRootProps< Value, Multiple, Item > ) {
const { contextValue, onOpenChange } =
useKeyboardHighlightState< Value >( props );
const contextValue = useInputFocusState();

return (
<DirectionProvider>
<KeyboardHighlightContext.Provider value={ contextValue }>
<InputFocusContext.Provider value={ contextValue }>
<_Combobox.Root
{ ...props }
highlightItemOnHover={ props.highlightItemOnHover ?? false }
onOpenChange={ onOpenChange }
/>
</KeyboardHighlightContext.Provider>
</InputFocusContext.Provider>
</DirectionProvider>
);
}
15 changes: 13 additions & 2 deletions packages/ui/src/form/primitives/combobox/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,11 @@ export const Default: Story = {
};

/**
* Pointer use and typing do not show option rings. Navigate the options with
* arrow keys to show a ring, then hover another option to compare the targets.
* Highlighted options show rings while the input has focus. Open the selected
* option with the pointer, or type to automatically highlight another option.
*/
export const KeyboardFocus: Story = {
name: 'Active option rings',
args: {
...Default.args,
defaultValue: ITEMS[ 1 ],
Expand All @@ -109,6 +110,16 @@ export const KeyboardFocus: Story = {
highlightItemOnHover: { control: 'boolean' },
autoHighlight: { control: 'boolean' },
},
render: ( args ) => (
<>
<p>
Open with the pointer to see a ring on Apricot. Type{ ' ' }
<code>ap</code> to see the ring move to Apple. Hovering another
option leaves the ring on the active option.
</p>
<Combobox.Root { ...args } />
</>
),
};

export const Compact: Story = {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/utils/css/item-popup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@
}

.popup .item[data-highlighted]:focus-visible,
.item[data-highlighted][data-keyboard-highlight] {
.item[data-highlighted][data-input-focused] {
/* Keep the ring visible above adjacent rows and wp-admin's resets. */
--_gcd-div-outline: var(--wpds-border-width-focus) solid var(--focus-color, var(--wpds-color-stroke-focus));

Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/utils/item-popup/input-focus-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Combobox as _Combobox } from '@base-ui/react/combobox';
import {
createContext,
useContext,
useMemo,
useState,
} from '@wordpress/element';

export const InputFocusContext = createContext< {
inputFocused: boolean;
inputProps: Pick<
_Combobox.Input.Props,
'onFocusCapture' | 'onBlurCapture'
>;
} >( {
inputFocused: false,
inputProps: {},
} );

export const useInputFocus = () => useContext( InputFocusContext );

export function useInputFocusState() {
const [ inputFocused, setInputFocused ] = useState( false );
return useMemo(
() => ( {
inputFocused,
inputProps: {
onFocusCapture: () => setInputFocused( true ),
onBlurCapture: () => setInputFocused( false ),
},
} ),
[ inputFocused ]
);
}
81 changes: 0 additions & 81 deletions packages/ui/src/utils/item-popup/keyboard-highlight-context.ts

This file was deleted.