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

### Enhancements

- `BlockAlignmentControl`: List Wide and Full width as unavailable when a parent layout withholds them but the theme itself offers them, instead of removing them from the menu without explanation. A theme that offers neither keeps them hidden, since it is curating its own options. Blocks whose only alignments are wide and full, such as Group and Columns, now keep an alignment control in layouts that offer neither ([#82600](https://github.com/WordPress/gutenberg/pull/82600)).
- Borders: rename the "Border & Shadow" panel to "Borders", whichever of its controls are available, and always show the Border and Shadow controls' visible labels. A stable panel title is what lets the Border label render unconditionally, so its "Unlink sides" toggle lines up with the border radius one ([#82163](https://github.com/WordPress/gutenberg/pull/82163)).
- `ListView`: Updated to use `outset-ring__focus()` mixin for focus outline wherever applicable instead of the previous box-shadow implementation. ([#82129](https://github.com/WordPress/gutenberg/pull/82129))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BlockAlignmentUI from '../ui';
import { LayoutProvider } from '../../block-list/layout';
import { BlockEditorProvider } from '../../provider';

describe( 'BlockAlignmentUI', () => {
const alignment = 'left';
Expand Down Expand Up @@ -115,3 +117,268 @@ describe( 'BlockAlignmentUI', () => {
expect( onChange ).toHaveBeenCalledWith( 'center' );
} );
} );

/**
* A theme offering both wide and full. Without one, those alignments were never
* on the table and are not reported as withheld.
*/
const THEME_LAYOUT = { contentSize: '600px', wideSize: '1200px' };

function renderWithTheme( ui, layout = THEME_LAYOUT, settings = {} ) {
return render(
<BlockEditorProvider
value={ [] }
settings={ { __experimentalFeatures: { layout }, ...settings } }
>
{ ui }
</BlockEditorProvider>
);
}

describe( 'BlockAlignmentUI unavailable alignments', () => {
// The block supports every alignment; the default flow layout offers only
// none/left/center/right, so wide and full are taken away by the parent.
const controls = [ 'left', 'center', 'right', 'wide', 'full' ];
const onChange = vi.fn();

afterEach( () => {
onChange.mockClear();
} );

async function openMenu() {
const user = userEvent.setup();
renderWithTheme(
<BlockAlignmentUI onChange={ onChange } controls={ controls } />
);
await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);
return user;
}

test( 'lists them rather than omitting them, in their usual position', async () => {
await openMenu();

expect(
screen
.getAllByRole( 'menuitemradio' )
.map( ( item ) => item.textContent )
).toEqual( [
'None',
'Wide widthNot available',
'Full widthNot available',
'Align left',
'Align center',
'Align right',
] );
} );

test( 'marks them as disabled while keeping them reachable', async () => {
await openMenu();

const wide = screen.getByRole( 'menuitemradio', {
name: /Wide width/,
} );

expect( wide ).toHaveAttribute( 'aria-disabled', 'true' );
// `disabled` would remove it from the accessibility tree entirely,
// taking the explanation with it.
expect( wide ).toBeEnabled();
} );

test( 'does not change the alignment when one is chosen', async () => {
const user = await openMenu();

await user.click(
screen.getByRole( 'menuitemradio', { name: /Full width/ } )
);

expect( onChange ).not.toHaveBeenCalled();
} );

test( 'leaves the menu alone when the layout offers every alignment', async () => {
const user = userEvent.setup();
renderWithTheme(
<LayoutProvider
value={ {
type: 'constrained',
contentSize: '600px',
wideSize: '1200px',
} }
>
<BlockAlignmentUI onChange={ onChange } controls={ controls } />
</LayoutProvider>
);
await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);

expect( screen.queryAllByText( 'Not available' ) ).toHaveLength( 0 );
expect(
screen
.getAllByRole( 'menuitemradio' )
.map( ( item ) => item.textContent )
).toEqual( [
'NoneMax 600px wide',
'Wide widthMax 1200px wide',
'Full width',
'Align left',
'Align center',
'Align right',
] );
} );
} );

describe( 'BlockAlignmentUI with no available alignments', () => {
// Group supports only wide and full, so a flow layout leaves it nothing.
const controls = [ 'wide', 'full' ];
const onChange = vi.fn();

afterEach( () => {
onChange.mockClear();
} );

test( 'still offers None alongside what the layout withholds', async () => {
const user = userEvent.setup();
renderWithTheme(
<BlockAlignmentUI onChange={ onChange } controls={ controls } />
);

await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);

expect(
screen
.getAllByRole( 'menuitemradio' )
.map( ( item ) => item.textContent )
).toEqual( [
'None',
'Wide widthNot available',
'Full widthNot available',
] );
} );

test( 'renders nothing when the layout places children itself', () => {
const { container } = renderWithTheme(
<LayoutProvider value={ { type: 'flex' } }>
<BlockAlignmentUI onChange={ onChange } controls={ controls } />
</LayoutProvider>
);

expect( container ).toBeEmptyDOMElement();
} );
} );

describe( 'BlockAlignmentUI when the theme withholds alignments', () => {
// A theme offering neither is curating its own options globally, so those
// alignments stay hidden rather than being reported as unavailable.
const controls = [ 'left', 'center', 'right', 'wide', 'full' ];
const onChange = vi.fn();

afterEach( () => {
onChange.mockClear();
} );

async function openMenuForTheme( layout ) {
const user = userEvent.setup();
renderWithTheme(
<BlockAlignmentUI onChange={ onChange } controls={ controls } />,
layout
);
await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);
}

function menuItems() {
return screen
.getAllByRole( 'menuitemradio' )
.map( ( item ) => item.textContent );
}

test( 'hides both when the theme sets no layout at all', async () => {
// No provider at all, so the store holds no global layout.
const user = userEvent.setup();
render(
<BlockAlignmentUI onChange={ onChange } controls={ controls } />
);
await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);

expect( menuItems() ).toEqual( [
'None',
'Align left',
'Align center',
'Align right',
] );
} );

test( 'hides Wide when the theme sets no wide size', async () => {
await openMenuForTheme( { contentSize: '600px' } );

expect( menuItems() ).toEqual( [
'None',
'Full widthNot available',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should there be a space between the two text spans so the alignment name isn't glued to "not available"? VoiceOver seems to read it correctly regardless, but this might vary between screen readers and languages?

'Align left',
'Align center',
'Align right',
] );
} );
} );

