Skip to content

Commit

Permalink
Tabs: tweak sizing and overflow behavior of TabList (#64371)
Browse files Browse the repository at this point in the history
* Tweak sizing and overflow behavior of TabList.

* Add size and overflow playground story.

* Add "scroll into view" behavior to selected tabs.

* fit-content only on horizontal orientation

* Reduce specificity of `fit-content` to make it easier to override.

* centered label only when orientation is horizontal

* Remove unused file.

* Fix inspector controls tabs.

* Fix font library modal tabs.

* Fix style-book tabs.

* fix typo

* Add changelog entry.

* fix emotion being weird ugh

* Prevent unwanted focusable container in Firefox.

* Add fade effect.

* Fix IntersectionObserver logic.

* Feature detect IntersectionObserver to prevent tests from failing.

* Add a bit of tolerance for scroll state detection.

* Fix vertical indicator.

* Better handling of vertical overflow.

* Add a bit of scroll margin for better "scroll into view" experience.

* Horizontal fade should only happen on horizontal direction.

* Adjust for offset parent scroll state in `getElementOffsetRect`.

* Better "scroll into view" positioning heuristics ("nearest").

* Invert use of before and after to remove z-index and fix related issues.

* Make vertical indicator light blue as discussed.

* Undo most overrides in pattern/media vertical tabs.

* Clean up outdated styles previously needed for label wrapping.

* Revert vertical indicator changes and some indicator patterns/media tabs styles

* Revert vertical indicator bug fix

* Add changelog entry

* Remove outdated style.

* Address feedback

* Fix scroll bug

* Improve automatic tab scrolling behavior.

* Tweaks to prevent unit test failure and minor cleanup.

* Undo unnecessary changes.

* Improved story

* Fix scroll jumping bug.

* Scroll to active tab instead of selected (support `selectOnMove=false`).

* Fix minor visual glitch with overflow fade out indicators.

* Misc tweaks

* Fix.

* Fix changelog

* Fix changelog but it's actually true

* Fix changelog

* Make Story Book tabs nicer.

* Temp fix for scrollbar issue in Style Book tabs.

* Fix scroll bug and clean up a little.

* Simplify and clean up a bit more.

* Fix merge issues.

* Fix merge issues again.

* Make inserter patterns/media changes more minimal

* Fix outdated comment

* Fix another typo in comment.

* Minor cleanup.

* Fix bad automatic merge.

* ugh, fix again

Co-authored-by: DaniGuardiola <[email protected]>
Co-authored-by: tyxla <[email protected]>
Co-authored-by: ciampo <[email protected]>
Co-authored-by: jasmussen <[email protected]>
Co-authored-by: jameskoster <[email protected]>
Co-authored-by: afercia <[email protected]>
  • Loading branch information
7 people authored and getdave committed Oct 1, 2024
1 parent 611f915 commit c9d8db7
Showing 15 changed files with 346 additions and 185 deletions.
28 changes: 0 additions & 28 deletions packages/block-editor/src/components/inserter/style.scss
Original file line number Diff line number Diff line change
@@ -257,39 +257,11 @@ $block-inserter-tabs-height: 44px;
svg {
fill: var(--wp-admin-theme-color);
}

&::after {
content: "";
display: block;
outline: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: $radius-small;
opacity: 0.04;
background: var(--wp-admin-theme-color);
height: 100%;
}
}

&:focus-visible,
&:focus:not(:disabled) {
border-radius: $radius-small;
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
// Windows high contrast mode.
outline: 2px solid transparent;
outline-offset: 0;
}

&::before {
display: none;
}

&::after {
display: none;
}
}
}

