From d3f29bd26ca5266e40d459c5598b34fdf1b9f652 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 23 May 2024 18:59:29 +0200 Subject: [PATCH] Tabs: indicator animation (#60560) * Initial tab indicator animation implementation * Add changelog entry * Minor tweak * Fix downstream issues. * Use ResizeObserver. * Add width transition. * Simplify and use framer motion * vertical indicator * Revert to previous implementation. * Fix bug due to some animations breaking measurement of the tab element. * Abstracted and fixed all previous issues. * Follow naming convention for classes. * Support vertical orientation + misc fixes and improvements. * Clean up styles a bit. * Better focus ring animation + minor style cleanup. * Fix changelog (oops). * Actually fix changelog. * Remove deprecated `reduceMotion` utility. * Fix open/closed * Add vertical tabs story * Move ResizeObserver unobserve to effect cleanup * Remove outdated type cast. * Hide vertical indicator for now. Co-authored-by: DaniGuardiola Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: tyxla Co-authored-by: jsnajdr Co-authored-by: stokesman Co-authored-by: jasmussen Co-authored-by: jameskoster --- packages/components/CHANGELOG.md | 1 + .../src/tabs/stories/index.story.tsx | 14 ++ packages/components/src/tabs/styles.ts | 77 +++++----- packages/components/src/tabs/tablist.tsx | 133 +++++++++++++++++- .../preferences-modal-tabs/style.scss | 9 +- 5 files changed, 192 insertions(+), 42 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6cc14fdd545ef5..617ec772f2a4ed 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -8,6 +8,7 @@ ### Enhancements +- `Tabs`: Animate indicator ([#60560](https://github.com/WordPress/gutenberg/pull/60560)). - `ComboboxControl`: Introduce Combobox expandOnFocus prop ([#61705](https://github.com/WordPress/gutenberg/pull/61705)). ### Bug Fixes diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index 558daf9143c25a..16f8ac956ccf75 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -68,6 +68,20 @@ const Template: StoryFn< typeof Tabs > = ( props ) => { export const Default = Template.bind( {} ); +const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + Tab 1 + Tab 2 + Tab 3 + + + ); +}; + +export const Vertical = VerticalTemplate.bind( {} ); + const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => { return ( diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index 6abca79aa51aed..ce575c9f317794 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -10,15 +10,49 @@ import * as Ariakit from '@ariakit/react'; */ import { COLORS } from '../utils'; import { space } from '../utils/space'; -import { reduceMotion } from '../utils/reduce-motion'; export const TabListWrapper = styled.div` + position: relative; display: flex; align-items: stretch; flex-direction: row; &[aria-orientation='vertical'] { flex-direction: column; } + @media not ( prefers-reduced-motion: reduce ) { + &.is-animation-enabled::after { + transition-property: left, top, width, height; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + } + &::after { + content: ''; + position: absolute; + pointer-events: none; + + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: -1px; + } + &:not( [aria-orientation='vertical'] )::after { + left: var( --indicator-left ); + bottom: 0; + width: var( --indicator-width ); + height: 0; + border-bottom: var( --wp-admin-border-width-focus ) solid + ${ COLORS.theme.accent }; + } + &[aria-orientation='vertical']::after { + /* Temporarily hidden, context: https://github.com/WordPress/gutenberg/pull/60560#issuecomment-2126670072 */ + opacity: 0; + + right: 0; + top: var( --indicator-top ); + height: var( --indicator-height ); + border-right: var( --wp-admin-border-width-focus ) solid + ${ COLORS.theme.accent }; + } `; export const Tab = styled( Ariakit.Tab )` @@ -51,34 +85,6 @@ export const Tab = styled( Ariakit.Tab )` outline: none; } - // Tab indicator - &::after { - content: ''; - position: absolute; - right: 0; - bottom: 0; - left: 0; - pointer-events: none; - - // Draw the indicator. - background: ${ COLORS.theme.accent }; - height: calc( 0 * var( --wp-admin-border-width-focus ) ); - border-radius: 0; - - // Animation - transition: all 0.1s linear; - ${ reduceMotion( 'transition' ) }; - } - - // Active. - &[aria-selected='true']::after { - height: calc( 1 * var( --wp-admin-border-width-focus ) ); - - // Windows high contrast mode. - outline: 2px solid transparent; - outline-offset: -1px; - } - // Focus. &::before { content: ''; @@ -90,17 +96,20 @@ export const Tab = styled( Ariakit.Tab )` pointer-events: none; // Draw the indicator. - box-shadow: 0 0 0 0 transparent; + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + ${ COLORS.theme.accent }; border-radius: 2px; // Animation - transition: all 0.1s linear; - ${ reduceMotion( 'transition' ) }; + opacity: 0; + + @media not ( prefers-reduced-motion ) { + transition: opacity 0.1s linear; + } } &:focus-visible::before { - box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) - ${ COLORS.theme.accent }; + opacity: 1; // Windows high contrast mode. outline: 2px solid transparent; diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index afa2d8283e6d6c..cbd4290f06bff2 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -8,7 +8,13 @@ import * as Ariakit from '@ariakit/react'; * WordPress dependencies */ import warning from '@wordpress/warning'; -import { forwardRef } from '@wordpress/element'; +import { + forwardRef, + useEffect, + useLayoutEffect, + useRef, + useState, +} from '@wordpress/element'; /** * Internal dependencies @@ -17,19 +23,121 @@ import type { TabListProps } from './types'; import { useTabsContext } from './context'; import { TabListWrapper } from './styles'; import type { WordPressComponentProps } from '../context'; +import clsx from 'clsx'; + +function useTrackElementOffset( + targetElement?: HTMLElement | null, + onUpdate?: () => void +) { + const [ indicatorPosition, setIndicatorPosition ] = useState( { + left: 0, + top: 0, + width: 0, + height: 0, + } ); + + // TODO: replace with useEventCallback or similar when officially available. + const updateCallbackRef = useRef( onUpdate ); + useLayoutEffect( () => { + updateCallbackRef.current = onUpdate; + } ); + + const observedElementRef = useRef< HTMLElement >(); + const resizeObserverRef = useRef< ResizeObserver >(); + useEffect( () => { + if ( targetElement === observedElementRef.current ) { + return; + } + + observedElementRef.current = targetElement ?? undefined; + + function updateIndicator( element: HTMLElement ) { + setIndicatorPosition( { + left: element.offsetLeft, + top: element.offsetTop, + width: element.offsetWidth, + height: element.offsetHeight, + } ); + updateCallbackRef.current?.(); + } + + // Set up a ResizeObserver. + if ( ! resizeObserverRef.current ) { + resizeObserverRef.current = new ResizeObserver( () => { + if ( observedElementRef.current ) { + updateIndicator( observedElementRef.current ); + } + } ); + } + const { current: resizeObserver } = resizeObserverRef; + + // Observe new element. + if ( targetElement ) { + updateIndicator( targetElement ); + resizeObserver.observe( targetElement ); + } + + return () => { + // Unobserve previous element. + if ( observedElementRef.current ) { + resizeObserver.unobserve( observedElementRef.current ); + } + }; + }, [ targetElement ] ); + + return indicatorPosition; +} + +type ValueUpdateContext< T > = { + previousValue: T; +}; + +function useOnValueUpdate< T >( + value: T, + onUpdate: ( context: ValueUpdateContext< T > ) => void +) { + const previousValueRef = useRef( value ); + + // TODO: replace with useEventCallback or similar when officially available. + const updateCallbackRef = useRef( onUpdate ); + useLayoutEffect( () => { + updateCallbackRef.current = onUpdate; + } ); + + useEffect( () => { + if ( previousValueRef.current !== value ) { + updateCallbackRef.current( { + previousValue: previousValueRef.current, + } ); + previousValueRef.current = value; + } + }, [ value ] ); +} export const TabList = forwardRef< HTMLDivElement, WordPressComponentProps< TabListProps, 'div', false > >( function TabList( { children, ...otherProps }, ref ) { const context = useTabsContext(); + + const selectedId = context?.store.useState( 'selectedId' ); + const indicatorPosition = useTrackElementOffset( + context?.store.item( selectedId )?.element + ); + + const [ animationEnabled, setAnimationEnabled ] = useState( false ); + useOnValueUpdate( + selectedId, + ( { previousValue } ) => previousValue && setAnimationEnabled( true ) + ); + if ( ! context ) { warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); return null; } const { store } = context; - const { selectedId, activeId, selectOnMove } = store.useState(); + const { activeId, selectOnMove } = store.useState(); const { setActiveId } = store; const onBlur = () => { @@ -50,9 +158,28 @@ export const TabList = forwardRef< } + render={ + { + if ( event.pseudoElement === '::after' ) { + setAnimationEnabled( false ); + } + } } + /> + } onBlur={ onBlur } { ...otherProps } + style={ { + '--indicator-left': `${ indicatorPosition.left }px`, + '--indicator-top': `${ indicatorPosition.top }px`, + '--indicator-width': `${ indicatorPosition.width }px`, + '--indicator-height': `${ indicatorPosition.height }px`, + ...otherProps.style, + } } + className={ clsx( + animationEnabled ? 'is-animation-enabled' : '', + otherProps.className + ) } > { children } diff --git a/packages/preferences/src/components/preferences-modal-tabs/style.scss b/packages/preferences/src/components/preferences-modal-tabs/style.scss index d3afd4174cd0cb..e20b9aa9064ed6 100644 --- a/packages/preferences/src/components/preferences-modal-tabs/style.scss +++ b/packages/preferences/src/components/preferences-modal-tabs/style.scss @@ -1,12 +1,15 @@ $vertical-tabs-width: 160px; .preferences__tabs-tablist { - position: absolute; + position: absolute !important; top: $header-height + $grid-unit-30; // Aligns button text instead of button box. left: $grid-unit-20; width: $vertical-tabs-width; + &::after { + content: none !important; + } } .preferences__tabs-tab { @@ -19,10 +22,6 @@ $vertical-tabs-width: 160px; font-weight: 500; } - &[aria-selected="true"]::after { - content: none; - } - &[role="tab"]:focus:not(:disabled) { box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); // Windows high contrast mode.