describe( 'BlockAlignmentUI on a theme without layout support', () => {
// Classic themes opt into wide alignments with `add_theme_support`, which
// arrives as the `alignWide` setting. There is no layout to withhold
// anything, so the menu either offers wide and full or leaves them out.
const controls = [ 'left', 'center', 'right', 'wide', 'full' ];
const onChange = vi.fn();

afterEach( () => {
onChange.mockClear();
} );

async function openMenuWithAlignWide( alignWide ) {
const user = userEvent.setup();
renderWithTheme(
<BlockAlignmentUI onChange={ onChange } controls={ controls } />,
undefined,
{ supportsLayout: false, alignWide }
);
await user.click(
screen.getByRole( 'button', { name: 'Align block' } )
);
}

function menuItems() {
return screen
.getAllByRole( 'menuitemradio' )
.map( ( item ) => item.textContent );
}

test( 'offers both when the theme supports wide alignments', async () => {
await openMenuWithAlignWide( true );

expect( screen.queryAllByText( 'Not available' ) ).toHaveLength( 0 );
expect( menuItems() ).toEqual( [
'None',
'Align left',
'Align center',
'Align right',
'Wide width',
'Full width',
] );
} );

test( 'hides both when the theme does not support wide alignments', async () => {
await openMenuWithAlignWide( false );

expect( menuItems() ).toEqual( [
'None',
'Align left',
'Align center',
'Align right',
] );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
MenuGroup,
MenuItem,
} from '@wordpress/components';
import useAvailableAlignments from './use-available-alignments';
import { useAlignmentMenu } from './use-available-alignments';
import { BLOCK_ALIGNMENTS_CONTROLS, DEFAULT_CONTROL } from './constants';

function BlockAlignmentUI( {
Expand All @@ -18,13 +18,39 @@ function BlockAlignmentUI( {
label = __( 'Align block' ),
description,
} ) {
const enabledControls = useAvailableAlignments( controls );
const hasEnabledControls = !! enabledControls.length;
/*
* Wide and Full are the alignments users look for and fail to find. When a
* parent layout does not offer them they disappear from this menu with no
* explanation, which reads as the editor losing the setting. List them as
* unavailable instead, so the menu says why it cannot do what was asked.
*/
const { enabled: enabledControls, unavailable: unavailableControls } =
useAlignmentMenu( controls );

if ( ! hasEnabledControls ) {
if ( ! enabledControls.length && ! unavailableControls.length ) {
return null;
}

/*
* A block whose alignments are all unavailable still needs somewhere to
* anchor them, and `None` is always a valid choice for it.
*/
const menuControls = enabledControls.length
? [ ...enabledControls ]
: [ { name: 'none' } ];
const enabledNames = menuControls.map( ( { name } ) => name );

// Unavailable alignments sit where they would have sat had they been
// offered, which is directly after `none`.
menuControls.splice(
enabledNames.indexOf( 'none' ) + 1,
0,
...unavailableControls.map( ( name ) => ( {
name,
isUnavailable: true,
} ) )
);

function onChangeAlignment( align ) {
onChange( [ value, 'none' ].includes( align ) ? undefined : align );
}
Expand Down Expand Up @@ -60,13 +86,20 @@ function BlockAlignmentUI( {
return (
<>
<MenuGroup className="block-editor-block-alignment-control__menu-group">
{ enabledControls.map(
( { name: controlName, info } ) => {
{ menuControls.map(
( {
name: controlName,
info,
isUnavailable,
} ) => {
const { icon, title } =
BLOCK_ALIGNMENTS_CONTROLS[
controlName
];
// If no value is provided, mark as selected the `none` option.
// An unavailable alignment can still be the saved value, and
// showing it as selected is how the menu says the setting
// exists but has no effect in this position.
const isSelected =
controlName === value ||
( ! value &&
Expand All @@ -83,14 +116,19 @@ function BlockAlignmentUI( {
}
) }
isSelected={ isSelected }
disabled={ isUnavailable }
onClick={ () => {
onChangeAlignment(
controlName
);
onClose();
} }
role="menuitemradio"
info={ info }
info={
isUnavailable
? __( 'Not available' )
: info
}
>
{ title }
</MenuItem>
Expand Down
Loading
Loading