diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 3baddb5cccadd6..2d859184ae381b 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -6,6 +6,8 @@
- `ToolsPanel`: atomic one-step state update when (un)registering panels ([#65564](https://github.com/WordPress/gutenberg/pull/65564)).
- `Navigator`: fix `isInitial` logic ([#65527](https://github.com/WordPress/gutenberg/pull/65527)).
+- `ToggleGroupControl`: Fix arrow key navigation in RTL ([#65735](https://github.com/WordPress/gutenberg/pull/65735)).
+- `ToggleGroupControl`: indicator doesn't jump around when the layout around it changes ([#65175](https://github.com/WordPress/gutenberg/pull/65175)).
### Deprecations
@@ -21,6 +23,8 @@
- `Navigator`: add support for exit animation ([#64777](https://github.com/WordPress/gutenberg/pull/64777)).
- `Guide`: Update finish button to use the new default size ([#65680](https://github.com/WordPress/gutenberg/pull/65680)).
- `BorderControl`: Use `__next40pxDefaultSize` prop for Reset button ([#65682](https://github.com/WordPress/gutenberg/pull/65682)).
+- `Navigator`: stabilize APIs ([#64613](https://github.com/WordPress/gutenberg/pull/64613)).
+- `ToggleGroupControl`: indicator animation is now more lightweight and performant ([#65175](https://github.com/WordPress/gutenberg/pull/65175)).
## 28.8.0 (2024-09-19)
diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
index e9b4f4ca22ab85..6885263d09b23d 100644
--- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
+++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
@@ -60,6 +60,55 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
outline-offset: -2px;
}
+@media not ( prefers-reduced-motion ) {
+ .emotion-8.is-animation-enabled::before {
+ transition-property: transform,border-radius;
+ transition-duration: 0.2s;
+ transition-timing-function: ease-out;
+ }
+}
+
+.emotion-8::before {
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ background: #1e1e1e;
+ outline: 2px solid transparent;
+ outline-offset: -3px;
+ --antialiasing-factor: 100;
+ border-radius: calc(
+ 1px /
+ (
+ var( --selected-width, 0 ) /
+ var( --antialiasing-factor )
+ )
+ )/1px;
+ left: -1px;
+ width: calc( var( --antialiasing-factor ) * 1px );
+ height: calc( var( --selected-height, 0 ) * 1px );
+ transform-origin: left top;
+ -webkit-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ -moz-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ -ms-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+}
+
.emotion-10 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
@@ -150,17 +199,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
line-height: 1;
}
-.emotion-15 {
- background: #1e1e1e;
- border-radius: 1px;
- position: absolute;
- inset: 0;
- z-index: 1;
- outline: 2px solid transparent;
- outline-offset: -3px;
-}
-
-.emotion-18 {
+.emotion-17 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -204,22 +243,22 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
}
@media not ( prefers-reduced-motion ) {
- .emotion-18 {
+ .emotion-17 {
-webkit-transition: background 160ms linear,color 160ms linear,font-weight 60ms linear;
transition: background 160ms linear,color 160ms linear,font-weight 60ms linear;
}
}
-.emotion-18::-moz-focus-inner {
+.emotion-17::-moz-focus-inner {
border: 0;
}
-.emotion-18[disabled] {
+.emotion-17[disabled] {
opacity: 0.4;
cursor: default;
}
-.emotion-18:active {
+.emotion-17:active {
background: #fff;
}
@@ -280,12 +319,6 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
-
-
{
if ( showTooltip && text ) {
return (
@@ -58,7 +51,6 @@ function ToggleGroupControlOptionBase(
>,
forwardedRef: ForwardedRef< any >
) {
- const shouldReduceMotion = useReducedMotion();
const toggleGroupControlContext = useToggleGroupControlContext();
const id = useInstanceId(
@@ -107,7 +99,6 @@ function ToggleGroupControlOptionBase(
),
[ cx, isDeselectable, isIcon, isPressed, size, className ]
);
- const backdropClasses = useMemo( () => cx( styles.backdropView ), [ cx ] );
const buttonOnClick = () => {
if ( isDeselectable && isPressed ) {
@@ -124,8 +115,15 @@ function ToggleGroupControlOptionBase(
ref: forwardedRef,
};
+ const labelRef = useRef< HTMLDivElement | null >( null );
+ useLayoutEffect( () => {
+ if ( isPressed && labelRef.current ) {
+ toggleGroupControlContext.setSelectedElement( labelRef.current );
+ }
+ }, [ isPressed, toggleGroupControlContext ] );
+
return (
-
+
) }
- { /* Animated backdrop using framer motion's shared layout animation */ }
- { isPressed ? (
-
-
-
- ) : null }
);
}
diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
index 020468991225c1..c0248f9b3f7f22 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
+++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts
@@ -119,14 +119,3 @@ const isIconStyles = ( {
padding-right: 0;
`;
};
-
-export const backdropView = css`
- background: ${ COLORS.gray[ 900 ] };
- border-radius: ${ CONFIG.radiusXSmall };
- position: absolute;
- inset: 0;
- z-index: 1;
- // Windows High Contrast mode will show this outline, but not the box-shadow.
- outline: 2px solid transparent;
- outline-offset: -3px;
-`;
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index b3f56bccd07c5f..7ce762b6e71df2 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -26,6 +26,7 @@ function UnforwardedToggleGroupControlAsButtonGroup(
size,
value: valueProp,
id: idProp,
+ setSelectedElement,
...otherProps
}: WordPressComponentProps<
ToggleGroupControlMainControlProps,
@@ -54,16 +55,23 @@ function UnforwardedToggleGroupControlAsButtonGroup(
} );
const groupContextValue = useMemo(
- () =>
- ( {
- baseId,
- value: selectedValue,
- setValue: setSelectedValue,
- isBlock: ! isAdaptiveWidth,
- isDeselectable: true,
- size,
- } ) as ToggleGroupControlContextProps,
- [ baseId, selectedValue, setSelectedValue, isAdaptiveWidth, size ]
+ (): ToggleGroupControlContextProps => ( {
+ baseId,
+ value: selectedValue,
+ setValue: setSelectedValue,
+ isBlock: ! isAdaptiveWidth,
+ isDeselectable: true,
+ size,
+ setSelectedElement,
+ } ),
+ [
+ baseId,
+ selectedValue,
+ setSelectedValue,
+ isAdaptiveWidth,
+ size,
+ setSelectedElement,
+ ]
);
return (
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
index c062e35cb2b72b..342f9f128defd9 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
@@ -33,6 +33,7 @@ function UnforwardedToggleGroupControlAsRadioGroup(
size,
value: valueProp,
id: idProp,
+ setSelectedElement,
...otherProps
}: WordPressComponentProps<
ToggleGroupControlMainControlProps,
@@ -73,15 +74,24 @@ function UnforwardedToggleGroupControlAsRadioGroup(
const setValue = radio.setValue;
const groupContextValue = useMemo(
- () =>
- ( {
- baseId,
- isBlock: ! isAdaptiveWidth,
- size,
- value: selectedValue,
- setValue,
- } ) as ToggleGroupControlContextProps,
- [ baseId, isAdaptiveWidth, size, selectedValue, setValue ]
+ (): ToggleGroupControlContextProps => ( {
+ baseId,
+ isBlock: ! isAdaptiveWidth,
+ size,
+ // @ts-expect-error - This is wrong and we should fix it.
+ value: selectedValue,
+ // @ts-expect-error - This is wrong and we should fix it.
+ setValue,
+ setSelectedElement,
+ } ),
+ [
+ baseId,
+ isAdaptiveWidth,
+ selectedValue,
+ setSelectedElement,
+ setValue,
+ size,
+ ]
);
return (
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
index 1c86c93548f6df..5f8da76676293e 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
@@ -2,13 +2,11 @@
* External dependencies
*/
import type { ForwardedRef } from 'react';
-import { LayoutGroup } from 'framer-motion';
/**
* WordPress dependencies
*/
-import { useInstanceId } from '@wordpress/compose';
-import { useMemo } from '@wordpress/element';
+import { useLayoutEffect, useMemo, useState } from '@wordpress/element';
/**
* Internal dependencies
@@ -22,6 +20,68 @@ import { VisualLabelWrapper } from './styles';
import * as styles from './styles';
import { ToggleGroupControlAsRadioGroup } from './as-radio-group';
import { ToggleGroupControlAsButtonGroup } from './as-button-group';
+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 (e.g. an indicator for the selected option
+ * of a component).
+ *
+ * It works by tracking the position and size (i.e., the "rect") of a given subelement,
+ * typically the one that corresponds to the selected option, relative to its offset
+ * parent. Then it:
+ *
+ * - Keeps CSS variables with that information in the parent, so that the animation
+ * can be implemented with them.
+ * - Adds a `is-animation-enabled` CSS class when the element changes, so that the
+ * target (e.g. the indicator) can be animated to its new position.
+ * - Removes the `is-animation-enabled` class when the animation is done.
+ */
+function useSubelementAnimation(
+ subelement?: HTMLElement | null,
+ {
+ parent = subelement?.offsetParent as HTMLElement | null | undefined,
+ prefix = 'subelement',
+ transitionEndFilter,
+ }: {
+ parent?: HTMLElement | null | undefined;
+ prefix?: string;
+ transitionEndFilter?: ( event: TransitionEvent ) => boolean;
+ } = {}
+) {
+ const rect = useTrackElementOffsetRect( subelement );
+
+ const setProperties = useEvent( () => {
+ ( Object.keys( rect ) as Array< keyof typeof rect > ).forEach(
+ ( property ) =>
+ property !== 'element' &&
+ parent?.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 ) {
+ parent?.classList.add( 'is-animation-enabled' );
+ }
+ } );
+ useLayoutEffect( () => {
+ function onTransitionEnd( event: TransitionEvent ) {
+ if ( transitionEndFilter?.( event ) ?? true ) {
+ parent?.classList.remove( 'is-animation-enabled' );
+ }
+ }
+ parent?.addEventListener( 'transitionend', onTransitionEnd );
+ return () =>
+ parent?.removeEventListener( 'transitionend', onTransitionEnd );
+ }, [ parent, transitionEndFilter ] );
+}
function UnconnectedToggleGroupControl(
props: WordPressComponentProps< ToggleGroupControlProps, 'div', false >,
@@ -44,10 +104,18 @@ function UnconnectedToggleGroupControl(
...otherProps
} = useContextSystem( props, 'ToggleGroupControl' );
- const baseId = useInstanceId( ToggleGroupControl, 'toggle-group-control' );
const normalizedSize =
__next40pxDefaultSize && size === 'default' ? '__unstable-large' : size;
+ const [ selectedElement, setSelectedElement ] = useState< HTMLElement >();
+ const [ controlElement, setControlElement ] = useState< HTMLElement >();
+ const refs = useMergeRefs( [ setControlElement, forwardedRef ] );
+ useSubelementAnimation( value ? selectedElement : undefined, {
+ parent: controlElement,
+ prefix: 'selected',
+ transitionEndFilter: ( event ) => event.pseudoElement === '::before',
+ } );
+
const cx = useCx();
const classes = useMemo(
@@ -81,15 +149,16 @@ function UnconnectedToggleGroupControl(
) }
- { children }
+ { children }
);
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
index 8d01c150a45eaf..ee6122126f557f 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
+++ b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts
@@ -26,6 +26,47 @@ export const toggleGroupControl = ( {
${ toggleGroupControlSize( size ) }
${ ! isDeselectable && enclosingBorders( isBlock ) }
+
+ @media not ( prefers-reduced-motion ) {
+ &.is-animation-enabled::before {
+ transition-property: transform, border-radius;
+ transition-duration: 0.2s;
+ transition-timing-function: ease-out;
+ }
+ }
+
+ &::before {
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ background: ${ COLORS.gray[ 900 ] };
+
+ // Windows High Contrast mode will show this outline, but not the box-shadow.
+ outline: 2px solid transparent;
+ outline-offset: -3px;
+
+ /* Using a large value to avoid antialiasing rounding issues
+ when scaling in the transform, see: https://stackoverflow.com/a/52159123 */
+ --antialiasing-factor: 100;
+ /* Adjusting the border radius to match the scaling in the x axis. */
+ border-radius: calc(
+ ${ CONFIG.radiusXSmall } /
+ (
+ var( --selected-width, 0 ) /
+ var( --antialiasing-factor )
+ )
+ ) / ${ CONFIG.radiusXSmall };
+ left: -1px; // Correcting for border.
+ width: calc( var( --antialiasing-factor ) * 1px );
+ height: calc( var( --selected-height, 0 ) * 1px );
+ transform-origin: left top;
+ transform: translateX( calc( var( --selected-left, 0 ) * 1px ) )
+ scaleX(
+ calc(
+ var( --selected-width, 0 ) / var( --antialiasing-factor )
+ )
+ );
+ }
`;
const enclosingBorders = ( isBlock: ToggleGroupControlProps[ 'isBlock' ] ) => {
diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts
index d49ef3cbb77cb4..2a4af680263dba 100644
--- a/packages/components/src/toggle-group-control/types.ts
+++ b/packages/components/src/toggle-group-control/types.ts
@@ -137,9 +137,11 @@ export type ToggleGroupControlContextProps = {
size: ToggleGroupControlProps[ 'size' ];
value: ToggleGroupControlProps[ 'value' ];
setValue: ( newValue: string | number | undefined ) => void;
+ setSelectedElement: ( element: HTMLElement | undefined ) => void;
};
export type ToggleGroupControlMainControlProps = Pick<
ToggleGroupControlProps,
'children' | 'isAdaptiveWidth' | 'label' | 'size' | 'onChange' | 'value'
->;
+> &
+ Pick< ToggleGroupControlContextProps, 'setSelectedElement' >;
diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts
index 4c60e4ba51c48a..7c83db4428ca0f 100644
--- a/packages/components/src/utils/element-rect.ts
+++ b/packages/components/src/utils/element-rect.ts
@@ -9,6 +9,10 @@ import { useEvent, useResizeObserver } from '@wordpress/compose';
* The position and dimensions of an element, relative to its offset parent.
*/
export type ElementOffsetRect = {
+ /**
+ * The element the rect belongs to.
+ */
+ element: HTMLElement | undefined;
/**
* The distance from the top edge of the offset parent to the top edge of
* the element.
@@ -43,6 +47,7 @@ export type ElementOffsetRect = {
* An `ElementOffsetRect` object with all values set to zero.
*/
export const NULL_ELEMENT_OFFSET_RECT = {
+ element: undefined,
top: 0,
right: 0,
bottom: 0,
@@ -92,6 +97,7 @@ export function getElementOffsetRect(
const scaleY = computedHeight / rect.height;
return {
+ element,
// 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.
@@ -119,6 +125,9 @@ const POLL_RATE = 100;
* Tracks the position and dimensions of an element, relative to its offset
* parent. The element can be changed dynamically.
*
+ * When no element is provided (`null` or `undefined`), the hook will return
+ * a "null" rect, in which all values are `0` and `element` is `undefined`.
+ *
* **Note:** sometimes, the measurement will fail (see `getElementOffsetRect`'s
* documentation for more details). When that happens, this hook will attempt
* to measure again after a frame, and if that fails, it will poll every 100
@@ -155,10 +164,12 @@ export function useTrackElementOffsetRect(
}
} );
- useLayoutEffect(
- () => setElement( targetElement ),
- [ setElement, targetElement ]
- );
+ useLayoutEffect( () => {
+ setElement( targetElement );
+ if ( ! targetElement ) {
+ setIndicatorPosition( NULL_ELEMENT_OFFSET_RECT );
+ }
+ }, [ setElement, targetElement ] );
return indicatorPosition;
}
diff --git a/packages/components/src/utils/hooks/use-on-value-update.ts b/packages/components/src/utils/hooks/use-on-value-update.ts
index 05c7173d092fac..15cfc321359e7c 100644
--- a/packages/components/src/utils/hooks/use-on-value-update.ts
+++ b/packages/components/src/utils/hooks/use-on-value-update.ts
@@ -3,7 +3,7 @@
* WordPress dependencies
*/
import { useEvent } from '@wordpress/compose';
-import { useRef, useEffect } from '@wordpress/element';
+import { useRef, useLayoutEffect } from '@wordpress/element';
/**
* Context object for the `onUpdate` callback of `useOnValueUpdate`.
@@ -27,7 +27,7 @@ export function useOnValueUpdate< T >(
) {
const previousValueRef = useRef( value );
const updateCallbackEvent = useEvent( onUpdate );
- useEffect( () => {
+ useLayoutEffect( () => {
if ( previousValueRef.current !== value ) {
updateCallbackEvent( {
previousValue: previousValueRef.current,