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

Try adding a keyboard-navigable font size selector with visual feedback #17418

Closed
wants to merge 10 commits into from
23 changes: 12 additions & 11 deletions packages/components/src/font-size-picker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,20 @@ import { __ } from '@wordpress/i18n';
*/
import Button from '../button';
import RangeControl from '../range-control';
import SelectControl from '../select-control';
import FontSizePickerSelect from './select';

function getSelectValueFromFontSize( fontSizes, value ) {
if ( value ) {
const fontSizeValue = fontSizes.find( ( font ) => font.size === value );
return fontSizeValue ? fontSizeValue.slug : 'custom';
}
return 'normal';
// We can't be sure what the theme font settings are so let's assume the "normal" size will be second if there are more than 2 sizes, and first if there are 2 or less.
return fontSizes.length > 2 ? fontSizes[ 1 ].slug : fontSizes[ 0 ].slug;
}

function getSelectOptions( optionsArray ) {
return [
...optionsArray.map( ( option ) => ( { value: option.slug, label: option.name } ) ),
...optionsArray.map( ( option ) => ( { value: option.slug, label: option.name, size: option.size } ) ),
{ value: 'custom', label: __( 'Custom' ) },
];
}
Expand Down Expand Up @@ -61,28 +62,28 @@ function FontSizePicker( {

return (
<fieldset>
<legend>
<legend className="screen-reader-text">
{ __( 'Font Size' ) }
</legend>
<div className="components-font-size-picker__controls">
{ ( fontSizes.length > 0 ) &&
<SelectControl
className={ 'components-font-size-picker__select' }
label={ 'Choose preset' }
hideLabelFromVision={ true }
<FontSizePickerSelect
value={ currentSelectValue }
onChange={ onSelectChangeValue }
options={ getSelectOptions( fontSizes ) }
fontSizes={ getSelectOptions( fontSizes ) }
/>
}
{ ( ! withSlider && ! disableCustomFontSizes ) &&
// eslint-disable-next-line jsx-a11y/label-has-for
<label className="components-range-control__label">
{ __( 'Custom' ) }
<input
className="components-range-control__number"
type="number"
onChange={ onChangeValue }
aria-label={ __( 'Custom' ) }
value={ value || '' }
/>
</label>
}
<Button
className="components-color-palette__clear"
Expand All @@ -92,7 +93,7 @@ function FontSizePicker( {
onChange( undefined );
setCurrentSelectValue( getSelectValueFromFontSize( fontSizes, undefined ) );
} }
isSmall
isLarge
isDefault
>
{ __( 'Reset' ) }
Expand Down
160 changes: 160 additions & 0 deletions packages/components/src/font-size-picker/select.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* External dependencies
*/
import { map } from 'lodash';

/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { withInstanceId } from '@wordpress/compose';
import { useRef, useCallback, useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import {
Dashicon,
BaseControl,
Button,
NavigableMenu,
Dropdown,
} from '../';

function FontSizePickerSelect( {
fontSizes = [],
instanceId,
onChange,
value,
} ) {
const currentFont = fontSizes.find( ( font ) => font.value === value );
const currentFontLabel = currentFont ? currentFont.label : '';

// Work-around to focus active item
const popoverRef = useRef( null );
const focusActiveItem = useCallback( () => {
// Hack work-arounds to control focus timing...
window.requestAnimationFrame( () => {
const { current } = popoverRef;
if ( ! current ) {
return;
}
const node = current.querySelector( '[aria-selected="true"]' );
node.blur();
window.requestAnimationFrame( () => {
if ( node ) {
// Hack work-arounds to control focus timing...
node.focus();
}
} );
} );
}, [] );

useEffect( () => {
focusActiveItem();
}, [ focusActiveItem, value ] );

// Work around to manage + force open state outside of Dropdown
const handleOnToggle = ( nextOpen ) => {
if ( nextOpen ) {
focusActiveItem();
}
};

// Work around to prevent scrolling.
// Need to adjust navigable-content/container
// https://github.com/WordPress/gutenberg/blob/master/packages/components/src/navigable-container/container.js#L89
const handleOnKeyDown = ( event ) => {
// event.preventDefault();
const { key } = event;
switch ( key ) {
case 'ArrowDown':
event.preventDefault();
break;
case 'ArrowUp':
event.preventDefault();
break;
default:
break;
}
};

// Improve voiceover consistency compared to native select
const ariaHasPopup = 'listbox';
const ariaProps = {
'aria-haspopup': ariaHasPopup,
};

const id = `font-size-picker__select-${ instanceId }`;

return (
<BaseControl className="components-font-size-picker__select">
<Dropdown
className="components-font-size-picker__select-dropdown"
contentClassName="components-font-size-picker__select-dropdown-content"
position="bottom"
focusOnMount="container"
onToggle={ handleOnToggle }
renderToggle={ ( { onToggle } ) => (
<label htmlFor={ id } className="components-font-size-picker__select-label">
{ __( 'Preset Size' ) }
<Button
aria-label={ __( 'Preset Size' ) }
className="components-font-size-picker__select-selector"
id={ id }
isLarge
onClick={ onToggle }
{ ...ariaProps }
>
{ currentFontLabel }
</Button>
</label>
) }
renderContent={ () => {
return (
<NavigableMenu
role="listbox"
aria-label="Choose Font Size"
onKeyDown={ handleOnKeyDown }
>
<div ref={ popoverRef }>
{ map( fontSizes, ( option ) => {
const isSelected = value === option.value;
const optionLabel = isSelected ? `${ option.label } (Selected)` : option.label;
const itemId = `item-${ option.value }`;
const itemRole = 'option';
const labelRole = 'presentation';

return (
<Button
key={ option.value }
onClick={ () => {
onChange( option.value );
} }
className={ `is-font-${ option.value }` }
role={ itemRole }
id={ itemId }
aria-label={ optionLabel }
aria-selected={ isSelected }
>
{ isSelected && <Dashicon icon="saved" /> }
<span
className="components-font-size-picker__select-dropdown-text-size"
style={ option.size ? { fontSize: option.size } : {} }
role={ labelRole }
>
{ option.label }
</span>
</Button>
);
} ) }
</div>
</NavigableMenu>
);
} }
/>
</BaseControl>
);
}

export default withInstanceId( FontSizePickerSelect );
66 changes: 66 additions & 0 deletions packages/components/src/font-size-picker/select.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.components-font-size-picker__select {
// Need to override a generic rule
// that can apply to all controls in the sidebar
// Ideally that generic rule shouldn't target
// this select in the first place.
margin-bottom: 0 !important;

.components-base-control__field {
margin-bottom: 0;
}
}

.components-font-size-picker__select-dropdown {
display: inline-block;
width: 80px;
}

.components-font-size-picker__select-dropdown-content button {
display: block;
width: 100%;
text-align: left;
position: relative;
padding: 10px 5px;
line-height: 1;
}

.components-font-size-picker__select-dropdown-content button:hover {
background: #eee !important;
}

.components-font-size-picker__select-dropdown-content button.highlighted {
background: #eee !important;
}

.components-font-size-picker__select-dropdown-content button svg {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}

.components-font-size-picker__select-dropdown-text-size {
margin-left: 30px;
display: block;
}

.components-font-size-picker__select-selector.components-font-size-picker__select-selector {
background: #fff;
border: 1px solid $dark-gray-200;
border-radius: 4px;
box-shadow: none;
margin-top: $grid-size;
padding-left: $grid-size-small;
position: relative;
}

.components-font-size-picker__select-selector::after {
content: "▼";
position: absolute;
right: $grid-size-small;
}

.components-font-size-picker__select-selector:focus {
border: 1px solid $blue-medium-focus !important;
outline: none !important;
}
11 changes: 10 additions & 1 deletion packages/components/src/font-size-picker/style.scss
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
@import "./select.scss";

.components-font-size-picker__controls {
max-width: $sidebar-width - ( 2 * $panel-padding );
display: flex;
align-items: center;
align-items: flex-end;
margin-bottom: $grid-size * 3;

// Apply the same height as the isSmall Reset button.
.components-range-control__number {
height: 30px;
margin-left: 0;
margin-right: $grid-size;
margin-top: $grid-size;

// Show the reset button as disabled until a value is entered.
&[value=""] + .components-button {
Expand All @@ -31,6 +34,12 @@
margin-bottom: 0;
}

.components-range-control__label,
.components-font-size-picker__select-label {
display: flex;
flex-direction: column;
}

.components-font-size-picker__custom-input {
.components-range-control__slider + .dashicon {
width: 30px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,9 @@ function withFocusReturn( options ) {
componentWillUnmount() {
const {
activeElementOnMount,
isFocused,
ownFocusedElements,
} = this;

if ( ! isFocused ) {
return;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

It'd be interesting to know the reasoning behind removing this code. I'm not sure what it does, but I think it terms of documenting its removal some details in the PR description would be great.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this seems dangerous for me. This means if we close a modal/popover programmatically (not by focusing something else explicitly) and the focus is outside of that modal/popover when we do so, the focus will return to the button that opened the popover) which IMO is not what we want right?

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 what it does, but I think it terms of documenting its removal some details in the PR description would be great.

What it currently does is return if no element within the wrapped component is focused, so that focus goes back to the document body. I can't think of any situation where we would want this to happen, especially when using a component the sole purpose of which is to return focus to the previously focused element when closing a modal or popover. So I'm particularly curious to know why it was added in the first place. I know it's part of this changeset but I can't reproduce the issue it fixes after my changes.

Note that this return doesn't get triggered when we deliberately move focus to a specific element, such as when we use the Block Navigator. It only gets triggered when focus is for some reason lost while we are still inside the wrapped component. This is something that, for the sake of accessibility, should never happen, so I'm not sure that we should be catering to it.

Removing this code fixes the issue of focus being lost when closing the font size dropdown because in the focusActiveItem function focus is blurred from the dropdown just before closing. I'm not sure why we're managing focus with this function though; I thought NavigableMenu would be enough to provide keyboard navigation with the arrow keys? Perhaps @youknowriad could shed some light on this.

Happy to hear further feedback and suggestions!

Copy link
Member

Choose a reason for hiding this comment

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

I think this check exists to address the issues that there might be several nested components that are wrapped with withFocusReturn and all of them can respond when they get unmounted. I suspect, this is a way to ensure that it's handled only once in such a case. This should be confirmed though.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this check exists to address the issues that there might be several nested components that are wrapped with withFocusReturn and all of them can respond when they get unmounted.

Hmmm I don't think that's what it does, because if we have nested withFocusReturns (such as this font size picker nested in the sidebar) the parent keeps tracking focus even when it's inside the child component, so I wouldn't expect focus to be lost in the parent unless it were also lost in the child.

We don't seem to have any instance of a parent popover/withFocusReturn-wrapped component that closes when a child popover is closed (that's probably an accessibility anti-pattern anyway), but I have verified that, with the current changes, when opening the font size picker and then closing the sidebar with it still open, focus is correctly transferred to the open sidebar button.

// Defer to the component's own explicit focus return behavior,
// if specified. The function should return `false` to prevent
// the default behavior otherwise occurring here. This allows
Expand Down
2 changes: 1 addition & 1 deletion packages/e2e-tests/specs/editor-modes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe( 'Editing modes (visual/HTML)', () => {
expect( htmlBlockContent ).toEqual( '<p>Hello world!</p>' );

// Change the font size using the sidebar.
await page.select( '.components-font-size-picker__select .components-select-control__input', 'large' );
await page.select( '.components-font-size-picker__select-selector', 'large' );

// Make sure the HTML content updated.
htmlBlockContent = await page.$eval( '.block-editor-block-list__layout .block-editor-block-list__block .block-editor-block-list__block-html-textarea', ( node ) => node.textContent );
Expand Down
6 changes: 3 additions & 3 deletions packages/e2e-tests/specs/font-size-picker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe( 'Font Size Picker', () => {
// Create a paragraph block with some content.
await clickBlockAppender();
await page.keyboard.type( 'Paragraph to be made "large"' );
await page.select( '.components-font-size-picker__select .components-select-control__input', 'large' );
await page.select( '.components-font-size-picker__select-selector', 'large' );

// Ensure content matches snapshot.
const content = await getEditedPostContent();
Expand Down Expand Up @@ -56,7 +56,7 @@ describe( 'Font Size Picker', () => {
await clickBlockAppender();
await page.keyboard.type( 'Paragraph with font size reset using button' );

await page.select( '.components-font-size-picker__select .components-select-control__input', 'normal' );
await page.select( '.components-font-size-picker__select-selector', 'normal' );

const resetButton = ( await page.$x( '//*[contains(concat(" ", @class, " "), " components-font-size-picker__controls ")]//*[text()=\'Reset\']' ) )[ 0 ];
await resetButton.click();
Expand All @@ -71,7 +71,7 @@ describe( 'Font Size Picker', () => {
await clickBlockAppender();
await page.keyboard.type( 'Paragraph with font size reset using input field' );

await page.select( '.components-font-size-picker__select .components-select-control__input', 'large' );
await page.select( '.components-font-size-picker__select-selector', 'large' );

// Clear the custom font size input.
await page.click( '.blocks-font-size .components-range-control__number' );
Expand Down