From ed5e18dbe9ea414064ad20feba4ce1c20332558c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sun, 30 Apr 2023 13:43:22 +0200 Subject: [PATCH 01/42] Reuse previous implementation --- .../stories/index.story.tsx | 112 ++++++++++++++++++ .../component.tsx | 28 ++++- .../styles.ts | 11 ++ .../toggle-group-control/as-button-group.tsx | 30 ++--- .../toggle-group-control/as-radio-group.tsx | 30 ++--- .../toggle-group-control/component.tsx | 2 +- .../toggle-group-control/styles.ts | 21 +--- .../toggle-group-control-backdrop.tsx | 84 ------------- .../src/toggle-group-control/types.ts | 9 +- 9 files changed, 175 insertions(+), 152 deletions(-) delete mode 100644 packages/components/src/toggle-group-control/toggle-group-control/toggle-group-control-backdrop.tsx diff --git a/packages/components/src/toggle-group-control/stories/index.story.tsx b/packages/components/src/toggle-group-control/stories/index.story.tsx index 92f1e6076248b..cf945797d20be 100644 --- a/packages/components/src/toggle-group-control/stories/index.story.tsx +++ b/packages/components/src/toggle-group-control/stories/index.story.tsx @@ -2,6 +2,8 @@ * External dependencies */ import type { Meta, StoryFn } from '@storybook/react'; +// eslint-disable-next-line no-restricted-imports +import { motion } from 'framer-motion'; /** * WordPress dependencies @@ -12,6 +14,8 @@ import { formatLowercase, formatUppercase } from '@wordpress/icons'; /** * Internal dependencies */ +import Button from '../../button'; +import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; import { ToggleGroupControl, ToggleGroupControlOption, @@ -140,3 +144,111 @@ Deselectable.args = { ...WithIcons.args, isDeselectable: true, }; + +// TODO: remove before merging +export const DoubleToggles: StoryFn< + typeof ToggleGroupControl +> = () => { + const aligns = [ 'Left', 'Center', 'Right' ]; + const quantities = [ 'One', 'Two', 'Three', 'Four' ]; + + const [ alignState, setAlignState ] = useState< string | undefined >(); + const [ quantityState, setQuantityState ] = useState< + string | undefined + >(); + + return ( +
+ setAlignState( value as string ) } + value={ alignState } + label={ 'Pick an alignment option' } + > + { aligns.map( ( key ) => ( + + ) ) } + + + + setQuantityState( value as string ) } + value={ quantityState } + label={ 'Pick a quantity' } + > + { quantities.map( ( key ) => ( + + ) ) } + + +
+ ); +}; + +// TODO: Remove before merging as well. +const { Fill: InspectorControls, Slot } = createSlotFill( 'InspectorControls' ); +// @ts-expect-error +InspectorControls.Slot = Slot; + +export const RenderViaSlot: StoryFn< + typeof ToggleGroupControl +> = () => { + const [ alignState, setAlignState ] = useState< string | undefined >(); + const aligns = [ 'Left', 'Center', 'Right' ]; + + return ( + + { /* This motion.div element breaks the `ToggleGroupControl` backdrop, + * because motion registers it as the "motion parent" of the backdrop + * (even if the `ToggleGroupControl` gets rendered in another part of the + * tree via Slot/Fill) + */ } + + + + setAlignState( value as string ) + } + value={ alignState } + label={ 'Pick an alignment option' } + > + { aligns.map( ( key ) => ( + + ) ) } + + + +
+ { /* @ts-expect-error */ } + + +
+
+ ); +}; diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx index eb36f06022eed..1ab2e14590b9b 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx @@ -4,11 +4,14 @@ import type { ForwardedRef } from 'react'; // eslint-disable-next-line no-restricted-imports import { Radio } from 'reakit'; +// eslint-disable-next-line no-restricted-imports +import { motion, useReducedMotion } from 'framer-motion'; /** * WordPress dependencies */ import { useInstanceId } from '@wordpress/compose'; +// import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -26,6 +29,12 @@ import Tooltip from '../../tooltip'; const { ButtonContentView, LabelView } = styles; +const REDUCED_MOTION_TRANSITION_CONFIG = { + duration: 0, +}; + +const LAYOUT_ID = 'toggle-group-backdrop-shared-layout-id'; + const WithToolTip = ( { showTooltip, text, children }: WithToolTipProps ) => { if ( showTooltip && text ) { return ( @@ -45,6 +54,7 @@ function ToggleGroupControlOptionBase( >, forwardedRef: ForwardedRef< any > ) { + const shouldReduceMotion = useReducedMotion(); const toggleGroupControlContext = useToggleGroupControlContext(); const id = useInstanceId( ToggleGroupControlOptionBase, @@ -72,10 +82,11 @@ function ToggleGroupControlOptionBase( const isPressed = otherContextProps.state === value; const cx = useCx(); const labelViewClasses = cx( isBlock && styles.labelBlock ); - const classes = cx( + const itemClasses = cx( styles.buttonView( { isDeselectable, isIcon, isPressed, size } ), className ); + const backdropClasses = cx( styles.backdropView ); const buttonOnClick = () => { if ( isDeselectable && isPressed ) { @@ -87,7 +98,7 @@ function ToggleGroupControlOptionBase( const commonProps = { ...otherButtonProps, - className: classes, + className: itemClasses, 'data-value': value, ref: forwardedRef, }; @@ -120,6 +131,19 @@ function ToggleGroupControlOptionBase( ) } + { /* 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 c3abc1fad2416..e097f13cc6bf3 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 @@ -109,3 +109,14 @@ const isIconStyles = ( { padding-right: 0; `; }; + +export const backdropView = css` + background: ${ COLORS.gray[ 900 ] }; + border-radius: ${ CONFIG.controlBorderRadius }; + 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 72e247659818f..95407f6586355 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 @@ -2,23 +2,19 @@ * External dependencies */ import type { ForwardedRef } from 'react'; +// eslint-disable-next-line no-restricted-imports +import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies */ -import { - useMergeRefs, - useInstanceId, - usePrevious, - useResizeObserver, -} from '@wordpress/compose'; -import { forwardRef, useRef, useState } from '@wordpress/element'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; +import { forwardRef, useState } from '@wordpress/element'; /** * Internal dependencies */ import { View } from '../../view'; -import ToggleGroupControlBackdrop from './toggle-group-control-backdrop'; import ToggleGroupControlContext from '../context'; import { useUpdateEffect } from '../../utils/hooks'; import type { WordPressComponentProps } from '../../ui/context'; @@ -40,8 +36,6 @@ function UnforwardedToggleGroupControlAsButtonGroup( >, forwardedRef: ForwardedRef< HTMLDivElement > ) { - const containerRef = useRef(); - const [ resizeListener, sizes ] = useResizeObserver(); const baseId = useInstanceId( ToggleGroupControlAsButtonGroup, 'toggle-group-control-as-button-group' @@ -82,17 +76,15 @@ function UnforwardedToggleGroupControlAsButtonGroup( - { resizeListener } - - { children } + { /* `LayoutGroup` acts as a "namespace" for the backdrop's shared + layout animation (defined in `ToggleGroupControlOptionBase`), and + thus it allows multiple instances of `ToggleGroupControl` in the + same page, each with their independent backdrop animation. + */ } + { children } ); 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 9f66e964d9d43..f354d302e3927 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 @@ -4,23 +4,19 @@ import type { ForwardedRef } from 'react'; // eslint-disable-next-line no-restricted-imports import { RadioGroup, useRadioState } from 'reakit'; +// eslint-disable-next-line no-restricted-imports +import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies */ -import { - useMergeRefs, - useInstanceId, - usePrevious, - useResizeObserver, -} from '@wordpress/compose'; -import { forwardRef, useRef } from '@wordpress/element'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; +import { forwardRef } from '@wordpress/element'; /** * Internal dependencies */ import { View } from '../../view'; -import ToggleGroupControlBackdrop from './toggle-group-control-backdrop'; import ToggleGroupControlContext from '../context'; import { useUpdateEffect } from '../../utils/hooks'; import type { WordPressComponentProps } from '../../ui/context'; @@ -42,8 +38,6 @@ function UnforwardedToggleGroupControlAsRadioGroup( >, forwardedRef: ForwardedRef< HTMLDivElement > ) { - const containerRef = useRef(); - const [ resizeListener, sizes ] = useResizeObserver(); const baseId = useInstanceId( ToggleGroupControlAsRadioGroup, 'toggle-group-control-as-radio-group' @@ -79,16 +73,14 @@ function UnforwardedToggleGroupControlAsRadioGroup( aria-label={ label } as={ View } { ...otherProps } - ref={ useMergeRefs( [ containerRef, forwardedRef ] ) } + ref={ forwardedRef } > - { resizeListener } - - { children } + { /* `LayoutGroup` acts as a "namespace" for the backdrop's shared + layout animation (defined in `ToggleGroupControlOptionBase`), and + thus it allows multiple instances of `ToggleGroupControl` in the + same page, each with their independent backdrop animation. + */ } + { children } ); 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 f9a65e0aacacd..c4e7d5210341a 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 @@ -47,7 +47,7 @@ function UnconnectedToggleGroupControl( const classes = useMemo( () => cx( - styles.ToggleGroupControl( { isBlock, isDeselectable, size } ), + styles.toggleGroupControl( { isBlock, isDeselectable, size } ), isBlock && styles.block, className ), 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 fa71cc317da48..5d0b90096024c 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 @@ -7,10 +7,10 @@ import styled from '@emotion/styled'; /** * Internal dependencies */ -import { CONFIG, COLORS, reduceMotion } from '../../utils'; +import { CONFIG, COLORS } from '../../utils'; import type { ToggleGroupControlProps } from '../types'; -export const ToggleGroupControl = ( { +export const toggleGroupControl = ( { isBlock, isDeselectable, size, @@ -24,8 +24,6 @@ export const ToggleGroupControl = ( { min-width: 0; padding: 2px; position: relative; - transition: transform ${ CONFIG.transitionDurationFastest } linear; - ${ reduceMotion( 'transition' ) } ${ toggleGroupControlSize( size ) } ${ ! isDeselectable && enclosingBorders( isBlock ) } @@ -72,21 +70,6 @@ export const block = css` width: 100%; `; -export const BackdropView = styled.div` - background: ${ COLORS.gray[ 900 ] }; - border-radius: ${ CONFIG.controlBorderRadius }; - left: 0; - position: absolute; - top: 2px; - bottom: 2px; - transition: transform ${ CONFIG.transitionDurationFast } ease; - ${ reduceMotion( 'transition' ) } - z-index: 1; - // Windows High Contrast mode will show this outline, but not the box-shadow. - outline: 2px solid transparent; - outline-offset: -3px; -`; - export const VisualLabelWrapper = styled.div` // Makes the inline label be the correct height, equivalent to setting line-height: 0 display: flex; diff --git a/packages/components/src/toggle-group-control/toggle-group-control/toggle-group-control-backdrop.tsx b/packages/components/src/toggle-group-control/toggle-group-control/toggle-group-control-backdrop.tsx deleted file mode 100644 index 6d122c711deb5..0000000000000 --- a/packages/components/src/toggle-group-control/toggle-group-control/toggle-group-control-backdrop.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState, useEffect, memo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import type { ToggleGroupControlBackdropProps } from '../types'; -import { BackdropView } from './styles'; - -function ToggleGroupControlBackdrop( { - containerRef, - containerWidth, - isAdaptiveWidth, - state, -}: ToggleGroupControlBackdropProps ) { - const [ left, setLeft ] = useState( 0 ); - const [ width, setWidth ] = useState( 0 ); - const [ canAnimate, setCanAnimate ] = useState( false ); - const [ renderBackdrop, setRenderBackdrop ] = useState( false ); - - useEffect( () => { - const containerNode = containerRef?.current; - if ( ! containerNode ) return; - - /** - * Workaround for Reakit - */ - const targetNode = containerNode.querySelector( - `[data-value="${ state }"]` - ); - setRenderBackdrop( !! targetNode ); - if ( ! targetNode ) { - return; - } - - const computeDimensions = () => { - const { width: offsetWidth, x } = - targetNode.getBoundingClientRect(); - - const { x: parentX } = containerNode.getBoundingClientRect(); - - const borderWidth = 1; - const offsetLeft = x - parentX - borderWidth; - - setLeft( offsetLeft ); - setWidth( offsetWidth ); - }; - // Fix to make the component appear as expected inside popovers. - // If the targetNode width is 0 it means the element was not yet rendered we should allow - // some time for the render to happen. - // requestAnimationFrame instead of setTimeout with a small time does not seems to work. - const dimensionsRequestId = window.setTimeout( computeDimensions, 100 ); - - let animationRequestId: number; - if ( ! canAnimate ) { - animationRequestId = window.requestAnimationFrame( () => { - setCanAnimate( true ); - } ); - } - return () => { - window.clearTimeout( dimensionsRequestId ); - window.cancelAnimationFrame( animationRequestId ); - }; - }, [ canAnimate, containerRef, containerWidth, state, isAdaptiveWidth ] ); - - if ( ! renderBackdrop ) { - return null; - } - - return ( - - ); -} - -export default memo( ToggleGroupControlBackdrop ); diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts index 4cc82bd3ac7b0..ee2f872f51f53 100644 --- a/packages/components/src/toggle-group-control/types.ts +++ b/packages/components/src/toggle-group-control/types.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { MutableRefObject, ReactNode, ReactText } from 'react'; +import type { ReactNode, ReactText } from 'react'; // eslint-disable-next-line no-restricted-imports import type { RadioStateReturn } from 'reakit'; @@ -141,13 +141,6 @@ export type ToggleGroupControlContextProps = Pick< baseId: string; } & ( ToggleGroupControlAsRadioContext | ToggleGroupControlAsButtonContext ); -export type ToggleGroupControlBackdropProps = { - containerRef: MutableRefObject< HTMLElement | undefined >; - containerWidth?: number | null; - isAdaptiveWidth?: boolean; - state?: any; -}; - export type ToggleGroupControlMainControlProps = Pick< ToggleGroupControlProps, 'children' | 'isAdaptiveWidth' | 'label' | 'size' From ea4c62f7f785c164fd49aff9ff3c16e5efec4be6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 1 May 2023 17:49:25 +0200 Subject: [PATCH 02/42] Reset motion context in slot-fill --- .../src/slot-fill/bubbles-virtually/fill.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/components/src/slot-fill/bubbles-virtually/fill.js b/packages/components/src/slot-fill/bubbles-virtually/fill.js index c86f3d0765fab..b8ae95be419d7 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/fill.js +++ b/packages/components/src/slot-fill/bubbles-virtually/fill.js @@ -1,4 +1,11 @@ // @ts-nocheck + +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import { MotionContext } from 'framer-motion'; + /** * WordPress dependencies */ @@ -56,9 +63,13 @@ export default function Fill( { name, children } ) { // to make sure we're referencing the right document/iframe (instead of the // context of the `Fill`'s parent). const wrappedChildren = ( - - { children } - + // Resetting framer-motion's context as a way to fix an issue with portals + // (see https://github.com/framer/motion/issues/1524) + + + { children } + + ); return createPortal( wrappedChildren, slot.ref.current ); From b8bc3c5d9186eecaddb3e73ccc6cc278b57fef9a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 1 May 2023 19:16:57 +0200 Subject: [PATCH 03/42] Memoize classes --- .../component.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx index 1ab2e14590b9b..4f0153cea3e2e 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx @@ -11,7 +11,7 @@ import { motion, useReducedMotion } from 'framer-motion'; * WordPress dependencies */ import { useInstanceId } from '@wordpress/compose'; -// import { useMemo } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -81,12 +81,24 @@ function ToggleGroupControlOptionBase( const isPressed = otherContextProps.state === value; const cx = useCx(); - const labelViewClasses = cx( isBlock && styles.labelBlock ); - const itemClasses = cx( - styles.buttonView( { isDeselectable, isIcon, isPressed, size } ), - className + const labelViewClasses = useMemo( + () => cx( isBlock && styles.labelBlock ), + [ cx, isBlock ] ); - const backdropClasses = cx( styles.backdropView ); + const itemClasses = useMemo( + () => + cx( + styles.buttonView( { + isDeselectable, + isIcon, + isPressed, + size, + } ), + className + ), + [ cx, isDeselectable, isIcon, isPressed, size, className ] + ); + const backdropClasses = useMemo( () => cx( styles.backdropView ), [ cx ] ); const buttonOnClick = () => { if ( isDeselectable && isPressed ) { From ba4a3c239dc3cccf0e97b1bb9695e80be2bebd85 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 1 May 2023 19:17:31 +0200 Subject: [PATCH 04/42] Move layout group to root component --- .../toggle-group-control/as-button-group.tsx | 9 +-------- .../toggle-group-control/as-radio-group.tsx | 9 +-------- .../toggle-group-control/component.tsx | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 18 deletions(-) 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 95407f6586355..402e9ed00c4ea 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 @@ -2,8 +2,6 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -// eslint-disable-next-line no-restricted-imports -import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies @@ -79,12 +77,7 @@ function UnforwardedToggleGroupControlAsButtonGroup( ref={ forwardedRef } role="group" > - { /* `LayoutGroup` acts as a "namespace" for the backdrop's shared - layout animation (defined in `ToggleGroupControlOptionBase`), and - thus it allows multiple instances of `ToggleGroupControl` in the - same page, each with their independent backdrop animation. - */ } - { children } + { children } ); 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 f354d302e3927..f954a96988ae3 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 @@ -4,8 +4,6 @@ import type { ForwardedRef } from 'react'; // eslint-disable-next-line no-restricted-imports import { RadioGroup, useRadioState } from 'reakit'; -// eslint-disable-next-line no-restricted-imports -import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies @@ -75,12 +73,7 @@ function UnforwardedToggleGroupControlAsRadioGroup( { ...otherProps } ref={ forwardedRef } > - { /* `LayoutGroup` acts as a "namespace" for the backdrop's shared - layout animation (defined in `ToggleGroupControlOptionBase`), and - thus it allows multiple instances of `ToggleGroupControl` in the - same page, each with their independent backdrop animation. - */ } - { children } + { children } ); 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 c4e7d5210341a..68fd8e3b31459 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,9 +2,13 @@ * External dependencies */ import type { ForwardedRef } from 'react'; +// eslint-disable-next-line no-restricted-imports +import { LayoutGroup } from 'framer-motion'; + /** * WordPress dependencies */ +import { useInstanceId } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; import { useMemo } from '@wordpress/element'; @@ -42,6 +46,12 @@ function UnconnectedToggleGroupControl( children, ...otherProps } = useContextSystem( props, 'ToggleGroupControl' ); + + const baseId = useInstanceId( + ToggleGroupControl, + 'toggle-group-control' + ).toString(); + const cx = useCx(); const classes = useMemo( @@ -70,7 +80,6 @@ function UnconnectedToggleGroupControl( ) } + > + { children } + ); } From 5ab8f5a3b03ef609443bee934bba6c486ee32960 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 2 May 2023 23:29:27 +0200 Subject: [PATCH 05/42] Use useLayoutEffect to apply internal state changes --- .../toggle-group-control/as-radio-group.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) 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 f954a96988ae3..774dc673de71d 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 @@ -9,14 +9,18 @@ import { RadioGroup, useRadioState } from 'reakit'; * WordPress dependencies */ import { useInstanceId, usePrevious } from '@wordpress/compose'; -import { forwardRef } from '@wordpress/element'; +import { + forwardRef, + useRef, + useLayoutEffect, + useEffect, +} from '@wordpress/element'; /** * Internal dependencies */ import { View } from '../../view'; import ToggleGroupControlContext from '../context'; -import { useUpdateEffect } from '../../utils/hooks'; import type { WordPressComponentProps } from '../../ui/context'; import type { ToggleGroupControlMainControlProps } from '../types'; @@ -36,6 +40,8 @@ function UnforwardedToggleGroupControlAsRadioGroup( >, forwardedRef: ForwardedRef< HTMLDivElement > ) { + const mounted = useRef( false ); + const baseId = useInstanceId( ToggleGroupControlAsRadioGroup, 'toggle-group-control-as-radio-group' @@ -46,21 +52,27 @@ function UnforwardedToggleGroupControlAsRadioGroup( } ); const previousValue = usePrevious( value ); + useEffect( () => { + mounted.current = true; + }, [] ); + + const { setState: radioSetState, state: radioState } = radio; + // Propagate radio.state change. - useUpdateEffect( () => { + useLayoutEffect( () => { // Avoid calling onChange if radio state changed // from incoming value. - if ( previousValue !== radio.state ) { - onChange( radio.state ); + if ( mounted.current && previousValue !== radioState ) { + onChange( radioState ); } - }, [ radio.state ] ); + }, [ radioState, previousValue, onChange ] ); // Sync incoming value with radio.state. - useUpdateEffect( () => { - if ( value !== radio.state ) { - radio.setState( value ); + useLayoutEffect( () => { + if ( mounted.current && value !== radioState ) { + radioSetState( value ); } - }, [ value ] ); + }, [ value, radioSetState, radioState ] ); return ( Date: Wed, 3 May 2023 14:49:12 +0200 Subject: [PATCH 06/42] CHANGELOG --- packages/components/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 362e2d26a3d7b..15a9e26fb5055 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -201,6 +201,7 @@ - `NavigableContainer`: Convert to TypeScript ([#49377](https://github.com/WordPress/gutenberg/pull/49377)). - `ToolbarItem`: Convert to TypeScript ([#49190](https://github.com/WordPress/gutenberg/pull/49190)). +- `ToggleGroupControl`: Rewrite backdrop animation using framer motion shared layout animations ([#50278](https://github.com/WordPress/gutenberg/pull/50278)). - Move rich-text related types to the rich-text package ([#49651](https://github.com/WordPress/gutenberg/pull/49651)). - `SlotFill`: simplified the implementation and removed unused code ([#50098](https://github.com/WordPress/gutenberg/pull/50098) and [#50133](https://github.com/WordPress/gutenberg/pull/50133)). From 0a52c458b5a7bf0ca668f6600500a1f7aea15341 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 3 May 2023 15:06:05 +0200 Subject: [PATCH 07/42] Update snapshots --- .../test/__snapshots__/index.tsx.snap | 94 ++++++------------- 1 file changed, 30 insertions(+), 64 deletions(-) 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 e5ea6c14f5ef6..38a478949257e 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 @@ -49,17 +49,9 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` min-width: 0; padding: 2px; position: relative; - -webkit-transition: -webkit-transform 100ms linear; - transition: transform 100ms linear; min-height: 36px; } -@media ( prefers-reduced-motion: reduce ) { - .emotion-8 { - transition-duration: 0ms; - } -} - .emotion-8:hover { border-color: #757575; } @@ -73,26 +65,6 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` } .emotion-10 { - background: #1e1e1e; - border-radius: 2px; - left: 0; - position: absolute; - top: 2px; - bottom: 2px; - -webkit-transition: -webkit-transform 160ms ease; - transition: transform 160ms ease; - z-index: 1; - outline: 2px solid transparent; - outline-offset: -3px; -} - -@media ( prefers-reduced-motion: reduce ) { - .emotion-10 { - transition-duration: 0ms; - } -} - -.emotion-12 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -105,7 +77,7 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` flex: 1; } -.emotion-14 { +.emotion-12 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -151,24 +123,24 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` } @media ( prefers-reduced-motion: reduce ) { - .emotion-14 { + .emotion-12 { transition-duration: 0ms; } } -.emotion-14::-moz-focus-inner { +.emotion-12::-moz-focus-inner { border: 0; } -.emotion-14:active { +.emotion-12:active { background: #fff; } -.emotion-14:active { +.emotion-12:active { background: transparent; } -.emotion-15 { +.emotion-13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -177,7 +149,17 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` line-height: 1; } -.emotion-19 { +.emotion-15 { + background: #1e1e1e; + border-radius: 2px; + position: absolute; + inset: 0; + z-index: 1; + outline: 2px solid transparent; + outline-offset: -3px; +} + +.emotion-18 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -222,16 +204,16 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` } @media ( prefers-reduced-motion: reduce ) { - .emotion-19 { + .emotion-18 { transition-duration: 0ms; } } -.emotion-19::-moz-focus-inner { +.emotion-18::-moz-focus-inner { border: 0; } -.emotion-19:active { +.emotion-18:active { background: #fff; } @@ -259,22 +241,13 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` id="toggle-group-control-as-radio-group-1" role="radiogroup" > - + `; @@ -526,6 +532,12 @@ exports[`ToggleGroupControl controlled should render correctly with text options + `; diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx index 4bf0c68ecb034..9ac02a4f33262 100644 --- a/packages/components/src/toggle-group-control/test/index.tsx +++ b/packages/components/src/toggle-group-control/test/index.tsx @@ -13,6 +13,7 @@ import { formatLowercase, formatUppercase } from '@wordpress/icons'; /** * Internal dependencies */ +import Button from '../../button'; import { ToggleGroupControl, ToggleGroupControlOption, @@ -32,14 +33,17 @@ const ControlledToggleGroupControl = ( { const [ value, setValue ] = useState( defaultValue ); return ( - { - setValue( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - value={ value } - /> + <> + { + setValue( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + value={ value } + /> + + ); }; const options = ( @@ -68,7 +72,7 @@ describe.each( [ [ 'uncontrolled', ToggleGroupControl ], [ 'controlled', ControlledToggleGroupControl ], ] )( 'ToggleGroupControl %s', ( ...modeAndComponent ) => { - const [ , Component ] = modeAndComponent; + const [ mode, Component ] = modeAndComponent; describe( 'should render correctly', () => { it( 'with text options', () => { @@ -170,6 +174,36 @@ describe.each( [ ); } ); + if ( mode === 'controlled' ) { + it( 'should reset values correctly', async () => { + const user = userEvent.setup(); + + render( + + { options } + + ); + + const rigasOption = screen.getByRole( 'radio', { name: 'R' } ); + const jackOption = screen.getByRole( 'radio', { name: 'J' } ); + + await user.click( rigasOption ); + + expect( jackOption ).not.toBeChecked(); + expect( rigasOption ).toBeChecked(); + + await user.click( jackOption ); + + expect( rigasOption ).not.toBeChecked(); + expect( jackOption ).toBeChecked(); + + await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); + + expect( rigasOption ).not.toBeChecked(); + expect( jackOption ).not.toBeChecked(); + } ); + } + describe( 'isDeselectable', () => { describe( 'isDeselectable = false', () => { it( 'should not be deselectable', async () => { @@ -211,7 +245,13 @@ describe.each( [ expect( rigas ).toHaveFocus(); await user.tab(); - expect( rigas.ownerDocument.body ).toHaveFocus(); + + const expectedFocusTarget = + mode === 'uncontrolled' + ? rigas.ownerDocument.body + : screen.getByRole( 'button', { name: 'Reset' } ); + + expect( expectedFocusTarget ).toHaveFocus(); } ); } ); @@ -238,7 +278,7 @@ describe.each( [ } ) ); expect( mockOnChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnChange ).toHaveBeenLastCalledWith( '' ); + expect( mockOnChange ).toHaveBeenLastCalledWith( undefined ); await user.click( screen.getByRole( 'button', { diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx index 247fd7cd85d7d..4f0153cea3e2e 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx @@ -102,7 +102,7 @@ function ToggleGroupControlOptionBase( const buttonOnClick = () => { if ( isDeselectable && isPressed ) { - otherContextProps.setState( '' ); + otherContextProps.setState( undefined ); } else { otherContextProps.setState( value ); } 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 1423ae4e52632..d0a58b395ec88 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 @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import type { ForwardedRef } from 'react'; - /** * WordPress dependencies */ @@ -14,8 +9,9 @@ import { forwardRef, useMemo } from '@wordpress/element'; */ import { View } from '../../view'; import { useControlledValue } from '../../utils'; -import ToggleGroupControlContext from '../context'; import type { WordPressComponentProps } from '../../ui/context'; +import ToggleGroupControlContext from '../context'; +import { useAdjustUndefinedValue } from './utils'; import type { ToggleGroupControlMainControlProps, ToggleGroupControlContextProps, @@ -28,7 +24,7 @@ function UnforwardedToggleGroupControlAsButtonGroup( label, onChange, size, - value, + value: valueProp, defaultValue, ...otherProps }: WordPressComponentProps< @@ -36,16 +32,21 @@ function UnforwardedToggleGroupControlAsButtonGroup( 'div', false >, - forwardedRef: ForwardedRef< HTMLDivElement > + forwardedRef: React.ForwardedRef< HTMLDivElement > ) { const baseId = useInstanceId( ToggleGroupControlAsButtonGroup, 'toggle-group-control-as-button-group' ).toString(); + // Use a heuristic to understand if `undefined` values should be intended as + // "no value" values for controlled mode, or that the component is being + // used in an uncontrolled way. + const adjustedValueProp = useAdjustUndefinedValue( valueProp ); + const [ selectedValue, setSelectedValue ] = useControlledValue( { defaultValue, - value, + value: adjustedValueProp, onChange, } ); 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 2085ea2a06088..562c52697ca63 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 @@ -15,8 +15,9 @@ import { forwardRef, useMemo } from '@wordpress/element'; * Internal dependencies */ import { View } from '../../view'; -import ToggleGroupControlContext from '../context'; import type { WordPressComponentProps } from '../../ui/context'; +import ToggleGroupControlContext from '../context'; +import { useAdjustUndefinedValue } from './utils'; import type { ToggleGroupControlMainControlProps, ToggleGroupControlContextProps, @@ -44,17 +45,22 @@ function UnforwardedToggleGroupControlAsRadioGroup( 'toggle-group-control-as-radio-group' ).toString(); + // Use a heuristic to understand if `undefined` values should be intended as + // "no value" values for controlled mode, or that the component is being + // used in an uncontrolled way. + const adjustedValueProp = useAdjustUndefinedValue( valueProp ); + // Handle controlled and uncontrolled updates to the component. // Similar to the logic in the `useControlledState` hook, but with: // - `useRadioState` instead of `useState` // - a guard in `onChange` so that it doesn't fire if the value doesn't change - const hasValue = typeof valueProp !== 'undefined'; - const initialValue = hasValue ? valueProp : defaultValueProp; + const hasValue = typeof adjustedValueProp !== 'undefined'; + const initialValue = hasValue ? adjustedValueProp : defaultValueProp; const { state, setState, ...radio } = useRadioState( { baseId, state: initialValue, } ); - const value = hasValue ? valueProp : state; + const value = hasValue ? adjustedValueProp : state; const onChange = typeof onChangeProp === 'function' ? ( ( ( newValue ) => { diff --git a/packages/components/src/toggle-group-control/toggle-group-control/utils.ts b/packages/components/src/toggle-group-control/toggle-group-control/utils.ts new file mode 100644 index 0000000000000..5875d19c04683 --- /dev/null +++ b/packages/components/src/toggle-group-control/toggle-group-control/utils.ts @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { ToggleGroupControlProps } from '../types'; + +/** + * Used to determine, via an internal heuristics, whether an `undefined` value + * received for the `value` prop should be interpreted as the component being + * used in uncontrolled mode, or as an "empty" value for controlled mode. + * + * @param valueProp The received `value` + */ +export function useAdjustUndefinedValue( + valueProp: ToggleGroupControlProps[ 'value' ] +): ToggleGroupControlProps[ 'value' ] { + const hasEverBeenUsedInControlledMode = useRef( + typeof valueProp !== 'undefined' + ); + + useEffect( () => { + if ( ! hasEverBeenUsedInControlledMode.current ) { + hasEverBeenUsedInControlledMode.current = + typeof valueProp !== 'undefined'; + } + }, [ valueProp ] ); + + return valueProp === undefined && hasEverBeenUsedInControlledMode.current + ? '' + : valueProp; +} From fc03b141d828ef09049fa7818d0e7538eb9d8afd Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 26 Jun 2023 18:14:06 +0200 Subject: [PATCH 26/42] Bring back README example to be uncontrolled, but use `defaultValue` --- .../toggle-group-control/README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/components/src/toggle-group-control/toggle-group-control/README.md b/packages/components/src/toggle-group-control/toggle-group-control/README.md index 2f6f786ec90d9..f29d153d44924 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/README.md +++ b/packages/components/src/toggle-group-control/toggle-group-control/README.md @@ -17,18 +17,10 @@ import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, } from '@wordpress/components'; -import { useState } from '@wordpress/element'; function Example() { - const [ orientation, setOrientation ] = useState( "vertical" ); - return ( - + From 8c84d616434dc8b0ef3d867af4958eef44bf6847 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 26 Jun 2023 18:14:29 +0200 Subject: [PATCH 27/42] Update `defaultValue` prop description, add to README --- .../src/toggle-group-control/toggle-group-control/README.md | 5 +++++ packages/components/src/toggle-group-control/types.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/components/src/toggle-group-control/toggle-group-control/README.md b/packages/components/src/toggle-group-control/toggle-group-control/README.md index f29d153d44924..866fbdfdc6fec 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/README.md +++ b/packages/components/src/toggle-group-control/toggle-group-control/README.md @@ -77,6 +77,11 @@ Callback when a segment is selected. - Required: No - Default: `() => {}` +### `defaultValue`: `string | number` + +The initial value to be used when in uncontrolled mode. + +- Required: No ### `value`: `string | number` diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts index 33723e86e0ac6..86ad9eee05007 100644 --- a/packages/components/src/toggle-group-control/types.ts +++ b/packages/components/src/toggle-group-control/types.ts @@ -113,7 +113,7 @@ export type ToggleGroupControlProps = Pick< */ value?: ReactText; /** - * The selected value. + * The initial value to be used when in uncontrolled mode. */ defaultValue?: ReactText; /** From 4da4e77feeba7c53c2495631a71443d34fa2bb8e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 26 Jun 2023 18:14:52 +0200 Subject: [PATCH 28/42] Stop using deprecated `ReactText` type, use `string | number` instead --- packages/components/src/toggle-group-control/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts index 86ad9eee05007..89a5dd0eae31c 100644 --- a/packages/components/src/toggle-group-control/types.ts +++ b/packages/components/src/toggle-group-control/types.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ReactNode, ReactText } from 'react'; +import type { ReactNode } from 'react'; // eslint-disable-next-line no-restricted-imports import type { RadioStateReturn } from 'reakit'; @@ -18,7 +18,7 @@ export type ToggleGroupControlOptionBaseProps = { * @default false */ isIcon?: boolean; - value: ReactText; + value: string | number; /** * Whether to display a Tooltip for the control option. If set to `true`, the tooltip will * show the aria-label or the label prop text. @@ -107,15 +107,15 @@ export type ToggleGroupControlProps = Pick< /** * Callback when a segment is selected. */ - onChange?: ( value: ReactText | undefined ) => void; + onChange?: ( value: string | number | undefined ) => void; /** * The selected value. */ - value?: ReactText; + value?: string | number; /** * The initial value to be used when in uncontrolled mode. */ - defaultValue?: ReactText; + defaultValue?: string | number; /** * The options to render in the `ToggleGroupControl`, using either the `ToggleGroupControlOption` or * `ToggleGroupControlOptionIcon` components. From 14f889717b0a909d463d700708d93e5ac1b5988f Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 27 Jun 2023 12:34:35 +0200 Subject: [PATCH 29/42] Add keyboard interaction with reset unit test --- packages/components/src/toggle-group-control/test/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx index 9ac02a4f33262..570240e10c7c7 100644 --- a/packages/components/src/toggle-group-control/test/index.tsx +++ b/packages/components/src/toggle-group-control/test/index.tsx @@ -192,7 +192,7 @@ describe.each( [ expect( jackOption ).not.toBeChecked(); expect( rigasOption ).toBeChecked(); - await user.click( jackOption ); + await user.keyboard( '[ArrowRight]' ); expect( rigasOption ).not.toBeChecked(); expect( jackOption ).toBeChecked(); From 9d110026306f1f053db01ffc04ad75d578877751 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 27 Jun 2023 14:01:07 +0200 Subject: [PATCH 30/42] Add reset button to Storybook example --- .../stories/index.story.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/components/src/toggle-group-control/stories/index.story.tsx b/packages/components/src/toggle-group-control/stories/index.story.tsx index 3d61c42d2871e..fdd7bc990bca9 100644 --- a/packages/components/src/toggle-group-control/stories/index.story.tsx +++ b/packages/components/src/toggle-group-control/stories/index.story.tsx @@ -53,15 +53,20 @@ const Template: StoryFn< typeof ToggleGroupControl > = ( { useState< ToggleGroupControlProps[ 'value' ] >(); return ( - { - setValue( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - value={ value } - /> + <> + { + setValue( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + value={ value } + />{ ' ' } + + ); }; From 0d75bf53cc0a31871536bcb4d244a199679ca1e6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Tue, 27 Jun 2023 15:31:31 +0200 Subject: [PATCH 31/42] Refactor to ariakit --- .../test/__snapshots__/index.tsx.snap | 16 +++-- .../component.tsx | 41 ++++++++---- .../toggle-group-control/as-button-group.tsx | 14 ++-- .../toggle-group-control/as-radio-group.tsx | 66 ++++++++----------- .../src/toggle-group-control/types.ts | 23 ++----- 5 files changed, 82 insertions(+), 78 deletions(-) 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 086c178d84e2b..149225c1daa57 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 @@ -248,12 +248,13 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = aria-checked="true" aria-label="Uppercase" class="emotion-12 components-toggle-group-control-option-base" + data-active-item="" + data-command="" data-value="uppercase" data-wp-c16t="true" data-wp-component="ToggleGroupControlOptionBase" id="toggle-group-control-as-radio-group-8-20" role="radio" - tabindex="0" >