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/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Breaking Changes

- `IconButton`: Require `shortcut.label` for a human-readable accessible shortcut description. Generate it with `shortcutAriaLabel` from `@wordpress/keycodes`. ([#80402](https://github.com/WordPress/gutenberg/pull/80402))

### Enhancements

- Add `SearchableChipSelect` primitive ([#80779](https://github.com/WordPress/gutenberg/pull/80779)).
Expand Down
24 changes: 20 additions & 4 deletions packages/ui/src/icon-button/icon-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { forwardRef } from '@wordpress/element';
import { Button } from '../button';
import { Icon } from '../icon';
import * as Tooltip from '../tooltip';
import {
KeyboardShortcutDescription,
KeyboardShortcutDisplay,
useKeyboardShortcutProps,
} from '../utils/keyboard-shortcut';
import styles from './style.module.css';
import { type IconButtonProps } from './types';

Expand All @@ -29,39 +34,50 @@ export const IconButton = forwardRef< HTMLButtonElement, IconButtonProps >(
size,
shortcut,
positioner,
'aria-describedby': ariaDescribedBy,
'aria-keyshortcuts': ariaKeyShortcuts,
...restProps
}: IconButtonProps & { children?: unknown },
ref
) {
const classes = clsx( styles[ 'icon-button' ], className );
const { descriptionId, targetProps } = useKeyboardShortcutProps( {
'aria-describedby': ariaDescribedBy,
'aria-keyshortcuts': ariaKeyShortcuts,
shortcut,
} );

return (
<Tooltip.Root>
<Tooltip.Trigger
ref={ ref }
{ ...targetProps }
disabled={ disabled && ! focusableWhenDisabled }
render={
<Button
{ ...restProps }
size={ size }
aria-label={ label }
aria-keyshortcuts={ shortcut?.ariaKeyShortcut }
disabled={ disabled }
focusableWhenDisabled={ focusableWhenDisabled }
/>
}
className={ classes }
>
<Icon icon={ icon } size={ 24 } className={ styles.icon } />
{ shortcut && descriptionId && (
<KeyboardShortcutDescription
descriptionId={ descriptionId }
shortcut={ shortcut }
/>
) }
</Tooltip.Trigger>
<Tooltip.Popup positioner={ positioner }>
{ label }
{ shortcut && (
<>
{ ' ' }
<span aria-hidden="true">
{ shortcut.displayShortcut }
</span>
<KeyboardShortcutDisplay shortcut={ shortcut } />
</>
) }
</Tooltip.Popup>
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/icon-button/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
upload,
wordpress,
} from '@wordpress/icons';
import { displayShortcut, ariaKeyShortcut } from '@wordpress/keycodes';
import {
displayShortcut,
ariaKeyShortcut,
shortcutAriaLabel,
} from '@wordpress/keycodes';
import { IconButton } from '../index';
import * as Tooltip from '../../tooltip';

Expand Down Expand Up @@ -125,8 +129,14 @@ export const Pressed: Story = {
const EXAMPLE_SHORTCUT_OBJECT = {
displayShortcut: displayShortcut.primary( 'c' ),
ariaKeyShortcut: ariaKeyShortcut.primary( 'c' ),
label: shortcutAriaLabel.primary( 'c' ),
};

/**
* Use the `displayShortcut`, `ariaKeyShortcut`, and `shortcutAriaLabel` helpers
* from `@wordpress/keycodes` to create the visual, ARIA-compatible, and
* human-readable representations of the shortcut.
*/
export const WithShortcut: Story = {
Comment thread
ciampo marked this conversation as resolved.
...Default,
args: {
Expand Down
81 changes: 66 additions & 15 deletions packages/ui/src/icon-button/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,30 @@ describe( 'IconButton', () => {
} );

describe( 'shortcut', () => {
it( 'sets aria-keyshortcuts attribute on the button', () => {
const { rerender } = render(
<IconButton
label="Save"
icon={ <svg /> }
shortcut={ {
displayShortcut: '⌘S',
ariaKeyShortcut: 'Meta+S',
} }
/>
it( 'uses the human-readable label in the accessible description', () => {
const externalDescriptionId = 'external-description';

render(
<>
<span id={ externalDescriptionId }>Available offline.</span>
<IconButton
label="Save"
icon={ <svg /> }
aria-describedby={ externalDescriptionId }
shortcut={ {
displayShortcut: '⌘S',
ariaKeyShortcut: 'Meta+S',
label: 'Command S',
} }
/>
</>
);

const button = screen.getByRole( 'button', { name: 'Save' } );
expect( button ).toHaveAttribute( 'aria-keyshortcuts', 'Meta+S' );

// The aria-keyshortcuts attribute is removed when there is no
// `shortcut` prop.
rerender( <IconButton label="Save" icon={ <svg /> } /> );
expect( button ).not.toHaveAttribute( 'aria-keyshortcuts' );
expect( button ).toHaveAccessibleDescription(
'Available offline. Keyboard shortcut: Command S'
);
} );

it( 'displays the shortcut in the tooltip but hides it from assistive technology', async () => {
Expand All @@ -98,6 +103,7 @@ describe( 'IconButton', () => {
shortcut={ {
displayShortcut: '⌘S',
ariaKeyShortcut: 'Meta+S',
label: 'Command S',
} }
/>
);
Expand All @@ -114,6 +120,51 @@ describe( 'IconButton', () => {
'aria-hidden',
'true'
);
expect( screen.getByText( '⌘S' ) ).toHaveAttribute( 'dir', 'ltr' );
} );

it( 'preserves direct ARIA props when shortcut metadata is omitted', () => {
const externalDescriptionId = 'external-description';

render(
<>
<span id={ externalDescriptionId }>Available offline.</span>
<IconButton
label="Save"
icon={ <svg /> }
aria-describedby={ externalDescriptionId }
aria-keyshortcuts="Meta+S"
/>
</>
);

const button = screen.getByRole( 'button', {
name: 'Save',
description: 'Available offline.',
} );
expect( button ).toHaveAttribute( 'aria-keyshortcuts', 'Meta+S' );
} );

it( 'keeps shortcut metadata available when focusable while disabled', () => {
render(
<IconButton
label="Save"
icon={ <svg /> }
disabled
shortcut={ {
displayShortcut: '⌘S',
ariaKeyShortcut: 'Meta+S',
label: 'Command S',
} }
/>
);

const button = screen.getByRole( 'button', {
name: 'Save',
description: 'Keyboard shortcut: Command S',
} );
expect( button ).toHaveAttribute( 'aria-disabled', 'true' );
expect( button ).toHaveAttribute( 'aria-keyshortcuts', 'Meta+S' );
} );
} );
} );
20 changes: 4 additions & 16 deletions packages/ui/src/icon-button/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ButtonProps } from '../button/types';
import { type IconProps } from '../icon/types';
import { type PopupProps as TooltipPopupProps } from '../tooltip/types';
import type { KeyboardShortcut } from '../utils/keyboard-shortcut';

export type IconButtonProps = Omit< ButtonProps, 'children' > & {
/**
Expand All @@ -19,23 +20,10 @@ export type IconButtonProps = Omit< ButtonProps, 'children' > & {
* shortcut is displayed in the tooltip and announced to assistive technology.
*
* **Note**: This prop is for display and accessibility purposes only — the
* consumer is responsible for implementing the actual keyboard event handler.
* consumer is responsible for registering the keyboard shortcut and keeping
* its handler synchronized with the button's disabled state.
*/
shortcut?: {
/**
* The human-readable representation of the shortcut, displayed in the
* tooltip. Use platform-appropriate symbols (e.g., "⌘S" on macOS,
* "Ctrl+S" on Windows).
*/
displayShortcut: string;
/**
* The shortcut in a format compatible with the
* [aria-keyshortcuts](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts)
* attribute. Use "+" to separate keys and standard key names
* (e.g., "Meta+S", "Control+Shift+P").
*/
ariaKeyShortcut: string;
};
shortcut?: KeyboardShortcut;

/**
* Customize how the tooltip is positioned relative to the button. Accepts
Expand Down
94 changes: 94 additions & 0 deletions packages/ui/src/utils/keyboard-shortcut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { AriaAttributes } from 'react';
import { useId } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { VisuallyHidden } from '../visually-hidden';

export type KeyboardShortcut = {
/**
* The visual representation of the shortcut (e.g., "⌘S" on macOS or
* "Ctrl+S" on Windows).
*/
displayShortcut: string;

/**
* The shortcut in a format compatible with the
* [aria-keyshortcuts](https://www.w3.org/TR/wai-aria-1.3/#aria-keyshortcuts)
* attribute. Use "+" to separate keys and standard key names
* (e.g., "Meta+S", "Control+Shift+P").
*/
ariaKeyShortcut: string;

/**
* A plain-text label for the shortcut, used in its accessible description
* (e.g., "Command Shift S"). Generate this value with
* `shortcutAriaLabel` from `@wordpress/keycodes`.
*/
label: string;
};

type ShortcutAriaProps = Pick<
AriaAttributes,
'aria-describedby' | 'aria-keyshortcuts'
>;

function useKeyboardShortcutProps( {
'aria-describedby': ariaDescribedBy,
'aria-keyshortcuts': ariaKeyShortcuts,
shortcut,
}: ShortcutAriaProps & { shortcut?: KeyboardShortcut } ) {
const generatedDescriptionId = useId();
const descriptionId = shortcut ? generatedDescriptionId : undefined;
const describedBy = [ ariaDescribedBy, descriptionId ]
.filter( Boolean )
.join( ' ' );

return {
descriptionId,
targetProps: {
'aria-describedby': describedBy || undefined,
'aria-keyshortcuts': shortcut?.ariaKeyShortcut ?? ariaKeyShortcuts,
},
};
}

function KeyboardShortcutDescription( {
descriptionId,
shortcut,
}: {
descriptionId: string;
shortcut: KeyboardShortcut;
} ) {
return (
<VisuallyHidden
id={ descriptionId }
aria-hidden="true"
render={ <span /> }
>
{ sprintf(
/* translators: %s: keyboard shortcut. */
__( 'Keyboard shortcut: %s' ),
shortcut.label ?? shortcut.ariaKeyShortcut
Comment thread
ciampo marked this conversation as resolved.
) }
</VisuallyHidden>
);
}

function KeyboardShortcutDisplay( {
className,
shortcut,
}: {
className?: string;
shortcut: KeyboardShortcut;
} ) {
return (
<span aria-hidden="true" className={ className } dir="ltr">
{ shortcut.displayShortcut }
</span>
);
}

export {
KeyboardShortcutDescription,
KeyboardShortcutDisplay,
useKeyboardShortcutProps,
};
Loading