diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss
index 3ce088901bce5..f3fa8d1e7df04 100644
--- a/packages/block-editor/src/components/inserter/style.scss
+++ b/packages/block-editor/src/components/inserter/style.scss
@@ -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;
- }
}
}
diff --git a/packages/block-editor/src/components/inserter/tabs.js b/packages/block-editor/src/components/inserter/tabs.js
deleted file mode 100644
index b46e4bfdaf014..0000000000000
--- a/packages/block-editor/src/components/inserter/tabs.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * WordPress dependencies
- */
-import {
- Button,
- privateApis as componentsPrivateApis,
-} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
-import { forwardRef } from '@wordpress/element';
-import { closeSmall } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import { unlock } from '../../lock-unlock';
-
-const { Tabs } = unlock( componentsPrivateApis );
-
-const blocksTab = {
- name: 'blocks',
- /* translators: Blocks tab title in the block inserter. */
- title: __( 'Blocks' ),
-};
-const patternsTab = {
- name: 'patterns',
- /* translators: Theme and Directory Patterns tab title in the block inserter. */
- title: __( 'Patterns' ),
-};
-
-const mediaTab = {
- name: 'media',
- /* translators: Media tab title in the block inserter. */
- title: __( 'Media' ),
-};
-
-function InserterTabs( { onSelect, children, onClose, selectedTab }, ref ) {
- const tabs = [ blocksTab, patternsTab, mediaTab ];
-
- return (
-
-
-
- onClose() }
- size="small"
- />
-
-
- { tabs.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
-
- { tabs.map( ( tab ) => (
-
- { children }
-
- ) ) }
-
-
- );
-}
-
-export default forwardRef( InserterTabs );
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/style.scss b/packages/block-editor/src/components/inspector-controls-tabs/style.scss
index 863ac3d9bed03..9c9b04f7b8473 100644
--- a/packages/block-editor/src/components/inspector-controls-tabs/style.scss
+++ b/packages/block-editor/src/components/inspector-controls-tabs/style.scss
@@ -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%;
}
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 09f46b43a623f..f98f878080aa2 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -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)).
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index e5f113d93b7d0..0f7e0d2c6ac75 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -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 (
+
+
+
+ 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.
+
+
+ The button will toggle between full width (adding{ ' ' }
+ width: 100%
) and the default width.
+
+
Try the following:
+
+
+ Small container that causes tabs to
+ overflow with scroll.
+
+
+ Large container that exceeds the normal
+ width of the tabs.
+
+
+
+ With width: 100%
+ { ' ' }
+ set on the TabList (tabs fill up the space).
+
+
+
+ Without width: 100%
+ { ' ' }
+ (defaults to auto
) set on the
+ TabList (tabs take up space proportional to
+ their content).
+
+
+
+
+
+
setFullWidth( ! fullWidth ) }
+ >
+ { fullWidth
+ ? 'Remove width: 100% from TabList'
+ : 'Set width: 100% in TabList' }
+
+
+
+
+
+ Label with multiple words
+
+ Short
+
+ Hippopotomonstrosesquippedaliophobia
+
+ Tab 4
+ Tab 5
+
+
+
+ Selected tab: Tab 1
+ (Label with multiple words)
+
+
+ Selected tab: Tab 2
+ (Short)
+
+
+ Selected tab: Tab 3
+ (Hippopotomonstrosesquippedaliophobia)
+
+
+ Selected tab: Tab 4
+
+
+ Selected tab: Tab 5
+
+
+
+ );
+};
+SizeAndOverflowPlayground.args = {
+ defaultTabId: 'tab4',
+};
+
const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index c00943b180f63..283d6421f5b76 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -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 )`
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index 2977d6a628370..ae8daf60fc237 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -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 (
{
- 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
) }
>
diff --git a/packages/components/src/tabs/use-track-overflow.ts b/packages/components/src/tabs/use-track-overflow.ts
new file mode 100644
index 0000000000000..5f6504e687521
--- /dev/null
+++ b/packages/components/src/tabs/use-track-overflow.ts
@@ -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 */
diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts
index a96c25ecfac94..4c60e4ba51c48 100644
--- a/packages/components/src/utils/element-rect.ts
+++ b/packages/components/src/utils/element-rect.ts
@@ -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,
diff --git a/packages/edit-site/src/components/editor-canvas-container/style.scss b/packages/edit-site/src/components/editor-canvas-container/style.scss
index 80d6a909d0c95..0bdbc2bbe3235 100644
--- a/packages/edit-site/src/components/editor-canvas-container/style.scss
+++ b/packages/edit-site/src/components/editor-canvas-container/style.scss
@@ -30,6 +30,6 @@
position: absolute;
right: $grid-unit-10;
top: $grid-unit-10;
- z-index: 1;
+ z-index: 2;
background: $white;
}
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/index.js
index 495652f144275..27093e0ef1cbb 100644
--- a/packages/edit-site/src/components/global-styles/font-library-modal/index.js
+++ b/packages/edit-site/src/components/global-styles/font-library-modal/index.js
@@ -67,7 +67,7 @@ function FontLibraryModal( {
className="font-library-modal"
>
-
+
{ tabs.map( ( { id, title } ) => (
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss
index e8c48ca2c30bf..7d94376ac8d94 100644
--- a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss
+++ b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss
@@ -133,7 +133,7 @@ $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;
@@ -141,6 +141,10 @@ $footer-height: 70px;
margin: 0 #{$grid-unit-40 * -1};
padding: 0 $grid-unit-20;
z-index: 1;
+
+ [role="tablist"] {
+ margin-bottom: -1px;
+ }
}
diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js
index e68474e19f407..7b85c320e20c9 100644
--- a/packages/edit-site/src/components/style-book/index.js
+++ b/packages/edit-site/src/components/style-book/index.js
@@ -122,16 +122,18 @@ function StyleBook( {
{ showTabs ? (
-
- { tabs.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
+
+
+ { tabs.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+
{ tabs.map( ( tab ) => (
{
if ( !! newSelectedTabId ) {