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
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- `ExternalLink`: No longer adds `noreferrer` to the `rel` attribute. `noopener` is still applied. Consumers relying on the previous behavior should pass `rel="noopener noreferrer"` explicitly ([#26968](https://github.com/WordPress/gutenberg/pull/26968)).

### Internal

- `NavigableContainer`: Refactor from class component to function component with hooks ([#77171](https://github.com/WordPress/gutenberg/pull/77171)).

## 32.6.0 (2026-04-15)

### Documentation
Expand Down
261 changes: 120 additions & 141 deletions packages/components/src/navigable-container/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { Component, forwardRef } from '@wordpress/element';
import { forwardRef, useRef, useEffect, useCallback } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';
import { focus } from '@wordpress/dom';

/**
Expand All @@ -28,163 +29,141 @@ function cycleValue( value: number, total: number, offset: number ) {
return nextValue;
}

class NavigableContainer extends Component< NavigableContainerProps > {
container?: HTMLDivElement;

constructor( args: NavigableContainerProps ) {
super( args );
this.onKeyDown = this.onKeyDown.bind( this );
this.bindContainer = this.bindContainer.bind( this );

this.getFocusableContext = this.getFocusableContext.bind( this );
this.getFocusableIndex = this.getFocusableIndex.bind( this );
}

componentDidMount() {
if ( ! this.container ) {
return;
}

// We use DOM event listeners instead of React event listeners
// because we want to catch events from the underlying DOM tree
// The React Tree can be different from the DOM tree when using
// portals. Block Toolbars for instance are rendered in a separate
// React Trees.
this.container.addEventListener( 'keydown', this.onKeyDown );
}

componentWillUnmount() {
if ( ! this.container ) {
return;
}

this.container.removeEventListener( 'keydown', this.onKeyDown );
}

bindContainer( ref: HTMLDivElement ) {
const { forwardedRef } = this.props;
this.container = ref;
function UnforwardedNavigableContainer(
{
children,
stopNavigationEvents,
eventToOffset,
onNavigate = noop,
onKeyDown,
cycle = true,
onlyBrowserTabstops,
...restProps
}: NavigableContainerProps,
ref: ForwardedRef< HTMLDivElement >
) {
const containerRef = useRef< HTMLDivElement | null >( null );

const getFocusableIndex = useCallback(
( focusables: Element[], target: Element ) => {
return focusables.indexOf( target );
},
[]
);

const getFocusableContext = useCallback(
( target: Element ) => {
if ( ! containerRef.current ) {
return null;
}

if ( typeof forwardedRef === 'function' ) {
forwardedRef( ref );
} else if ( forwardedRef && 'current' in forwardedRef ) {
forwardedRef.current = ref;
}
}
const finder = onlyBrowserTabstops
? focus.tabbable
: focus.focusable;
const focusables = finder.find( containerRef.current );

getFocusableContext( target: Element ) {
if ( ! this.container ) {
const index = getFocusableIndex( focusables, target );
if ( index > -1 && target ) {
return { index, target, focusables };
}
return null;
}
},
[ onlyBrowserTabstops, getFocusableIndex ]
);

const { onlyBrowserTabstops } = this.props;
const finder = onlyBrowserTabstops ? focus.tabbable : focus.focusable;
const focusables = finder.find( this.container );

const index = this.getFocusableIndex( focusables, target );
if ( index > -1 && target ) {
return { index, target, focusables };
useEffect( () => {
const container = containerRef.current;
if ( ! container ) {
return;
}
return null;
}

getFocusableIndex( focusables: Element[], target: Element ) {
return focusables.indexOf( target );
}

onKeyDown( event: KeyboardEvent ) {
if ( this.props.onKeyDown ) {
this.props.onKeyDown( event );
}
function handleKeyDown( event: KeyboardEvent ) {
if ( onKeyDown ) {
onKeyDown( event );
}

const { getFocusableContext } = this;
const {
cycle = true,
eventToOffset,
onNavigate = noop,
stopNavigationEvents,
} = this.props;

const offset = eventToOffset( event );

// eventToOffset returns undefined if the event is not handled by the component.
if ( offset !== undefined && stopNavigationEvents ) {
// Prevents arrow key handlers bound to the document directly interfering.
event.stopImmediatePropagation();

// When navigating a collection of items, prevent scroll containers
// from scrolling. The preventDefault also prevents Voiceover from
// 'handling' the event, as voiceover will try to use arrow keys
// for highlighting text.
const targetRole = (
event.target as HTMLDivElement | null
)?.getAttribute( 'role' );
const targetHasMenuItemRole =
!! targetRole && MENU_ITEM_ROLES.includes( targetRole );

if ( targetHasMenuItemRole ) {
event.preventDefault();
const offset = eventToOffset( event );

// eventToOffset returns undefined if the event is not handled by the component.
if ( offset !== undefined && stopNavigationEvents ) {
// Prevents arrow key handlers bound to the document directly interfering.
event.stopImmediatePropagation();

// When navigating a collection of items, prevent scroll containers
// from scrolling. The preventDefault also prevents Voiceover from
// 'handling' the event, as voiceover will try to use arrow keys
// for highlighting text.
const targetRole = (
event.target as HTMLDivElement | null
)?.getAttribute( 'role' );
const targetHasMenuItemRole =
!! targetRole && MENU_ITEM_ROLES.includes( targetRole );

if ( targetHasMenuItemRole ) {
event.preventDefault();
}
}
}

if ( ! offset ) {
return;
}
if ( ! offset ) {
return;
}

const activeElement = ( event.target as HTMLElement | null )
?.ownerDocument?.activeElement;
if ( ! activeElement ) {
return;
}
const activeElement = ( event.target as HTMLElement | null )
?.ownerDocument?.activeElement;
if ( ! activeElement ) {
return;
}

const context = getFocusableContext( activeElement );
if ( ! context ) {
return;
}
const context = getFocusableContext( activeElement );
if ( ! context ) {
return;
}

const { index, focusables } = context;
const nextIndex = cycle
? cycleValue( index, focusables.length, offset )
: index + offset;
const { index, focusables } = context;
const nextIndex = cycle
? cycleValue( index, focusables.length, offset )
: index + offset;

if ( nextIndex >= 0 && nextIndex < focusables.length ) {
focusables[ nextIndex ].focus();
onNavigate( nextIndex, focusables[ nextIndex ] );
if ( nextIndex >= 0 && nextIndex < focusables.length ) {
focusables[ nextIndex ].focus();
onNavigate( nextIndex, focusables[ nextIndex ] as HTMLElement );

// `preventDefault()` on tab to avoid having the browser move the focus
// after this component has already moved it.
if ( event.code === 'Tab' ) {
event.preventDefault();
// `preventDefault()` on tab to avoid having the browser move the focus
// after this component has already moved it.
if ( event.code === 'Tab' ) {
event.preventDefault();
}
}
}
}

render() {
const {
children,
stopNavigationEvents,
eventToOffset,
onNavigate,
onKeyDown,
cycle,
onlyBrowserTabstops,
forwardedRef,
...restProps
} = this.props;
return (
<div ref={ this.bindContainer } { ...restProps }>
{ children }
</div>
);
}
// We use DOM event listeners instead of React event listeners
// because we want to catch events from the underlying DOM tree.
// The React Tree can be different from the DOM tree when using
// portals. Block Toolbars for instance are rendered in a separate
// React Trees.
container.addEventListener( 'keydown', handleKeyDown );
return () => {
container.removeEventListener( 'keydown', handleKeyDown );
};
}, [
onKeyDown,
eventToOffset,
stopNavigationEvents,
cycle,
onNavigate,
getFocusableContext,
] );

const mergedRef = useMergeRefs( [ containerRef, ref ] );

return (
<div ref={ mergedRef } { ...restProps }>
{ children }
</div>
);
}

const forwardedNavigableContainer = (
props: NavigableContainerProps,
ref: ForwardedRef< HTMLDivElement >
) => {
return <NavigableContainer { ...props } forwardedRef={ ref } />;
};
forwardedNavigableContainer.displayName = 'NavigableContainer';
const NavigableContainer = forwardRef( UnforwardedNavigableContainer );
NavigableContainer.displayName = 'NavigableContainer';

export default forwardRef( forwardedNavigableContainer );
export default NavigableContainer;
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,30 @@ describe( 'NavigableMenu', () => {
expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 2 );
} );

it( 'should keep forwarded callback refs stable across rerenders', () => {
const refSpy = jest.fn();

const { rerender } = render(
<NavigableMenu ref={ refSpy }>
<button>Item 1</button>
</NavigableMenu>
);

expect( refSpy ).toHaveBeenCalledTimes( 1 );
expect( refSpy ).toHaveBeenCalledWith( expect.any( HTMLElement ) );

rerender(
<NavigableMenu ref={ refSpy }>
<button>Item 1</button>
</NavigableMenu>
);

// With a stable merged ref (useMergeRefs), the callback ref should
// not be called again on rerender. Previously, an inline ref callback
// would cause React to detach (null) and reattach on every render.
expect( refSpy ).toHaveBeenCalledTimes( 1 );
} );

it( 'skips its internal logic when the tab key is pressed', async () => {
const user = userEvent.setup();

Expand Down
6 changes: 1 addition & 5 deletions packages/components/src/navigable-container/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import type { ForwardedRef, ReactNode } from 'react';
import type { ReactNode } from 'react';

/**
* Internal dependencies
Expand Down Expand Up @@ -35,10 +35,6 @@ export type NavigableContainerProps = WordPressComponentProps<
* Gets an offset, given an event.
*/
eventToOffset: ( event: KeyboardEvent ) => -1 | 0 | 1 | undefined;
/**
* The forwarded ref.
*/
forwardedRef?: ForwardedRef< any >;
/**
* Whether to only consider browser tab stops.
*
Expand Down
Loading