Skip to content

Commit

Permalink
Clean up Tabs animation logic (WordPress#65878)
Browse files Browse the repository at this point in the history
* Clean up and misc improvements.

* Move utility from ToggleGroupControl to utils.

* Clean up Tabs.Tablist

* Clean up ToggleGroupControl and update utility from latest trunk version

* Clean up scroll utility

* Remove unnecessary null.

* CHANGELOG

---------

Co-authored-by: DaniGuardiola <daniguardiola@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
  • Loading branch information
3 people authored Oct 4, 2024
1 parent 300470a commit 570a81d
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 154 deletions.
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- `Tabs`: fix skipping indication animation glitch ([#65878](https://github.com/WordPress/gutenberg/pull/65878)).

## 28.9.0 (2024-10-03)

### Bug Fixes
Expand Down
17 changes: 10 additions & 7 deletions packages/components/src/tabs/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ export const TabListWrapper = styled.div`
--direction-factor: 1;
--direction-start: left;
--direction-end: right;
--indicator-start: var( --indicator-left );
--selected-start: var( --selected-left, 0 );
&:dir( rtl ) {
--direction-factor: -1;
--direction-start: right;
--direction-end: left;
--indicator-start: var( --indicator-right );
--selected-start: var( --selected-right, 0 );
}
@media not ( prefers-reduced-motion ) {
&.is-animation-enabled::before {
&[data-indicator-animated]::before {
transition-property: transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
Expand Down Expand Up @@ -90,13 +90,14 @@ export const TabListWrapper = styled.div`
width: calc( var( --antialiasing-factor ) * 1px );
transform: translateX(
calc(
var( --indicator-start ) * var( --direction-factor ) *
var( --selected-start ) * var( --direction-factor ) *
1px
)
)
scaleX(
calc(
var( --indicator-width ) / var( --antialiasing-factor )
var( --selected-width, 0 ) /
var( --antialiasing-factor )
)
);
border-bottom: var( --wp-admin-border-width-focus ) solid
Expand All @@ -108,9 +109,11 @@ export const TabListWrapper = styled.div`
left: 0;
width: 100%;
height: calc( var( --antialiasing-factor ) * 1px );
transform: translateY( calc( var( --indicator-top ) * 1px ) )
transform: translateY( calc( var( --selected-top, 0 ) * 1px ) )
scaleY(
calc( var( --indicator-height ) / var( --antialiasing-factor ) )
calc(
var( --selected-height, 0 ) / var( --antialiasing-factor )
)
);
background-color: ${ COLORS.theme.gray[ 100 ] };
}
Expand Down
96 changes: 47 additions & 49 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,43 @@ import { useTabsContext } from './context';
import { TabListWrapper } from './styles';
import type { WordPressComponentProps } from '../context';
import clsx from 'clsx';
import type { ElementOffsetRect } from '../utils/element-rect';
import { useTrackElementOffsetRect } from '../utils/element-rect';
import { useOnValueUpdate } from '../utils/hooks/use-on-value-update';
import { useTrackOverflow } from './use-track-overflow';
import { useAnimatedOffsetRect } from '../utils/hooks/use-animated-offset-rect';

const SCROLL_MARGIN = 24;
const DEFAULT_SCROLL_MARGIN = 24;

/**
* Scrolls a given parent element so that a given rect is visible.
*
* The scroll is updated initially and whenever the rect changes.
*/
function useScrollRectIntoView(
parent: HTMLElement | undefined,
rect: ElementOffsetRect,
{ margin = DEFAULT_SCROLL_MARGIN } = {}
) {
useLayoutEffect( () => {
if ( ! parent || ! rect ) {
return;
}

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

const parentRightEdge = parentScroll + parentWidth;
const childRightEdge = childLeft + childWidth;
const rightOverflow = childRightEdge + margin - parentRightEdge;
const leftOverflow = parentScroll - ( childLeft - margin );
if ( leftOverflow > 0 ) {
parent.scrollLeft = parentScroll - leftOverflow;
} else if ( rightOverflow > 0 ) {
parent.scrollLeft = parentScroll + rightOverflow;
}
}, [ margin, parent, rect ] );
}

export const TabList = forwardRef<
HTMLDivElement,
Expand All @@ -35,44 +67,27 @@ export const TabList = forwardRef<
const activeId = useStoreState( store, 'activeId' );
const selectOnMove = useStoreState( store, 'selectOnMove' );
const items = useStoreState( store, 'items' );
const [ parent, setParent ] = useState< HTMLElement | null >();
const [ parent, setParent ] = useState< HTMLElement >();
const refs = useMergeRefs( [ ref, setParent ] );
const selectedRect = useTrackElementOffsetRect(
store?.item( selectedId )?.element
);

// Track overflow to show scroll hints.
const overflow = useTrackOverflow( parent, {
first: items?.at( 0 )?.element,
last: items?.at( -1 )?.element,
} );

const selectedTabPosition = useTrackElementOffsetRect(
store?.item( selectedId )?.element
);

const [ animationEnabled, setAnimationEnabled ] = useState( false );
useOnValueUpdate( selectedId, ( { previousValue } ) => {
if ( previousValue ) {
setAnimationEnabled( true );
}
// Size, position, and animate the indicator.
useAnimatedOffsetRect( parent, selectedRect, {
prefix: 'selected',
dataAttribute: 'indicator-animated',
transitionEndFilter: ( event ) => event.pseudoElement === '::before',
} );

// 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 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 ] );
useScrollRectIntoView( parent, selectedRect );

const onBlur = () => {
if ( ! selectOnMove ) {
Expand All @@ -97,30 +112,13 @@ export const TabList = forwardRef<
<Ariakit.TabList
ref={ refs }
store={ store }
render={
<TabListWrapper
onTransitionEnd={ ( event ) => {
if ( event.pseudoElement === '::before' ) {
setAnimationEnabled( false );
}
} }
/>
}
render={ <TabListWrapper /> }
onBlur={ onBlur }
tabIndex={ -1 }
{ ...otherProps }
style={ {
'--indicator-top': selectedTabPosition.top,
'--indicator-right': selectedTabPosition.right,
'--indicator-left': selectedTabPosition.left,
'--indicator-width': selectedTabPosition.width,
'--indicator-height': selectedTabPosition.height,
...otherProps.style,
} }
className={ clsx(
overflow.first && 'is-overflowing-first',
overflow.last && 'is-overflowing-last',
animationEnabled && 'is-animation-enabled',
otherProps.className
) }
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { useLayoutEffect, useMemo, useState } from '@wordpress/element';
import { useMemo, useState } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -20,104 +20,9 @@ import { VisualLabelWrapper } from './styles';
import * as styles from './styles';
import { ToggleGroupControlAsRadioGroup } from './as-radio-group';
import { ToggleGroupControlAsButtonGroup } from './as-button-group';
import type { ElementOffsetRect } from '../../utils/element-rect';
import { useTrackElementOffsetRect } from '../../utils/element-rect';
import { useOnValueUpdate } from '../../utils/hooks/use-on-value-update';
import { useEvent, useMergeRefs } from '@wordpress/compose';

/**
* A utility used to animate something in a container component based on the "offset
* rect" (position relative to the container and size) of a subelement. For example,
* this is useful to render an indicator for the selected option of a component, and
* to animate it when the selected option changes.
*
* Takes in a container element and the up-to-date "offset rect" of the target
* subelement, obtained with `useTrackElementOffsetRect`. Then it does the following:
*
* - Adds CSS variables with rect information to the container, so that the indicator
* can be rendered and animated with them. These are kept up-to-date, enabling CSS
* transitions on change.
* - Sets an attribute (`data-subelement-animated` by default) when the tracked
* element changes, so that the target (e.g. the indicator) can be animated to its
* new size and position.
* - Removes the attribute when the animation is done.
*
* The need for the attribute is due to the fact that the rect might update in
* situations other than when the tracked element changes, e.g. the tracked element
* might be resized. In such cases, there is no need to animate the indicator, and
* the change in size or position of the indicator needs to be reflected immediately.
*/
function useAnimatedOffsetRect(
/**
* The container element.
*/
container: HTMLElement | undefined,
/**
* The rect of the tracked element.
*/
rect: ElementOffsetRect,
{
prefix = 'subelement',
dataAttribute = `${ prefix }-animated`,
transitionEndFilter = () => true,
}: {
/**
* The prefix used for the CSS variables, e.g. if `prefix` is `selected`, the
* CSS variables will be `--selected-top`, `--selected-left`, etc.
* @default 'subelement'
*/
prefix?: string;
/**
* The name of the data attribute used to indicate that the animation is in
* progress. The `data-` prefix is added automatically.
*
* For example, if `dataAttribute` is `indicator-animated`, the attribute will
* be `data-indicator-animated`.
* @default `${ prefix }-animated`
*/
dataAttribute?: string;
/**
* A function that is called with the transition event and returns a boolean
* indicating whether the animation should be stopped. The default is a function
* that always returns `true`.
*
* For example, if the animated element is the `::before` pseudo-element, the
* function can be written as `( event ) => event.pseudoElement === '::before'`.
* @default () => true
*/
transitionEndFilter?: ( event: TransitionEvent ) => boolean;
} = {}
) {
const setProperties = useEvent( () => {
( Object.keys( rect ) as Array< keyof typeof rect > ).forEach(
( property ) =>
property !== 'element' &&
container?.style.setProperty(
`--${ prefix }-${ property }`,
String( rect[ property ] )
)
);
} );
useLayoutEffect( () => {
setProperties();
}, [ rect, setProperties ] );
useOnValueUpdate( rect.element, ( { previousValue } ) => {
// Only enable the animation when moving from one element to another.
if ( rect.element && previousValue ) {
container?.setAttribute( `data-${ dataAttribute }`, '' );
}
} );
useLayoutEffect( () => {
function onTransitionEnd( event: TransitionEvent ) {
if ( transitionEndFilter( event ) ) {
container?.removeAttribute( `data-${ dataAttribute }` );
}
}
container?.addEventListener( 'transitionend', onTransitionEnd );
return () =>
container?.removeEventListener( 'transitionend', onTransitionEnd );
}, [ dataAttribute, container, transitionEndFilter ] );
}
import { useMergeRefs } from '@wordpress/compose';
import { useAnimatedOffsetRect } from '../../utils/hooks/use-animated-offset-rect';

function UnconnectedToggleGroupControl(
props: WordPressComponentProps< ToggleGroupControlProps, 'div', false >,
Expand Down
Loading

0 comments on commit 570a81d

Please sign in to comment.