Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Patterns: alternative grid layout to improve keyboard accessibility #52357

Merged
merged 6 commits into from
Jul 10, 2023
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
233 changes: 108 additions & 125 deletions packages/edit-site/src/components/page-patterns/grid-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
MenuGroup,
MenuItem,
__experimentalHStack as HStack,
__unstableCompositeItem as CompositeItem,
Tooltip,
Flex,
} from '@wordpress/components';
Expand All @@ -31,7 +30,6 @@ import {
} from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { store as reusableBlocksStore } from '@wordpress/reusable-blocks';
import { DELETE, BACKSPACE } from '@wordpress/keycodes';

/**
* Internal dependencies
Expand Down Expand Up @@ -65,12 +63,6 @@ function GridItem( { categoryId, item, ...props } ) {
categoryType: item.type,
} );

const onKeyDown = ( event ) => {
if ( DELETE === event.keyCode || BACKSPACE === event.keyCode ) {
setIsDeleteDialogOpen( true );
}
};

const isEmpty = ! item.blocks?.length;
const patternClassNames = classnames( 'edit-site-patterns__pattern', {
'is-placeholder': isEmpty,
Expand Down Expand Up @@ -137,129 +129,120 @@ function GridItem( { categoryId, item, ...props } ) {
);

return (
<>
<div className={ patternClassNames }>
<CompositeItem
className={ previewClassNames }
role="option"
as="div"
// Even though still incomplete, passing ids helps performance.
// @see https://reakit.io/docs/composite/#performance.
id={ `edit-site-patterns-${ item.name }` }
{ ...props }
onClick={ item.type !== PATTERNS ? onClick : undefined }
onKeyDown={ isCustomPattern ? onKeyDown : undefined }
aria-label={ item.title }
aria-describedby={
ariaDescriptions.length
? ariaDescriptions
.map(
( _, index ) =>
`${ descriptionId }-${ index }`
)
.join( ' ' )
: undefined
}
<li className={ patternClassNames }>
<button
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a little odd using a button here, but all the accessibility guidelines I looked at suggested that this is better than trying to make a div behave like a button

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theoretically, it would be possible to use 'clickable div' elements but to make them fully accessible we should replicate all the native semantics and interaction of a button element, including Enter / Spacebar behaviors, which are different (keydown vs. keyup). That would require an unnecessary amount of ARIA and scripting. Historically, developers started using clickable div elements because a few years ago button elements couldn't be fully styled. Now that modern browsers allow to fully style buttons, it's best to just use a button.
Also, a button element with type="button" had no default action, which allows to not use event.preventDefault().

className={ previewClassNames }
// Even though still incomplete, passing ids helps performance.
// @see https://reakit.io/docs/composite/#performance.
id={ `edit-site-patterns-${ item.name }` }
{ ...props }
onClick={ item.type !== PATTERNS ? onClick : undefined }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a pattern is not editable, it is good to keep the button focusable for discoverability. Btw, the button doesn't do anything so we should communicate semantically that, with an aria-disabled="true" attribute. Also visually, there should be some indication that the button doesn't do anything.
See Focusability of disabled controls.

aria-disabled={ item.type !== PATTERNS ? 'false' : 'true' }
aria-label={ item.title }
aria-describedby={
ariaDescriptions.length
? ariaDescriptions
.map(
( _, index ) =>
`${ descriptionId }-${ index }`
)
.join( ' ' )
: undefined
}
>
{ isEmpty && __( 'Empty pattern' ) }
{ ! isEmpty && <BlockPreview blocks={ item.blocks } /> }
</button>
{ ariaDescriptions.map( ( ariaDescription, index ) => (
<div
key={ index }
hidden
id={ `${ descriptionId }-${ index }` }
>
{ isEmpty && __( 'Empty pattern' ) }
{ ! isEmpty && <BlockPreview blocks={ item.blocks } /> }
</CompositeItem>
{ ariaDescriptions.map( ( ariaDescription, index ) => (
<div
key={ index }
hidden
id={ `${ descriptionId }-${ index }` }
>
{ ariaDescription }
</div>
) ) }
{ ariaDescription }
</div>
) ) }
<HStack
className="edit-site-patterns__footer"
justify="space-between"
>
<HStack
aria-hidden="true"
className="edit-site-patterns__footer"
justify="space-between"
alignment="center"
justify="left"
spacing={ 3 }
className="edit-site-patterns__pattern-title"
>
<HStack
alignment="center"
justify="left"
spacing={ 3 }
className="edit-site-patterns__pattern-title"
>
{ itemIcon && (
<Icon
className="edit-site-patterns__pattern-icon"
icon={ itemIcon }
/>
) }
<Flex as="span" gap={ 0 } justify="left">
{ item.title }
{ item.type === PATTERNS && (
<Tooltip
position="top center"
text={ __(
'Theme patterns cannot be edited.'
) }
>
<span className="edit-site-patterns__pattern-lock-icon">
<Icon icon={ lockSmall } size={ 24 } />
</span>
</Tooltip>
) }
</Flex>
</HStack>
<DropdownMenu
icon={ moreHorizontal }
label={ __( 'Actions' ) }
className="edit-site-patterns__dropdown"
popoverProps={ { placement: 'bottom-end' } }
toggleProps={ {
className: 'edit-site-patterns__button',
isSmall: true,
describedBy: sprintf(
/* translators: %s: pattern name */
__( 'Action menu for %s pattern' ),
item.title
),
// The dropdown menu is not focusable using the
// keyboard as this would interfere with the grid's
// roving tab index system. Instead, keyboard users
// use keyboard shortcuts to trigger actions.
tabIndex: -1,
} }
>
{ ( { onClose } ) => (
<MenuGroup>
{ isCustomPattern && ! hasThemeFile && (
<RenameMenuItem
item={ item }
onClose={ onClose }
/>
{ itemIcon && (
<Icon
className="edit-site-patterns__pattern-icon"
icon={ itemIcon }
/>
) }
<Flex as="span" gap={ 0 } justify="left">
{ item.title }
{ item.type === PATTERNS && (
<Tooltip
position="top center"
text={ __(
'Theme patterns cannot be edited.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the terminology used here is meaningful for users. What a theme pattern is? As far as I can tell, there's no other reference in the UI to the term theme pattern. The term theme pattern is not presented visually anywhere, nor used in any other non-visual context.

) }
<DuplicateMenuItem
categoryId={ categoryId }
>
<span className="edit-site-patterns__pattern-lock-icon">
<Icon icon={ lockSmall } size={ 24 } />
</span>
</Tooltip>
) }
</Flex>
</HStack>
<DropdownMenu
icon={ moreHorizontal }
label={ __( 'Actions' ) }
className="edit-site-patterns__dropdown"
popoverProps={ { placement: 'bottom-end' } }
toggleProps={ {
className: 'edit-site-patterns__button',
isSmall: true,
describedBy: sprintf(
/* translators: %s: pattern name */
__( 'Action menu for %s pattern' ),
item.title
),
} }
>
{ ( { onClose } ) => (
<MenuGroup>
{ isCustomPattern && ! hasThemeFile && (
<RenameMenuItem
item={ item }
onClose={ onClose }
label={
isNonUserPattern
? __( 'Copy to My patterns' )
: __( 'Duplicate' )
}
/>
{ isCustomPattern && (
<MenuItem
onClick={ () =>
setIsDeleteDialogOpen( true )
}
>
{ hasThemeFile
? __( 'Clear customizations' )
: __( 'Delete' ) }
</MenuItem>
) }
</MenuGroup>
) }
</DropdownMenu>
</HStack>
</div>
) }
<DuplicateMenuItem
categoryId={ categoryId }
item={ item }
onClose={ onClose }
label={
isNonUserPattern
? __( 'Copy to My patterns' )
: __( 'Duplicate' )
}
/>
{ isCustomPattern && (
<MenuItem
onClick={ () =>
setIsDeleteDialogOpen( true )
}
>
{ hasThemeFile
? __( 'Clear customizations' )
: __( 'Delete' ) }
</MenuItem>
) }
</MenuGroup>
) }
</DropdownMenu>
</HStack>

{ isDeleteDialogOpen && (
<ConfirmDialog
confirmButtonText={ confirmButtonText }
Expand All @@ -269,7 +252,7 @@ function GridItem( { categoryId, item, ...props } ) {
{ confirmPrompt }
</ConfirmDialog>
) }
</>
</li>
);
}

Expand Down
13 changes: 3 additions & 10 deletions packages/edit-site/src/components/page-patterns/grid.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
/**
* WordPress dependencies
*/
import {
__unstableComposite as Composite,
__unstableUseCompositeState as useCompositeState,
__experimentalText as Text,
} from '@wordpress/components';
import { __experimentalText as Text } from '@wordpress/components';
import { useRef } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';

Expand All @@ -17,7 +13,6 @@ import GridItem from './grid-item';
const PAGE_SIZE = 100;

export default function Grid( { categoryId, items, ...props } ) {
const composite = useCompositeState( { wrap: true } );
const gridRef = useRef();

if ( ! items?.length ) {
Expand All @@ -29,8 +24,7 @@ export default function Grid( { categoryId, items, ...props } ) {

return (
<>
<Composite
{ ...composite }
<ul
role="listbox"
className="edit-site-patterns__grid"
{ ...props }
Expand All @@ -41,10 +35,9 @@ export default function Grid( { categoryId, items, ...props } ) {
key={ item.name }
item={ item }
categoryId={ categoryId }
{ ...composite }
/>
) ) }
</Composite>
</ul>
{ restLength > 0 && (
<Text variant="muted" as="p" align="center">
{ sprintf(
Expand Down
20 changes: 17 additions & 3 deletions packages/edit-site/src/components/page-patterns/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
}
}

.edit-site-patterns__section-header {
.screen-reader-shortcut:focus {
top: 0;
}
}

.edit-site-patterns__grid {
display: grid;
grid-template-columns: 1fr;
Expand All @@ -77,20 +83,28 @@
display: flex;
flex-direction: column;
.edit-site-patterns__preview {
border-radius: $radius-block-ui;
box-shadow: none;
border: none;
padding: 0;
background-color: unset;
box-sizing: border-box;
border-radius: 4px;
cursor: pointer;
overflow: hidden;

&:focus {
box-shadow: inset 0 0 0 2px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);

box-shadow: inset 0 0 0 0 $white, 0 0 0 2px var(--wp-admin-theme-color);
// Windows High Contrast mode will show this outline, but not the box-shadow.
outline: 2px solid transparent;
}

&.is-inactive {
cursor: default;
}
&.is-inactive:focus {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $gray-800;
opacity: 0.8;
}
}

.edit-site-patterns__footer,
Expand Down