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
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
### Bug Fixes

- `Tabs`: Restore the `fit-content` width of horizontal tab lists, which stretched to full width after the SCSS Modules migration ([#84626](https://github.com/WordPress/gutenberg/pull/84626)).
- `Snackbar`: Return focus to the previously focused control when dismissing a focused snackbar outside SnackbarList, including portaled snackbars ([#84342](https://github.com/WordPress/gutenberg/pull/84342)).
- `InputControl`, `NumberControl`, `UnitControl`: Ignore the drag gesture for touch input, where it has no cursor affordance and is easy to trigger by accident ([#38865](https://github.com/WordPress/gutenberg/issues/38865), [#81519](https://github.com/WordPress/gutenberg/pull/81519)).
- `Modal`: Keep focus inside the dialog when `focusOnMount="firstContentElement"` finds no tabbable elements in the content ([#84344](https://github.com/WordPress/gutenberg/pull/84344)).
- `navigateRegions`: Only outline the Publish and Save controls when their region has focus ([#84343](https://github.com/WordPress/gutenberg/pull/84343)).
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/snackbar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,5 @@ Used to provide a custom spoken message.

## Related components

- Use SnackbarList to display multiple snackbars.
- To create a prominent message that requires a higher-level of attention, use a Notice.
37 changes: 33 additions & 4 deletions packages/components/src/snackbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react';
import type {
FocusEvent,
ForwardedRef,
KeyboardEvent,
MouseEvent,
} from 'react';
import clsx from 'clsx';
import { useIsPresent } from 'framer-motion';
import { speak } from '@wordpress/a11y';
import { useMergeRefs } from '@wordpress/compose';
import {
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -59,14 +65,36 @@ function UnforwardedSnackbar(
ref: ForwardedRef< any >
) {
const isPresent = useIsPresent();
const snackbarRef = useRef< HTMLDivElement >( null );
const mergedRef = useMergeRefs( [ snackbarRef, ref ] );
const focusReturnRef = useRef< HTMLElement | SVGElement | null >( null );

function onFocus(
event: FocusEvent< HTMLDivElement, HTMLElement | SVGElement >
) {
if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
focusReturnRef.current = event.relatedTarget;
}
}

function dismissMe( event: KeyboardEvent | MouseEvent ) {
if ( event && event.preventDefault ) {
event.preventDefault();
}

// Prevent focus loss by moving it to the list element.
listRef?.current?.focus();
if ( listRef?.current ) {
// Prevent focus loss by moving it to the list element.
listRef.current.focus();
} else if (
snackbarRef.current?.contains(
snackbarRef.current.ownerDocument.activeElement
)
) {
// DOM order does not identify a meaningful return target for portals.
if ( focusReturnRef.current?.isConnected ) {
focusReturnRef.current.focus( { preventScroll: true } );
}
}

onDismiss?.();
onRemove?.();
Expand Down Expand Up @@ -125,7 +153,8 @@ function UnforwardedSnackbar(

return (
<div
ref={ ref }
ref={ mergedRef }
onFocus={ onFocus }
className={ classes }
onClick={ ! explicitDismiss ? dismissMe : undefined }
tabIndex={ 0 }
Expand Down
173 changes: 173 additions & 0 deletions packages/components/src/snackbar/test/index.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { userEvent } from 'vitest/browser';
import { act, screen, within } from '@testing-library/react';
import { render } from 'vitest-browser-react';
import { speak } from '@wordpress/a11y';
import { createPortal, useState } from '@wordpress/element';
import { SVG, Path } from '@wordpress/primitives';
import Snackbar from '../index';

Expand Down Expand Up @@ -155,6 +156,178 @@ describe( 'Snackbar', () => {
expect( onDismiss ).toHaveBeenCalledTimes( 1 );
} );

it.each( [ false, true ] )(
'restores standalone focus on keyboard dismissal with explicitDismiss=%s',
async ( explicitDismiss ) => {
function DismissibleSnackbar() {
const [ isVisible, setIsVisible ] = useState( true );
return (
<>
<button>Previous control</button>
{ isVisible && (
<Snackbar
explicitDismiss={ explicitDismiss }
actions={ [ { label: 'View post', url: '#' } ] }
onRemove={ () => setIsVisible( false ) }
>
Message
</Snackbar>
) }
</>
);
}
await render( <DismissibleSnackbar /> );
screen.getByRole( 'button', { name: 'Previous control' } ).focus();
screen
.getByRole( 'button', { name: 'Dismiss this notice' } )
.focus();

await userEvent.keyboard( '{Enter}' );

expect( screen.queryByTestId( testId ) ).not.toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: 'Previous control' } )
).toHaveFocus();
}
);

it.each( [ 'keyboard', 'pointer' ] )(
'returns focus to the last outside control when dismissing a portaled snackbar with %s',
async ( input ) => {
function PortaledSnackbar() {
const [ isVisible, setIsVisible ] = useState( true );
return (
<>
<button>Previous control</button>
<button>Unrelated footer control</button>
{ isVisible &&
createPortal(
<Snackbar
explicitDismiss
actions={ [
{ label: 'View post', url: '#' },
] }
onRemove={ () => setIsVisible( false ) }
>
Message
</Snackbar>,
document.body
) }
</>
);
}
await render( <PortaledSnackbar /> );
const previousControl = screen.getByRole( 'button', {
name: 'Previous control',
} );
previousControl.focus();

if ( input === 'keyboard' ) {
screen.getByTestId( testId ).focus();
await userEvent.keyboard( '{Tab}{Tab}{Enter}' );
} else {
await userEvent.click(
screen.getByRole( 'button', {
name: 'Dismiss this notice',
} )
);
}

expect( screen.queryByTestId( testId ) ).not.toBeInTheDocument();
expect( previousControl ).toHaveFocus();
}
);

it( 'updates the focus return target when focus enters the snackbar again', async () => {
await render(
<>
<button>First control</button>
<Snackbar
explicitDismiss
actions={ [ { label: 'View post', url: '#' } ] }
>
Message
</Snackbar>
<button>Second control</button>
</>
);
const dismissControl = screen.getByRole( 'button', {
name: 'Dismiss this notice',
} );
screen.getByRole( 'button', { name: 'First control' } ).focus();
dismissControl.focus();
const secondControl = screen.getByRole( 'button', {
name: 'Second control',
} );
secondControl.focus();
screen.getByRole( 'link', { name: 'View post' } ).focus();
await userEvent.keyboard( '{Tab}{Enter}' );

expect( secondControl ).toHaveFocus();
} );

it( 'leaves outside focus unchanged when dismissal does not focus the snackbar', async () => {
const onRemove = vi.fn();
await render(
<>
<button>Previous control</button>
<Snackbar onRemove={ onRemove }>Message</Snackbar>
<button>Current control</button>
</>
);
const currentControl = screen.getByRole( 'button', {
name: 'Current control',
} );
currentControl.focus();
// Cancel the pointer's focus change while allowing its click to dismiss.
const snackbar = screen.getByTestId( testId );
snackbar.addEventListener(
'mousedown',
( event ) => event.preventDefault(),
{ once: true }
);
await userEvent.click( snackbar );

expect( onRemove ).toHaveBeenCalledTimes( 1 );
expect( currentControl ).toHaveFocus();
} );

it( 'dismisses safely when the previous control has been removed', async () => {
function DismissibleSnackbar( {
showPreviousControl,
}: {
showPreviousControl: boolean;
} ) {
const [ isVisible, setIsVisible ] = useState( true );
return (
<>
{ showPreviousControl && <button>Previous control</button> }
<button>Unrelated control</button>
{ isVisible && (
<Snackbar
explicitDismiss
onRemove={ () => setIsVisible( false ) }
>
Message
</Snackbar>
) }
</>
);
}
const { rerender } = await render(
<DismissibleSnackbar showPreviousControl />
);
screen.getByRole( 'button', { name: 'Previous control' } ).focus();
screen.getByRole( 'button', { name: 'Dismiss this notice' } ).focus();
await rerender( <DismissibleSnackbar showPreviousControl={ false } /> );
await userEvent.keyboard( '{Enter}' );

expect( screen.queryByTestId( testId ) ).not.toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: 'Unrelated control' } )
).not.toHaveFocus();
} );

describe( 'actions', () => {
it( 'should render only the first action with a warning when multiple actions are passed', async () => {
await render(
Expand Down
Loading