Skip to content

Commit

Permalink
Tabs: indicator animation (WordPress#60560)
Browse files Browse the repository at this point in the history
* 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 <daniguardiola@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: stokesman <presstoke@git.wordpress.org>
Co-authored-by: jasmussen <joen@git.wordpress.org>
Co-authored-by: jameskoster <jameskoster@git.wordpress.org>
  • Loading branch information
8 people authored and patil-vipul committed Jun 17, 2024
1 parent 291b118 commit d3f29bd
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 42 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/components/src/tabs/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {

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

const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
<Tabs orientation="vertical" { ...props }>
<Tabs.TabList style={ { maxWidth: '10rem' } }>
<Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab>
<Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab>
</Tabs.TabList>
</Tabs>
);
};

export const Vertical = VerticalTemplate.bind( {} );

const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
<Tabs { ...props }>
Expand Down
77 changes: 43 additions & 34 deletions packages/components/src/tabs/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 )`
Expand Down Expand Up @@ -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: '';
Expand All @@ -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;
Expand Down
133 changes: 130 additions & 3 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = () => {
Expand All @@ -50,9 +158,28 @@ export const TabList = forwardRef<
<Ariakit.TabList
ref={ ref }
store={ store }
render={ <TabListWrapper /> }
render={
<TabListWrapper
onTransitionEnd={ ( event ) => {
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 }
</Ariakit.TabList>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
Expand Down

0 comments on commit d3f29bd

Please sign in to comment.