Skip to content

Commit

Permalink
Implement Tabs in site-editor settings (#56959)
Browse files Browse the repository at this point in the history
* take that, slot/fill!

* panel margin style fix

* style updates

* e2e test updates

* ensure canvas is in edit mode

* fix tabpanel ids

* update new playwright tests

* disable `selectOnMove`

* address tab flow race condition

* implement feedback

Co-authored-by: chad1008 <[email protected]>
Co-authored-by: ciampo <[email protected]>
Co-authored-by: andrewhayward <[email protected]>
Co-authored-by: afercia <[email protected]>
Co-authored-by: alexstine <[email protected]>
Co-authored-by: joedolson <[email protected]>
  • Loading branch information
7 people authored Feb 2, 2024
1 parent 0b1de17 commit 15a5a88
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 190 deletions.
141 changes: 118 additions & 23 deletions packages/edit-site/src/components/sidebar-edit-mode/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* WordPress dependencies
*/
import { createSlotFill } from '@wordpress/components';
import {
createSlotFill,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { isRTL, __ } from '@wordpress/i18n';
import { drawerLeft, drawerRight } from '@wordpress/icons';
import { useEffect } from '@wordpress/element';
import { useCallback, useContext, useEffect, useRef } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as interfaceStore } from '@wordpress/interface';
import { store as blockEditorStore } from '@wordpress/block-editor';
Expand All @@ -22,26 +25,107 @@ import TemplatePanel from './template-panel';
import PluginTemplateSettingPanel from '../plugin-template-setting-panel';
import { SIDEBAR_BLOCK, SIDEBAR_TEMPLATE } from './constants';
import { store as editSiteStore } from '../../store';
import { unlock } from '../../lock-unlock';

const { Tabs } = unlock( componentsPrivateApis );

const { Slot: InspectorSlot, Fill: InspectorFill } = createSlotFill(
'EditSiteSidebarInspector'
);
export const SidebarInspectorFill = InspectorFill;

const FillContents = ( {
sidebarName,
isEditingPage,
supportsGlobalStyles,
} ) => {
const tabListRef = useRef( null );
// Because `DefaultSidebar` renders a `ComplementaryArea`, we
// need to forward the `Tabs` context so it can be passed through the
// underlying slot/fill.
const tabsContextValue = useContext( Tabs.Context );

// This effect addresses a race condition caused by tabbing from the last
// block in the editor into the settings sidebar. Without this effect, the
// selected tab and browser focus can become separated in an unexpected way.
// (e.g the "block" tab is focused, but the "post" tab is selected).
useEffect( () => {
const tabsElements = Array.from(
tabListRef.current?.querySelectorAll( '[role="tab"]' ) || []
);
const selectedTabElement = tabsElements.find(
// We are purposefully using a custom `data-tab-id` attribute here
// because we don't want rely on any assumptions about `Tabs`
// component internals.
( element ) => element.getAttribute( 'data-tab-id' ) === sidebarName
);
const activeElement = selectedTabElement?.ownerDocument.activeElement;
const tabsHasFocus = tabsElements.some( ( element ) => {
return activeElement && activeElement.id === element.id;
} );
if (
tabsHasFocus &&
selectedTabElement &&
selectedTabElement.id !== activeElement?.id
) {
selectedTabElement?.focus();
}
}, [ sidebarName ] );

return (
<>
<DefaultSidebar
identifier={ sidebarName }
title={ __( 'Settings' ) }
icon={ isRTL() ? drawerLeft : drawerRight }
closeLabel={ __( 'Close Settings' ) }
header={
<Tabs.Context.Provider value={ tabsContextValue }>
<SettingsHeader ref={ tabListRef } />
</Tabs.Context.Provider>
}
headerClassName="edit-site-sidebar-edit-mode__panel-tabs"
// This classname is added so we can apply a corrective negative
// margin to the panel.
// see https://github.com/WordPress/gutenberg/pull/55360#pullrequestreview-1737671049
className="edit-site-sidebar__panel"
>
<Tabs.Context.Provider value={ tabsContextValue }>
<Tabs.TabPanel
tabId={ SIDEBAR_TEMPLATE }
focusable={ false }
>
{ isEditingPage ? <PagePanels /> : <TemplatePanel /> }
<PluginTemplateSettingPanel.Slot />
</Tabs.TabPanel>
<Tabs.TabPanel tabId={ SIDEBAR_BLOCK } focusable={ false }>
<InspectorSlot bubblesVirtually />
</Tabs.TabPanel>
</Tabs.Context.Provider>
</DefaultSidebar>
{ supportsGlobalStyles && <GlobalStylesSidebar /> }
</>
);
};

export function SidebarComplementaryAreaFills() {
const {
sidebar,
isEditorSidebarOpened,
hasBlockSelection,
supportsGlobalStyles,
isEditingPage,
isEditorOpen,
} = useSelect( ( select ) => {
const _sidebar =
select( interfaceStore ).getActiveComplementaryArea( STORE_NAME );

const _isEditorSidebarOpened = [
SIDEBAR_BLOCK,
SIDEBAR_TEMPLATE,
].includes( _sidebar );
const { getCanvasMode } = unlock( select( editSiteStore ) );

return {
sidebar: _sidebar,
isEditorSidebarOpened: _isEditorSidebarOpened,
Expand All @@ -50,6 +134,7 @@ export function SidebarComplementaryAreaFills() {
supportsGlobalStyles:
select( coreStore ).getCurrentTheme()?.is_block_theme,
isEditingPage: select( editSiteStore ).isPage(),
isEditorOpen: getCanvasMode() === 'edit',
};
}, [] );
const { enableComplementaryArea } = useDispatch( interfaceStore );
Expand Down Expand Up @@ -79,27 +164,37 @@ export function SidebarComplementaryAreaFills() {
sidebarName = hasBlockSelection ? SIDEBAR_BLOCK : SIDEBAR_TEMPLATE;
}

// `newSelectedTabId` could technically be falsey if no tab is selected (i.e.
// the initial render) or when we don't want a tab displayed (i.e. the
// sidebar is closed). These cases should both be covered by the `!!` check
// below, so we shouldn't need any additional falsey handling.
const onTabSelect = useCallback(
( newSelectedTabId ) => {
if ( !! newSelectedTabId ) {
enableComplementaryArea( STORE_NAME, newSelectedTabId );
}
},
[ enableComplementaryArea ]
);

return (
<>
<DefaultSidebar
identifier={ sidebarName }
title={ __( 'Settings' ) }
icon={ isRTL() ? drawerLeft : drawerRight }
closeLabel={ __( 'Close Settings' ) }
header={ <SettingsHeader sidebarName={ sidebarName } /> }
headerClassName="edit-site-sidebar-edit-mode__panel-tabs"
>
{ sidebarName === SIDEBAR_TEMPLATE && (
<>
{ isEditingPage ? <PagePanels /> : <TemplatePanel /> }
<PluginTemplateSettingPanel.Slot />
</>
) }
{ sidebarName === SIDEBAR_BLOCK && (
<InspectorSlot bubblesVirtually />
) }
</DefaultSidebar>
{ supportsGlobalStyles && <GlobalStylesSidebar /> }
</>
<Tabs
// Due to how this component is controlled (via a value from the
// edit-site store), when the sidebar closes the currently selected
// tab can't be found. This causes the component to continuously reset
// the selection to `null` in an infinite loop. Proactively setting
// the selected tab to `null` avoids that.
selectedTabId={
isEditorOpen && isEditorSidebarOpened ? sidebarName : null
}
onSelect={ onTabSelect }
selectOnMove={ false }
>
<FillContents
sidebarName={ sidebarName }
isEditingPage={ isEditingPage }
supportsGlobalStyles={ supportsGlobalStyles }
/>
</Tabs>
);
}
Original file line number Diff line number Diff line change
@@ -1,82 +1,44 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as interfaceStore } from '@wordpress/interface';
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { forwardRef } from '@wordpress/element';

/**
* Internal dependencies
*/
import { STORE_NAME } from '../../../store/constants';
import { SIDEBAR_BLOCK, SIDEBAR_TEMPLATE } from '../constants';
import { unlock } from '../../../lock-unlock';

const SettingsHeader = ( { sidebarName } ) => {
const { Tabs } = unlock( componentsPrivateApis );

const SettingsHeader = ( _, ref ) => {
const postTypeLabel = useSelect(
( select ) => select( editorStore ).getPostTypeLabel(),
[]
);

const { enableComplementaryArea } = useDispatch( interfaceStore );
const openTemplateSettings = () =>
enableComplementaryArea( STORE_NAME, SIDEBAR_TEMPLATE );
const openBlockSettings = () =>
enableComplementaryArea( STORE_NAME, SIDEBAR_BLOCK );

const documentAriaLabel =
sidebarName === SIDEBAR_TEMPLATE
? // translators: ARIA label for the Template sidebar tab, selected.
sprintf( __( '%s (selected)' ), postTypeLabel )
: postTypeLabel;

/* Use a list so screen readers will announce how many tabs there are. */
return (
<ul>
<li>
<Button
onClick={ openTemplateSettings }
className={ classnames(
'edit-site-sidebar-edit-mode__panel-tab',
{
'is-active': sidebarName === SIDEBAR_TEMPLATE,
}
) }
aria-label={ documentAriaLabel }
data-label={ postTypeLabel }
>
{ postTypeLabel }
</Button>
</li>
<li>
<Button
onClick={ openBlockSettings }
className={ classnames(
'edit-site-sidebar-edit-mode__panel-tab',
{
'is-active': sidebarName === SIDEBAR_BLOCK,
}
) }
aria-label={
sidebarName === SIDEBAR_BLOCK
? // translators: ARIA label for the Block Settings Sidebar tab, selected.
__( 'Block (selected)' )
: // translators: ARIA label for the Block Settings Sidebar tab, not selected.
__( 'Block' )
}
data-label={ __( 'Block' ) }
>
{ __( 'Block' ) }
</Button>
</li>
</ul>
<Tabs.TabList ref={ ref }>
<Tabs.Tab
tabId={ SIDEBAR_TEMPLATE }
// Used for focus management in the SettingsSidebar component.
data-tab-id={ SIDEBAR_TEMPLATE }
>
{ postTypeLabel }
</Tabs.Tab>
<Tabs.Tab
tabId={ SIDEBAR_BLOCK }
// Used for focus management in the SettingsSidebar component.
data-tab-id={ SIDEBAR_BLOCK }
>
{ __( 'Block' ) }
</Tabs.Tab>
</Tabs.TabList>
);
};

export default SettingsHeader;
export default forwardRef( SettingsHeader );
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
.components-panel__header.edit-site-sidebar-edit-mode__panel-tabs {
justify-content: flex-start;
padding-left: 0;
padding-right: $grid-unit-20;
border-top: 0;
margin-top: 0;

ul {
display: flex;
}
li {
margin: 0;
}

.components-button.has-icon {
display: none;
margin: 0 0 0 auto;
padding: 0;
min-width: $icon-size;
height: $icon-size;
Expand All @@ -24,78 +12,3 @@
}
}
}

// This tab style CSS is duplicated verbatim in
// /packages/components/src/tab-panel/style.scss
.components-button.edit-site-sidebar-edit-mode__panel-tab {
position: relative;
border-radius: 0;
height: $grid-unit-60;
background: transparent;
border: none;
box-shadow: none;
cursor: pointer;
padding: 3px $grid-unit-20; // Use padding to offset the is-active border, this benefits Windows High Contrast mode
margin-left: 0;
font-weight: 500;

&:focus:not(:disabled) {
position: relative;
box-shadow: none;
outline: none;
}

// Tab indicator
&::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;

// Draw the indicator.
background: var(--wp-admin-theme-color);
height: calc(0 * var(--wp-admin-border-width-focus));
border-radius: 0;

// Animation
transition: all 0.1s linear;
@include reduce-motion("transition");
}

// Active.
&.is-active::after {
height: calc(1 * var(--wp-admin-border-width-focus));

// Windows high contrast mode.
outline: 2px solid transparent;
outline-offset: -1px;
}

// Focus.
&::before {
content: "";
position: absolute;
top: $grid-unit-15;
right: $grid-unit-15;
bottom: $grid-unit-15;
left: $grid-unit-15;
pointer-events: none;

// Draw the indicator.
box-shadow: 0 0 0 0 transparent;
border-radius: $radius-block-ui;

// Animation
transition: all 0.1s linear;
@include reduce-motion("transition");
}

&:focus-visible::before {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);

// Windows high contrast mode.
outline: 2px solid transparent;
}
}
Loading

0 comments on commit 15a5a88

Please sign in to comment.