78 changes: 0 additions & 78 deletions packages/block-editor/src/components/inserter/tabs.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.show-icon-labels {
.block-editor-block-inspector__tabs [role="tablist"] {
.components-button {
justify-content: center;
}
}
.block-editor-block-inspector__tabs [role="tablist"] {
width: 100%;
}
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@

### Enhancements

- `Tabs`: handle horizontal overflow and large tab lists gracefully ([#64371](https://github.com/WordPress/gutenberg/pull/64371)).
- `BorderBoxControl`: promote to stable ([#65586](https://github.com/WordPress/gutenberg/pull/65586)).
- `MenuGroup`: Simplify the MenuGroup styles within dropdown menus. ([#65561](https://github.com/WordPress/gutenberg/pull/65561)).
- `DatePicker`: Use compact button size. ([#65653](https://github.com/WordPress/gutenberg/pull/65653)).
106 changes: 106 additions & 0 deletions packages/components/src/tabs/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -70,6 +70,112 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {

export const Default = Template.bind( {} );

export const SizeAndOverflowPlayground: StoryFn< typeof Tabs > = ( props ) => {
const [ fullWidth, setFullWidth ] = useState( false );
return (
<div>
<div style={ { maxWidth: '40rem', marginBottom: '1rem' } }>
<p>
This story helps understand how the TabList component
behaves under different conditions. The container below
(with the dotted red border) can be horizontally resized,
and it has a bit of padding to be out of the way of the
TabList.
</p>
<p>
The button will toggle between full width (adding{ ' ' }
<code>width: 100%</code>) and the default width.
</p>
<p>Try the following:</p>
<ul>
<li>
<strong>Small container</strong> that causes tabs to
overflow with scroll.
</li>
<li>
<strong>Large container</strong> that exceeds the normal
width of the tabs.
<ul>
<li>
<strong>
With <code>width: 100%</code>
</strong>{ ' ' }
set on the TabList (tabs fill up the space).
</li>
<li>
<strong>
Without <code>width: 100%</code>
</strong>{ ' ' }
(defaults to <code>auto</code>) set on the
TabList (tabs take up space proportional to
their content).
</li>
</ul>
</li>
</ul>
</div>
<Button
style={ { marginBottom: '1rem' } }
variant="primary"
onClick={ () => setFullWidth( ! fullWidth ) }
>
{ fullWidth
? 'Remove width: 100% from TabList'
: 'Set width: 100% in TabList' }
</Button>
<Tabs { ...props }>
<div
style={ {
width: '20rem',
border: '2px dotted red',
padding: '1rem',
resize: 'horizontal',
overflow: 'auto',
} }
>
<Tabs.TabList
style={ {
maxWidth: '100%',
width: fullWidth ? '100%' : undefined,
} }
>
<Tabs.Tab tabId="tab1">
Label with multiple words
</Tabs.Tab>
<Tabs.Tab tabId="tab2">Short</Tabs.Tab>
<Tabs.Tab tabId="tab3">
Hippopotomonstrosesquippedaliophobia
</Tabs.Tab>
<Tabs.Tab tabId="tab4">Tab 4</Tabs.Tab>
<Tabs.Tab tabId="tab5">Tab 5</Tabs.Tab>
</Tabs.TabList>
</div>
<Tabs.TabPanel tabId="tab1">
<p>Selected tab: Tab 1</p>
<p>(Label with multiple words)</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabId="tab2">
<p>Selected tab: Tab 2</p>
<p>(Short)</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabId="tab3">
<p>Selected tab: Tab 3</p>
<p>(Hippopotomonstrosesquippedaliophobia)</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabId="tab4">
<p>Selected tab: Tab 4</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabId="tab5">
<p>Selected tab: Tab 5</p>
</Tabs.TabPanel>
</Tabs>
</div>
);
};
SizeAndOverflowPlayground.args = {
defaultTabId: 'tab4',
};

const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
<Tabs orientation="vertical" { ...props }>
74 changes: 54 additions & 20 deletions packages/components/src/tabs/styles.ts
Original file line number Diff line number Diff line change
@@ -16,32 +16,40 @@ export const TabListWrapper = styled.div`
align-items: stretch;
flex-direction: row;
text-align: center;
overflow-x: auto;
&[aria-orientation='vertical'] {
flex-direction: column;
text-align: start;
}
@media not ( prefers-reduced-motion ) {
&.is-animation-enabled::after {
transition-property: transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
}
:where( [aria-orientation='horizontal'] ) {
width: fit-content;
}
--direction-factor: 1;
--direction-origin-x: left;
--direction-start: left;
--direction-end: right;
--indicator-start: var( --indicator-left );
&:dir( rtl ) {
--direction-factor: -1;
--direction-origin-x: right;
--direction-start: right;
--direction-end: left;
--indicator-start: var( --indicator-right );
}
&::after {
@media not ( prefers-reduced-motion ) {
&.is-animation-enabled::before {
transition-property: transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
}
}
&::before {
content: '';
position: absolute;
pointer-events: none;
transform-origin: var( --direction-origin-x ) top;
transform-origin: var( --direction-start ) top;
// Windows high contrast mode.
outline: 2px solid transparent;
@@ -52,7 +60,31 @@ export const TabListWrapper = styled.div`
when scaling in the transform, see: https://stackoverflow.com/a/52159123 */
--antialiasing-factor: 100;
&:not( [aria-orientation='vertical'] ) {
&::after {
--fade-width: 4rem;
--fade-gradient-base: transparent 0%, black var( --fade-width );
--fade-gradient-composed: var( --fade-gradient-base ), black 60%,
transparent 50%;
&.is-overflowing-first {
mask-image: linear-gradient(
to var( --direction-end ),
var( --fade-gradient-base )
);
}
&.is-overflowing-last {
mask-image: linear-gradient(
to var( --direction-start ),
var( --fade-gradient-base )
);
}
&.is-overflowing-first.is-overflowing-last {
mask-image: linear-gradient(
to right,
var( --fade-gradient-composed )
),
linear-gradient( to left, var( --fade-gradient-composed ) );
}
&::before {
bottom: 0;
height: 0;
width: calc( var( --antialiasing-factor ) * 1px );
@@ -71,8 +103,7 @@ export const TabListWrapper = styled.div`
${ COLORS.theme.accent };
}
}
&[aria-orientation='vertical']::after {
z-index: -1;
&[aria-orientation='vertical']::before {
top: 0;
left: 0;
width: 100%;
@@ -87,14 +118,14 @@ export const TabListWrapper = styled.div`

export const Tab = styled( Ariakit.Tab )`
& {
scroll-margin: 24px;
flex-grow: 1;
flex-shrink: 0;
display: inline-flex;
align-items: center;
position: relative;
border-radius: 0;
min-height: ${ space(
12
) }; // Avoid fixed height to allow for long strings that go in multiple lines.
height: auto;
height: ${ space( 12 ) };
background: transparent;
border: none;
box-shadow: none;
@@ -104,7 +135,6 @@ export const Tab = styled( Ariakit.Tab )`
margin-left: 0;
font-weight: 500;
text-align: inherit;
hyphens: auto;
color: ${ COLORS.theme.foreground };
&[aria-disabled='true'] {
@@ -123,7 +153,7 @@ export const Tab = styled( Ariakit.Tab )`
}
// Focus.
&::before {
&::after {
content: '';
position: absolute;
top: ${ space( 3 ) };
@@ -146,7 +176,7 @@ export const Tab = styled( Ariakit.Tab )`
}
}
&:focus-visible::before {
&:focus-visible::after {
opacity: 1;
}
}
@@ -156,6 +186,10 @@ export const Tab = styled( Ariakit.Tab )`
10
) }; // Avoid fixed height to allow for long strings that go in multiple lines.
}
[aria-orientation='horizontal'] & {
justify-content: center;
}
`;

export const TabPanel = styled( Ariakit.TabPanel )`
86 changes: 60 additions & 26 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
@@ -8,7 +8,8 @@ import { useStoreState } from '@ariakit/react';
* WordPress dependencies
*/
import warning from '@wordpress/warning';
import { forwardRef, useState } from '@wordpress/element';
import { forwardRef, useLayoutEffect, useState } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';

/**
* Internal dependencies
@@ -20,33 +21,58 @@ import type { WordPressComponentProps } from '../context';
import clsx from 'clsx';
import { useTrackElementOffsetRect } from '../utils/element-rect';
import { useOnValueUpdate } from '../utils/hooks/use-on-value-update';
import { useTrackOverflow } from './use-track-overflow';

const SCROLL_MARGIN = 24;

export const TabList = forwardRef<
HTMLDivElement,
WordPressComponentProps< TabListProps, 'div', false >
>( function TabList( { children, ...otherProps }, ref ) {
const context = useTabsContext();
const { store } = useTabsContext() ?? {};

const selectedId = useStoreState( store, 'selectedId' );
const activeId = useStoreState( store, 'activeId' );
const selectOnMove = useStoreState( store, 'selectOnMove' );
const items = useStoreState( store, 'items' );
const [ parent, setParent ] = useState< HTMLElement | null >();
const refs = useMergeRefs( [ ref, setParent ] );
const overflow = useTrackOverflow( parent, {
first: items?.at( 0 )?.element,
last: items?.at( -1 )?.element,
} );

const tabStoreState = useStoreState( context?.store );
const selectedId = tabStoreState?.selectedId;
const indicatorPosition = useTrackElementOffsetRect(
context?.store.item( selectedId )?.element
const selectedTabPosition = useTrackElementOffsetRect(
store?.item( selectedId )?.element
);

const [ animationEnabled, setAnimationEnabled ] = useState( false );
useOnValueUpdate(
selectedId,
( { previousValue } ) => previousValue && setAnimationEnabled( true )
);
useOnValueUpdate( selectedId, ( { previousValue } ) => {
if ( previousValue ) {
setAnimationEnabled( true );
}
} );

if ( ! context || ! tabStoreState ) {
warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
return null;
}
// Make sure selected tab is scrolled into view.
useLayoutEffect( () => {
if ( ! parent || ! selectedTabPosition ) {
return;
}

const { scrollLeft: parentScroll } = parent;
const parentWidth = parent.getBoundingClientRect().width;
const { left: childLeft, width: childWidth } = selectedTabPosition;

const { store } = context;
const { activeId, selectOnMove } = tabStoreState;
const { setActiveId } = store;
const parentRightEdge = parentScroll + parentWidth;
const childRightEdge = childLeft + childWidth;
const rightOverflow = childRightEdge + SCROLL_MARGIN - parentRightEdge;
const leftOverflow = parentScroll - ( childLeft - SCROLL_MARGIN );
if ( leftOverflow > 0 ) {
parent.scrollLeft = parentScroll - leftOverflow;
} else if ( rightOverflow > 0 ) {
parent.scrollLeft = parentScroll + rightOverflow;
}
}, [ parent, selectedTabPosition ] );

const onBlur = () => {
if ( ! selectOnMove ) {
@@ -58,35 +84,43 @@ export const TabList = forwardRef<
// that the selected tab will receive keyboard focus when tabbing back into
// the tablist.
if ( selectedId !== activeId ) {
setActiveId( selectedId );
store?.setActiveId( selectedId );
}
};

if ( ! store ) {
warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
return null;
}

return (
<Ariakit.TabList
ref={ ref }
ref={ refs }
store={ store }
render={
<TabListWrapper
onTransitionEnd={ ( event ) => {
if ( event.pseudoElement === '::after' ) {
if ( event.pseudoElement === '::before' ) {
setAnimationEnabled( false );
}
} }
/>
}
onBlur={ onBlur }
tabIndex={ -1 }
{ ...otherProps }
style={ {
'--indicator-top': indicatorPosition.top,
'--indicator-right': indicatorPosition.right,
'--indicator-left': indicatorPosition.left,
'--indicator-width': indicatorPosition.width,
'--indicator-height': indicatorPosition.height,
'--indicator-top': selectedTabPosition.top,
'--indicator-right': selectedTabPosition.right,
'--indicator-left': selectedTabPosition.left,
'--indicator-width': selectedTabPosition.width,
'--indicator-height': selectedTabPosition.height,
...otherProps.style,
} }
className={ clsx(
animationEnabled ? 'is-animation-enabled' : '',
overflow.first && 'is-overflowing-first',
overflow.last && 'is-overflowing-last',
animationEnabled && 'is-animation-enabled',
otherProps.className
) }
>
76 changes: 76 additions & 0 deletions packages/components/src/tabs/use-track-overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable jsdoc/require-param */
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import { useEvent } from '@wordpress/compose';

/**
* Tracks if an element contains overflow and on which end by tracking the
* first and last child elements with an `IntersectionObserver` in relation
* to the parent element.
*
* Note that the returned value will only indicate whether the first or last
* element is currently "going out of bounds" but not whether it happens on
* the X or Y axis.
*/
export function useTrackOverflow(
parent: HTMLElement | undefined | null,
children: {
first: HTMLElement | undefined | null;
last: HTMLElement | undefined | null;
}
) {
const [ first, setFirst ] = useState( false );
const [ last, setLast ] = useState( false );
const [ observer, setObserver ] = useState< IntersectionObserver >();

const callback: IntersectionObserverCallback = useEvent( ( entries ) => {
for ( const entry of entries ) {
if ( entry.target === children.first ) {
setFirst( ! entry.isIntersecting );
}
if ( entry.target === children.last ) {
setLast( ! entry.isIntersecting );
}
}
} );

useEffect( () => {
if ( ! parent || ! window.IntersectionObserver ) {
return;
}
const newObserver = new IntersectionObserver( callback, {
root: parent,
threshold: 0.9,
} );
setObserver( newObserver );

return () => newObserver.disconnect();
}, [ callback, parent ] );

useEffect( () => {
if ( ! observer ) {
return;
}

if ( children.first ) {
observer.observe( children.first );
}
if ( children.last ) {
observer.observe( children.last );
}

return () => {
if ( children.first ) {
observer.unobserve( children.first );
}
if ( children.last ) {
observer.unobserve( children.last );
}
};
}, [ children.first, children.last, observer ] );

return { first, last };
}
/* eslint-enable jsdoc/require-param */
22 changes: 16 additions & 6 deletions packages/components/src/utils/element-rect.ts
Original file line number Diff line number Diff line change
@@ -75,9 +75,11 @@ export function getElementOffsetRect(
if ( rect.width === 0 || rect.height === 0 ) {
return;
}
const offsetParent = element.offsetParent;
const offsetParentRect =
element.offsetParent?.getBoundingClientRect() ??
NULL_ELEMENT_OFFSET_RECT;
offsetParent?.getBoundingClientRect() ?? NULL_ELEMENT_OFFSET_RECT;
const offsetParentScrollX = offsetParent?.scrollLeft ?? 0;
const offsetParentScrollY = offsetParent?.scrollTop ?? 0;

// Computed widths and heights have subpixel precision, and are not affected
// by distortions.
@@ -93,10 +95,18 @@ export function getElementOffsetRect(
// To obtain the adjusted values for the position:
// 1. Compute the element's position relative to the offset parent.
// 2. Correct for the scale factor.
top: ( rect.top - offsetParentRect?.top ) * scaleY,
right: ( offsetParentRect?.right - rect.right ) * scaleX,
bottom: ( offsetParentRect?.bottom - rect.bottom ) * scaleY,
left: ( rect.left - offsetParentRect?.left ) * scaleX,
// 3. Adjust for the scroll position of the offset parent.
top:
( rect.top - offsetParentRect?.top ) * scaleY + offsetParentScrollY,
right:
( offsetParentRect?.right - rect.right ) * scaleX -
offsetParentScrollX,
bottom:
( offsetParentRect?.bottom - rect.bottom ) * scaleY -
offsetParentScrollY,
left:
( rect.left - offsetParentRect?.left ) * scaleX +
offsetParentScrollX,
// Computed dimensions don't need any adjustments.
width: computedWidth,
height: computedHeight,
Original file line number Diff line number Diff line change
@@ -30,6 +30,6 @@
position: absolute;
right: $grid-unit-10;
top: $grid-unit-10;
z-index: 1;
z-index: 2;
background: $white;
}
Original file line number Diff line number Diff line change
@@ -67,7 +67,7 @@ function FontLibraryModal( {
className="font-library-modal"
>
<Tabs defaultTabId={ defaultTabId }>
<div className="font-library-modal__tablist">
<div className="font-library-modal__tablist-container">
<Tabs.TabList>
{ tabs.map( ( { id, title } ) => (
<Tabs.Tab key={ id } tabId={ id }>
Original file line number Diff line number Diff line change
@@ -133,14 +133,18 @@ $footer-height: 70px;
padding-bottom: $grid-unit-20;
}

.font-library-modal__tablist {
.font-library-modal__tablist-container {
position: sticky;
top: 0;
border-bottom: 1px solid $gray-300;
background: $white;
margin: 0 #{$grid-unit-40 * -1};
padding: 0 $grid-unit-20;
z-index: 1;

[role="tablist"] {
margin-bottom: -1px;
}
}


22 changes: 12 additions & 10 deletions packages/edit-site/src/components/style-book/index.js
Original file line number Diff line number Diff line change
@@ -122,16 +122,18 @@ function StyleBook( {
{ showTabs ? (
<div className="edit-site-style-book__tabs">
<Tabs>
<Tabs.TabList>
{ tabs.map( ( tab ) => (
<Tabs.Tab
tabId={ tab.slug }
key={ tab.slug }
>
{ tab.title }
</Tabs.Tab>
) ) }
</Tabs.TabList>
<div className="edit-site-style-book__tablist-container">
<Tabs.TabList>
{ tabs.map( ( tab ) => (
<Tabs.Tab
tabId={ tab.slug }
key={ tab.slug }
>
{ tab.title }
</Tabs.Tab>
) ) }
</Tabs.TabList>
</div>
{ tabs.map( ( tab ) => (
<Tabs.TabPanel
key={ tab.slug }
14 changes: 9 additions & 5 deletions packages/edit-site/src/components/style-book/style.scss
Original file line number Diff line number Diff line change
@@ -17,12 +17,16 @@
}
}

.edit-site-style-book__tabs {
[role="tablist"] {
background: $white;
color: $gray-900;
}
.edit-site-style-book__tablist-container {
background: $white;
width: 100%;
padding-right: 56px;
display: flex;
position: absolute;
z-index: 1;
}

.edit-site-style-book__tabs {
[role="tabpanel"] {
bottom: 0;
left: 0;
6 changes: 3 additions & 3 deletions packages/edit-widgets/src/components/sidebar/index.js
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ const SIDEBAR_ACTIVE_BY_DEFAULT = Platform.select( {

const BLOCK_INSPECTOR_IDENTIFIER = 'edit-widgets/block-inspector';

// Widget areas were one called block areas, so use 'edit-widgets/block-areas'
// Widget areas were once called block areas, so use 'edit-widgets/block-areas'
// for backwards compatibility.
const WIDGET_AREAS_IDENTIFIER = 'edit-widgets/block-areas';

@@ -192,10 +192,10 @@ export default function Sidebar() {

const { enableComplementaryArea } = useDispatch( interfaceStore );

// `newSelectedTabId` could technically be falsey if no tab is selected (i.e.
// `newSelectedTabId` could technically be falsy 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.
// below, so we shouldn't need any additional falsy handling.
const onTabSelect = useCallback(
( newSelectedTabId ) => {
if ( !! newSelectedTabId ) {

0 comments on commit c9d8db7

Please sign in to comment.