fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,237 @@
# Popover
`Popover` renders its content in a floating modal. If no explicit anchor is passed via props, it anchors to its parent element by default.
The behavior of the popover when it exceeds the viewport's edges can be controlled via its props.
## Usage
Render a Popover adjacent to its container.
If a Popover is returned by your component, it will be shown. To hide the popover, simply omit it from your component's render value.
```jsx
import { useState } from 'react';
import { Button, Popover } from '@wordpress/components';
const MyPopover = () => {
const [ isVisible, setIsVisible ] = useState( false );
const toggleVisible = () => {
setIsVisible( ( state ) => ! state );
};
return (
<Button variant="secondary" onClick={ toggleVisible }>
Toggle Popover!
{ isVisible && <Popover>Popover is toggled!</Popover> }
</Button>
);
};
```
In order to pass an explicit anchor, you can use the `anchor` prop. When doing so, **the anchor element should be stored in local state** rather than a plain React ref to ensure reactive updating when it changes.
```jsx
import { useState } from 'react';
import { Button, Popover } from '@wordpress/components';
const MyPopover = () => {
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ popoverAnchor, setPopoverAnchor ] = useState();
const [ isVisible, setIsVisible ] = useState( false );
const toggleVisible = () => {
setIsVisible( ( state ) => ! state );
};
return (
<p ref={ setPopoverAnchor }>Popover s anchor</p>
<Button variant="secondary" onClick={ toggleVisible }>
Toggle Popover!
</Button>
{ isVisible && (
<Popover
anchor={ popoverAnchor }
>
Popover is toggled!
</Popover>
) }
);
};
```
By default Popovers render at the end of the body of your document. If you want Popover elements to render to a specific location on the page, you must render a `Popover.Slot` further up the element tree:
```jsx
import { createRoot } from 'react-dom/client';
import { Popover } from '@wordpress/components';
import Content from './Content';
const app = document.getElementById( 'app' );
const root = createRoot( app );
root.render(
<div>
<Content />
<Popover.Slot />
</div>
);
```
## Props
The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content.
### `anchor`: `Element | VirtualElement | null`
The element that should be used by the `Popover` as its anchor. It can either be an `Element` or, alternatively, a `VirtualElement` — ie. an object with the `getBoundingClientRect()` and the `ownerDocument` properties defined.
The element should be stored in state rather than a plain ref to ensure reactive updating when it changes.
- Required: No
### `anchorRect`: `DomRectWithOwnerDocument`
_Note: this prop is deprecated. Please use the `anchor` prop instead._
An object extending a `DOMRect` with an additional optional `ownerDocument` property, used to specify a fixed popover position.
- Required: No
### `anchorRef`: `Element | PopoverAnchorRefReference | PopoverAnchorRefTopBottom | Range`
_Note: this prop is deprecated. Please use the `anchor` prop instead._
Used to specify a fixed popover position. It can be an `Element`, a React reference to an `element`, an object with a `top` and a `bottom` properties (both pointing to elements), or a `range`.
- Required: No
### `animate`: `boolean`
Whether the popover should animate when opening.
- Required: No
- Default: `true`
### `children`: `ReactNode`
The `children` elements rendered as the popover's content.
- Required: Yes
### `expandOnMobile`: `boolean`
Show the popover fullscreen on mobile viewports.
- Required: No
### `flip`: `boolean`
Specifies whether the popover should flip across its axis if there isn't space for it in the normal placement.
When the using a 'top' placement, the popover will switch to a 'bottom' placement. When using a 'left' placement, the popover will switch to a `right' placement.
The popover will retain its alignment of 'start' or 'end' when flipping.
- Required: No
- Default: `true`
### `focusOnMount`: `'firstElement' | boolean`
By default, the _first tabbable element_ in the popover will receive focus when it mounts. This is the same as setting this prop to `"firstElement"`.
Specifying a `true` value will focus the container instead.
Specifying a `false` value disables the focus handling entirely (this should only be done when an appropriately accessible substitute behavior exists).
- Required: No
- Default: `"firstElement"`
### `onFocusOutside`: `( event: SyntheticEvent ) => void`
A callback invoked when the focus leaves the opened popover. This should only be provided in advanced use-cases when a popover should close under specific circumstances (for example, if the new `document.activeElement` is content of or otherwise controlling popover visibility).
When not provided, the `onClose` callback will be called instead.
- Required: No
### `getAnchorRect`: `( fallbackReferenceElement: Element | null ) => DomRectWithOwnerDocument`
_Note: this prop is deprecated. Please use the `anchor` prop instead._
A function returning the same value as the one expected by the `anchorRect` prop, used to specify a dynamic popover position.
- Required: No
### `headerTitle`: `string`
Used to customize the header text shown when the popover is toggled to fullscreen on mobile viewports (see the `expandOnMobile` prop).
- Required: No
### `isAlternate`: `boolean`
_Note: this prop is deprecated. Please use the `variant` prop with the `'toolbar'` values instead._
Used to enable a different visual style for the popover.
- Required: No
### `noArrow`: `boolean`
Used to show/hide the arrow that points at the popover's anchor.
- Required: No
- Default: `true`
### `offset`: `number`
The distance (in px) between the anchor and the popover.
- Required: No
### `onClose`: `() => void`
A callback invoked when the popover should be closed.
- Required: No
### `placement`: `'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'overlay'`
Used to specify the popover's position with respect to its anchor.
`overlay` is a special case that places the popover over the reference element.
Please note that other placement related props may not behave as excepted.
- Required: No
- Default: `"bottom-start"`
### `position`: `[yAxis] [xAxis] [optionalCorner]`
_Note: use the `placement` prop instead when possible._
Legacy way to specify the popover's position with respect to its anchor.
Possible values:
- `yAxis`: `'top' | 'middle' | 'bottom'`
- `xAxis`: `'left' | 'center' | 'right'`
- `corner`: `'top' | 'right' | 'bottom' | 'left'`
<!-- Break into two separate lists using an HTML comment -->
- Required: No
### `resize`: `boolean`
Adjusts the size of the popover to prevent its contents from going out of view when meeting the viewport edges.
- Required: No
- Default: `true`
### `variant`: `'toolbar' | 'unstyled'`
Specifies the popover's style.
Leave undefined for the default style. Possible values are:
- `unstyled`: The popover is essentially without any visible style, it has no background, border, outline or drop shadow, but the popover contents are still displayed.
- `toolbar`: A style that has no elevation, but a high contrast with other elements. This matches the style of the [`Toolbar` component](/packages/components/toolbar/README.md).
<!-- Break into two separate lists using an HTML comment -->
- Required: No

View File

@@ -0,0 +1,555 @@
/**
* External dependencies
*/
import type { ForwardedRef, SyntheticEvent, RefCallback } from 'react';
import classnames from 'classnames';
import {
useFloating,
flip as flipMiddleware,
shift as shiftMiddleware,
limitShift,
autoUpdate,
arrow,
offset as offsetMiddleware,
size,
} from '@floating-ui/react-dom';
// eslint-disable-next-line no-restricted-imports
import type { HTMLMotionProps, MotionProps } from 'framer-motion';
// eslint-disable-next-line no-restricted-imports
import { motion, useReducedMotion } from 'framer-motion';
/**
* WordPress dependencies
*/
import {
useRef,
useLayoutEffect,
forwardRef,
createContext,
useContext,
useMemo,
useState,
useCallback,
createPortal,
} from '@wordpress/element';
import {
useViewportMatch,
useMergeRefs,
__experimentalUseDialog as useDialog,
} from '@wordpress/compose';
import { close } from '@wordpress/icons';
import deprecated from '@wordpress/deprecated';
import { Path, SVG } from '@wordpress/primitives';
/**
* Internal dependencies
*/
import Button from '../button';
import ScrollLock from '../scroll-lock';
import { Slot, Fill, useSlot } from '../slot-fill';
import {
computePopoverPosition,
positionToPlacement,
placementToMotionAnimationProps,
getReferenceElement,
} from './utils';
import {
contextConnect,
useContextSystem,
ContextSystemProvider,
} from '../context';
import type { WordPressComponentProps } from '../context';
import type {
PopoverProps,
PopoverAnchorRefReference,
PopoverAnchorRefTopBottom,
} from './types';
import { overlayMiddlewares } from './overlay-middlewares';
import { StyleProvider } from '../style-provider';
/**
* Name of slot in which popover should fill.
*
* @type {string}
*/
export const SLOT_NAME = 'Popover';
// An SVG displaying a triangle facing down, filled with a solid
// color and bordered in such a way to create an arrow-like effect.
// Keeping the SVG's viewbox squared simplify the arrow positioning
// calculations.
const ArrowTriangle = () => (
<SVG
xmlns="http://www.w3.org/2000/svg"
viewBox={ `0 0 100 100` }
className="components-popover__triangle"
role="presentation"
>
<Path
className="components-popover__triangle-bg"
d="M 0 0 L 50 50 L 100 0"
/>
<Path
className="components-popover__triangle-border"
d="M 0 0 L 50 50 L 100 0"
vectorEffect="non-scaling-stroke"
/>
</SVG>
);
const slotNameContext = createContext< string | undefined >( undefined );
const fallbackContainerClassname = 'components-popover__fallback-container';
const getPopoverFallbackContainer = () => {
let container = document.body.querySelector(
'.' + fallbackContainerClassname
);
if ( ! container ) {
container = document.createElement( 'div' );
container.className = fallbackContainerClassname;
document.body.append( container );
}
return container;
};
const UnconnectedPopover = (
props: Omit<
WordPressComponentProps< PopoverProps, 'div', false >,
// To avoid overlaps between the standard HTML attributes and the props
// expected by `framer-motion`, omit all framer motion props from popover
// props (except for `animate` and `children`, which are re-defined in `PopoverProps`).
keyof Omit< MotionProps, 'animate' | 'children' >
>,
forwardedRef: ForwardedRef< any >
) => {
const {
animate = true,
headerTitle,
onClose,
children,
className,
noArrow = true,
position,
placement: placementProp = 'bottom-start',
offset: offsetProp = 0,
focusOnMount = 'firstElement',
anchor,
expandOnMobile,
onFocusOutside,
__unstableSlotName = SLOT_NAME,
flip = true,
resize = true,
shift = false,
inline = false,
variant,
// Deprecated props
__unstableForcePosition,
anchorRef,
anchorRect,
getAnchorRect,
isAlternate,
// Rest
...contentProps
} = useContextSystem( props, 'Popover' );
let computedFlipProp = flip;
let computedResizeProp = resize;
if ( __unstableForcePosition !== undefined ) {
deprecated( '`__unstableForcePosition` prop in wp.components.Popover', {
since: '6.1',
version: '6.3',
alternative: '`flip={ false }` and `resize={ false }`',
} );
// Back-compat, set the `flip` and `resize` props
// to `false` to replicate `__unstableForcePosition`.
computedFlipProp = ! __unstableForcePosition;
computedResizeProp = ! __unstableForcePosition;
}
if ( anchorRef !== undefined ) {
deprecated( '`anchorRef` prop in wp.components.Popover', {
since: '6.1',
alternative: '`anchor` prop',
} );
}
if ( anchorRect !== undefined ) {
deprecated( '`anchorRect` prop in wp.components.Popover', {
since: '6.1',
alternative: '`anchor` prop',
} );
}
if ( getAnchorRect !== undefined ) {
deprecated( '`getAnchorRect` prop in wp.components.Popover', {
since: '6.1',
alternative: '`anchor` prop',
} );
}
const computedVariant = isAlternate ? 'toolbar' : variant;
if ( isAlternate !== undefined ) {
deprecated( '`isAlternate` prop in wp.components.Popover', {
since: '6.2',
alternative: "`variant` prop with the `'toolbar'` value",
} );
}
const arrowRef = useRef< HTMLElement | null >( null );
const [ fallbackReferenceElement, setFallbackReferenceElement ] =
useState< HTMLSpanElement | null >( null );
const anchorRefFallback: RefCallback< HTMLSpanElement > = useCallback(
( node ) => {
setFallbackReferenceElement( node );
},
[]
);
const isMobileViewport = useViewportMatch( 'medium', '<' );
const isExpanded = expandOnMobile && isMobileViewport;
const hasArrow = ! isExpanded && ! noArrow;
const normalizedPlacementFromProps = position
? positionToPlacement( position )
: placementProp;
const middleware = [
...( placementProp === 'overlay' ? overlayMiddlewares() : [] ),
offsetMiddleware( offsetProp ),
computedFlipProp && flipMiddleware(),
computedResizeProp &&
size( {
apply( sizeProps ) {
const { firstElementChild } = refs.floating.current ?? {};
// Only HTMLElement instances have the `style` property.
if ( ! ( firstElementChild instanceof HTMLElement ) )
return;
// Reduce the height of the popover to the available space.
Object.assign( firstElementChild.style, {
maxHeight: `${ sizeProps.availableHeight }px`,
overflow: 'auto',
} );
},
} ),
shift &&
shiftMiddleware( {
crossAxis: true,
limiter: limitShift(),
padding: 1, // Necessary to avoid flickering at the edge of the viewport.
} ),
arrow( { element: arrowRef } ),
];
const slotName = useContext( slotNameContext ) || __unstableSlotName;
const slot = useSlot( slotName );
let onDialogClose;
if ( onClose || onFocusOutside ) {
onDialogClose = ( type: string | undefined, event: SyntheticEvent ) => {
// Ideally the popover should have just a single onClose prop and
// not three props that potentially do the same thing.
if ( type === 'focus-outside' && onFocusOutside ) {
onFocusOutside( event );
} else if ( onClose ) {
onClose();
}
};
}
const [ dialogRef, dialogProps ] = useDialog( {
focusOnMount,
__unstableOnClose: onDialogClose,
// @ts-expect-error The __unstableOnClose property needs to be deprecated first (see https://github.com/WordPress/gutenberg/pull/27675)
onClose: onDialogClose,
} );
const {
// Positioning coordinates
x,
y,
// Object with "regular" refs to both "reference" and "floating"
refs,
// Type of CSS position property to use (absolute or fixed)
strategy,
update,
placement: computedPlacement,
middlewareData: { arrow: arrowData },
} = useFloating( {
placement:
normalizedPlacementFromProps === 'overlay'
? undefined
: normalizedPlacementFromProps,
middleware,
whileElementsMounted: ( referenceParam, floatingParam, updateParam ) =>
autoUpdate( referenceParam, floatingParam, updateParam, {
layoutShift: false,
animationFrame: true,
} ),
} );
const arrowCallbackRef = useCallback(
( node: HTMLElement | null ) => {
arrowRef.current = node;
update();
},
[ update ]
);
// When any of the possible anchor "sources" change,
// recompute the reference element (real or virtual) and its owner document.
const anchorRefTop = ( anchorRef as PopoverAnchorRefTopBottom | undefined )
?.top;
const anchorRefBottom = (
anchorRef as PopoverAnchorRefTopBottom | undefined
)?.bottom;
const anchorRefStartContainer = ( anchorRef as Range | undefined )
?.startContainer;
const anchorRefCurrent = ( anchorRef as PopoverAnchorRefReference )
?.current;
useLayoutEffect( () => {
const resultingReferenceElement = getReferenceElement( {
anchor,
anchorRef,
anchorRect,
getAnchorRect,
fallbackReferenceElement,
} );
refs.setReference( resultingReferenceElement );
}, [
anchor,
anchorRef,
anchorRefTop,
anchorRefBottom,
anchorRefStartContainer,
anchorRefCurrent,
anchorRect,
getAnchorRect,
fallbackReferenceElement,
refs,
] );
const mergedFloatingRef = useMergeRefs( [
refs.setFloating,
dialogRef,
forwardedRef,
] );
const style = isExpanded
? undefined
: {
position: strategy,
top: 0,
left: 0,
// `x` and `y` are framer-motion specific props and are shorthands
// for `translateX` and `translateY`. Currently it is not possible
// to use `translateX` and `translateY` because those values would
// be overridden by the return value of the
// `placementToMotionAnimationProps` function.
x: computePopoverPosition( x ),
y: computePopoverPosition( y ),
};
const shouldReduceMotion = useReducedMotion();
const shouldAnimate = animate && ! isExpanded && ! shouldReduceMotion;
const [ animationFinished, setAnimationFinished ] = useState( false );
const { style: motionInlineStyles, ...otherMotionProps } = useMemo(
() => placementToMotionAnimationProps( computedPlacement ),
[ computedPlacement ]
);
const animationProps: HTMLMotionProps< 'div' > = shouldAnimate
? {
style: {
...motionInlineStyles,
...style,
},
onAnimationComplete: () => setAnimationFinished( true ),
...otherMotionProps,
}
: {
animate: false,
style,
};
// When Floating UI has finished positioning and Framer Motion has finished animating
// the popover, add the `is-positioned` class to signal that all transitions have finished.
const isPositioned =
( ! shouldAnimate || animationFinished ) && x !== null && y !== null;
// In case a `ColorPicker` component is rendered as a child of `Popover`,
// the `Popover` component can be notified of when the user is dragging
// parts of the `ColorPicker` UI (this is possible because the `ColorPicker`
// component exposes the `onPickerDragStart` and `onPickerDragEnd` props
// via internal context).
// While the user is performing a pointer drag, the `Popover` will render
// a transparent backdrop element that will serve as a "pointer events trap",
// making sure that no pointer events reach any potential `iframe` element
// underneath (like, for example, the editor canvas in the WordPress editor).
const [ showBackdrop, setShowBackdrop ] = useState( false );
const contextValue = useMemo(
() => ( {
ColorPicker: {
onPickerDragStart() {
setShowBackdrop( true );
},
onPickerDragEnd() {
setShowBackdrop( false );
},
},
} ),
[]
);
let content = (
<>
{ showBackdrop && (
<div
className="components-popover-pointer-events-trap"
aria-hidden="true"
onClick={ () => setShowBackdrop( false ) }
/>
) }
<motion.div
className={ classnames( 'components-popover', className, {
'is-expanded': isExpanded,
'is-positioned': isPositioned,
// Use the 'alternate' classname for 'toolbar' variant for back compat.
[ `is-${
computedVariant === 'toolbar'
? 'alternate'
: computedVariant
}` ]: computedVariant,
} ) }
{ ...animationProps }
{ ...contentProps }
ref={ mergedFloatingRef }
{ ...dialogProps }
tabIndex={ -1 }
>
{ /* Prevents scroll on the document */ }
{ isExpanded && <ScrollLock /> }
{ isExpanded && (
<div className="components-popover__header">
<span className="components-popover__header-title">
{ headerTitle }
</span>
<Button
className="components-popover__close"
icon={ close }
onClick={ onClose }
/>
</div>
) }
<div className="components-popover__content">
<ContextSystemProvider value={ contextValue }>
{ children }
</ContextSystemProvider>
</div>
{ hasArrow && (
<div
ref={ arrowCallbackRef }
className={ [
'components-popover__arrow',
`is-${ computedPlacement.split( '-' )[ 0 ] }`,
].join( ' ' ) }
style={ {
left:
typeof arrowData?.x !== 'undefined' &&
Number.isFinite( arrowData.x )
? `${ arrowData.x }px`
: '',
top:
typeof arrowData?.y !== 'undefined' &&
Number.isFinite( arrowData.y )
? `${ arrowData.y }px`
: '',
} }
>
<ArrowTriangle />
</div>
) }
</motion.div>
</>
);
const shouldRenderWithinSlot = slot.ref && ! inline;
const hasAnchor = anchorRef || anchorRect || anchor;
if ( shouldRenderWithinSlot ) {
content = <Fill name={ slotName }>{ content }</Fill>;
} else if ( ! inline ) {
content = createPortal(
<StyleProvider document={ document }>{ content }</StyleProvider>,
getPopoverFallbackContainer()
);
}
if ( hasAnchor ) {
return content;
}
return (
<>
<span ref={ anchorRefFallback } />
{ content }
</>
);
};
/**
* `Popover` renders its content in a floating modal. If no explicit anchor is passed via props, it anchors to its parent element by default.
*
* ```jsx
* import { Button, Popover } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const MyPopover = () => {
* const [ isVisible, setIsVisible ] = useState( false );
* const toggleVisible = () => {
* setIsVisible( ( state ) => ! state );
* };
*
* return (
* <Button variant="secondary" onClick={ toggleVisible }>
* Toggle Popover!
* { isVisible && <Popover>Popover is toggled!</Popover> }
* </Button>
* );
* };
* ```
*
*/
export const Popover = contextConnect( UnconnectedPopover, 'Popover' );
function PopoverSlot(
{ name = SLOT_NAME }: { name?: string },
ref: ForwardedRef< any >
) {
return (
<Slot
bubblesVirtually
name={ name }
className="popover-slot"
ref={ ref }
/>
);
}
// @ts-expect-error For Legacy Reasons
Popover.Slot = forwardRef( PopoverSlot );
// @ts-expect-error For Legacy Reasons
Popover.__unstableSlotNameProvider = slotNameContext.Provider;
export default Popover;

View File

@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import type { MiddlewareState } from '@floating-ui/react-dom';
import { size } from '@floating-ui/react-dom';
export function overlayMiddlewares() {
return [
{
name: 'overlay',
fn( { rects }: MiddlewareState ) {
return rects.reference;
},
},
size( {
apply( { rects, elements } ) {
const { firstElementChild } = elements.floating ?? {};
// Only HTMLElement instances have the `style` property.
if ( ! ( firstElementChild instanceof HTMLElement ) ) return;
// Reduce the height of the popover to the available space.
Object.assign( firstElementChild.style, {
width: `${ rects.reference.width }px`,
height: `${ rects.reference.height }px`,
} );
},
} ),
];
}

View File

@@ -0,0 +1,25 @@
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import Popover from '../..';
export default {
title: 'Components/Popover',
component: Popover,
};
export const Default = () => {
const [ isVisible, setIsVisible ] = useState( false );
return (
<button onClick={ () => setIsVisible( ( state ) => ! state ) }>
Toggle Popover!
{ isVisible && <Popover>Popover is toggled!</Popover> }
</button>
);
};

View File

@@ -0,0 +1,254 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState, useRef, useEffect } from '@wordpress/element';
// @ts-expect-error The `@wordpress/block-editor` is not typed
import { __unstableIframe as Iframe } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import Button from '../../button';
import { Popover } from '..';
import { PopoverInsideIframeRenderedInExternalSlot } from '../test/utils';
import type { PopoverProps } from '../types';
const AVAILABLE_PLACEMENTS: PopoverProps[ 'placement' ][] = [
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end',
'overlay',
];
const meta: Meta< typeof Popover > = {
title: 'Components/Popover',
component: Popover,
argTypes: {
anchor: { control: { type: null } },
anchorRef: { control: { type: null } },
anchorRect: { control: { type: null } },
children: { control: { type: null } },
focusOnMount: {
control: { type: 'select' },
options: [ 'firstElement', true, false ],
},
getAnchorRect: { control: { type: null } },
onClose: { action: 'onClose' },
onFocusOutside: { action: 'onFocusOutside' },
__unstableSlotName: { control: { type: null } },
},
parameters: {
controls: { expanded: true },
},
};
export default meta;
const PopoverWithAnchor = ( args: PopoverProps ) => {
const anchorRef = useRef( null );
return (
<div
style={ {
height: '200px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
} }
>
<p
style={ { padding: '8px', background: 'salmon' } }
ref={ anchorRef }
>
Popover&apos;s anchor
</p>
<Popover { ...args } anchorRef={ anchorRef } />
</div>
);
};
const Template: StoryFn< typeof Popover > = ( args ) => {
const [ isVisible, setIsVisible ] = useState( false );
const toggleVisible = () => {
setIsVisible( ( state ) => ! state );
};
const buttonRef = useRef< HTMLButtonElement | undefined >();
useEffect( () => {
buttonRef.current?.scrollIntoView?.( {
block: 'center',
inline: 'center',
} );
}, [] );
return (
<div
style={ {
width: '300vw',
height: '300vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
} }
>
<Button
variant="secondary"
onClick={ toggleVisible }
ref={ buttonRef }
>
Toggle Popover
{ isVisible && <Popover { ...args } /> }
</Button>
</div>
);
};
export const Default: StoryFn< typeof Popover > = Template.bind( {} );
Default.args = {
children: (
<div style={ { width: '280px', whiteSpace: 'normal' } }>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</div>
),
};
export const Unstyled: StoryFn< typeof Popover > = Template.bind( {} );
Unstyled.args = {
children: (
<div style={ { width: '280px', whiteSpace: 'normal' } }>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</div>
),
variant: 'unstyled',
};
export const AllPlacements: StoryFn< typeof Popover > = ( {
children,
...args
} ) => (
<div
style={ {
minWidth: '600px',
marginLeft: 'auto',
marginRight: 'auto',
} }
>
<h2>
Resize / scroll the viewport to test the behavior of the popovers
when they reach the viewport boundaries.
</h2>
<div>
{ AVAILABLE_PLACEMENTS.map( ( p ) => (
<PopoverWithAnchor
key={ p }
placement={ p }
{ ...args }
resize={ p === 'overlay' ? true : args.resize }
>
{ children }
<div>
<small>(placement: { p })</small>
</div>
</PopoverWithAnchor>
) ) }
</div>
</div>
);
// Excluding placement and position since they all possible values
// are passed directly in code.
AllPlacements.parameters = {
controls: {
exclude: [ 'placement', 'position' ],
},
};
AllPlacements.args = {
...Default.args,
children: (
<div style={ { width: '280px', whiteSpace: 'normal' } }>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
),
noArrow: false,
offset: 10,
resize: false,
flip: false,
};
export const DynamicHeight: StoryFn< typeof Popover > = ( {
children,
...args
} ) => {
const [ height, setHeight ] = useState( 200 );
const increase = () => setHeight( height + 100 );
const decrease = () => setHeight( height - 100 );
return (
<div style={ { padding: '20px' } }>
<div>
<Button
variant="primary"
onClick={ increase }
style={ {
marginRight: '20px',
} }
>
Increase Size
</Button>
<Button variant="primary" onClick={ decrease }>
Decrease Size
</Button>
</div>
<p>
When the height of the popover exceeds the available space in
the canvas, a scrollbar inside the popover should appear.
</p>
<div>
<Popover { ...args }>
<div
style={ {
height,
background: '#eee',
padding: '20px',
} }
>
{ children }
</div>
</Popover>
</div>
</div>
);
};
DynamicHeight.args = {
...Default.args,
children: 'Content with dynamic height',
};
export const WithSlotOutsideIframe: StoryFn< typeof Popover > = ( args ) => {
return <PopoverInsideIframeRenderedInExternalSlot { ...args } />;
};
WithSlotOutsideIframe.args = {
...Default.args,
};

View File

@@ -0,0 +1,144 @@
$arrow-triangle-base-size: 14px;
$shadow-popover-border-default: 0 0 0 $border-width $gray-400;
$shadow-popover-border-default-alternate: 0 0 0 $border-width $gray-900;
$shadow-popover-border-top-only: 0 #{-$border-width} 0 0 $gray-400;
$shadow-popover-border-top-only-alternate: 0 #{-$border-width} 0 $gray-900;
.components-popover {
z-index: z-index(".components-popover");
will-change: transform;
&.is-expanded {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: z-index(".components-popover") !important;
}
}
.components-popover__content {
background: $white;
box-shadow: $shadow-popover-border-default, $shadow-popover;
border-radius: $radius-block-ui;
box-sizing: border-box;
width: min-content;
// Alternate treatment for popovers that put them at elevation zero with high contrast.
.is-alternate & {
box-shadow: $shadow-popover-border-default-alternate;
}
// A style that gives the popover no visible ui.
.is-unstyled & {
background: none;
border-radius: 0;
box-shadow: none;
}
.components-popover.is-expanded & {
position: static;
height: calc(100% - #{ $panel-header-height });
overflow-y: visible;
width: auto;
box-shadow: $shadow-popover-border-top-only;
}
.components-popover.is-expanded.is-alternate & {
box-shadow: $shadow-popover-border-top-only-alternate;
}
}
.components-popover__header {
align-items: center;
background: $white;
display: flex;
height: $panel-header-height;
justify-content: space-between;
padding: 0 8px 0 $grid-unit-20;
}
.components-popover__header-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.components-popover__close.components-button {
z-index: z-index(".components-popover__close");
}
.components-popover__arrow {
position: absolute;
width: $arrow-triangle-base-size;
height: $arrow-triangle-base-size;
pointer-events: none;
display: flex;
// Thin line that helps to make sure that the underlying
// popover__content's outline is fully overlapped by the
// arrow
&::before {
content: "";
position: absolute;
top: -1px;
left: 1px;
height: 2px;
right: 1px;
background-color: $white;
}
// Position and rotate the arrow depending on the popover's placement.
// The `!important' is necessary to override the inline styles.
&.is-top {
bottom: -1 * $arrow-triangle-base-size !important;
transform: rotate(0);
}
&.is-right {
/*rtl:begin:ignore*/
left: -1 * $arrow-triangle-base-size !important;
transform: rotate(90deg);
}
&.is-bottom {
top: -1 * $arrow-triangle-base-size !important;
transform: rotate(180deg);
}
&.is-left {
/*rtl:begin:ignore*/
right: -1 * $arrow-triangle-base-size !important;
transform: rotate(-90deg);
/*rtl:end:ignore*/
}
}
.components-popover__triangle {
display: block;
flex: 1;
}
.components-popover__triangle-bg {
// Fill color is the same as the .components-popover__content's background
fill: $white;
}
.components-popover__triangle-border {
// Stroke colors are the same as the .components-popover__content's outline
fill: transparent;
stroke-width: $border-width;
stroke: $gray-400;
.is-alternate & {
stroke: $gray-900;
}
}
.components-popover-pointer-events-trap {
// Same z-index as popover, but rendered before the popover element
// in DOM order = it will display just under the popover
z-index: z-index(".components-popover");
position: fixed;
inset: 0;
background-color: transparent;
}

View File

@@ -0,0 +1,300 @@
/**
* External dependencies
*/
import { render, screen, waitFor, getByText } from '@testing-library/react';
import type { CSSProperties } from 'react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
computePopoverPosition,
positionToPlacement,
placementToMotionAnimationProps,
} from '../utils';
import Popover from '..';
import type { PopoverProps } from '../types';
import { PopoverInsideIframeRenderedInExternalSlot } from './utils';
type PositionToPlacementTuple = [
NonNullable< PopoverProps[ 'position' ] >,
NonNullable< PopoverProps[ 'placement' ] >,
];
type PlacementToAnimationOriginTuple = [
NonNullable< PopoverProps[ 'placement' ] >,
number,
number,
];
type PlacementToInitialTranslationTuple = [
NonNullable< PopoverProps[ 'placement' ] >,
'translateY' | 'translateX',
CSSProperties[ 'translate' ],
];
// There's no matching `placement` for 'middle center' positions,
// fallback to 'bottom' (same as `floating-ui`'s default.)
const FALLBACK_FOR_MIDDLE_CENTER_POSITIONS = 'bottom';
const ALL_POSITIONS_TO_EXPECTED_PLACEMENTS: PositionToPlacementTuple[] = [
// Format: [yAxis]
[ 'middle', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'bottom', 'bottom' ],
[ 'top', 'top' ],
// Format: [yAxis] [xAxis]
[ 'middle left', 'left' ],
[ 'middle center', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'middle right', 'right' ],
[ 'bottom left', 'bottom-end' ],
[ 'bottom center', 'bottom' ],
[ 'bottom right', 'bottom-start' ],
[ 'top left', 'top-end' ],
[ 'top center', 'top' ],
[ 'top right', 'top-start' ],
// Format: [yAxis] [xAxis] [corner]
[ 'middle left left', 'left' ],
[ 'middle left right', 'left' ],
[ 'middle left bottom', 'left-end' ],
[ 'middle left top', 'left-start' ],
[ 'middle center left', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'middle center right', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'middle center bottom', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'middle center top', FALLBACK_FOR_MIDDLE_CENTER_POSITIONS ],
[ 'middle right left', 'right' ],
[ 'middle right right', 'right' ],
[ 'middle right bottom', 'right-end' ],
[ 'middle right top', 'right-start' ],
[ 'bottom left left', 'bottom-end' ],
[ 'bottom left right', 'bottom-end' ],
[ 'bottom left bottom', 'bottom-end' ],
[ 'bottom left top', 'bottom-end' ],
[ 'bottom center left', 'bottom' ],
[ 'bottom center right', 'bottom' ],
[ 'bottom center bottom', 'bottom' ],
[ 'bottom center top', 'bottom' ],
[ 'bottom right left', 'bottom-start' ],
[ 'bottom right right', 'bottom-start' ],
[ 'bottom right bottom', 'bottom-start' ],
[ 'bottom right top', 'bottom-start' ],
[ 'top left left', 'top-end' ],
[ 'top left right', 'top-end' ],
[ 'top left bottom', 'top-end' ],
[ 'top left top', 'top-end' ],
[ 'top center left', 'top' ],
[ 'top center right', 'top' ],
[ 'top center bottom', 'top' ],
[ 'top center top', 'top' ],
[ 'top right left', 'top-start' ],
[ 'top right right', 'top-start' ],
[ 'top right bottom', 'top-start' ],
[ 'top right top', 'top-start' ],
];
describe( 'Popover', () => {
describe( 'Component', () => {
describe( 'basic behavior', () => {
it( 'should render content', async () => {
render( <Popover>Hello</Popover> );
await waitFor( () =>
expect( screen.getByText( 'Hello' ) ).toBeVisible()
);
} );
it( 'should forward additional props to portaled element', async () => {
render( <Popover role="tooltip">Hello</Popover> );
await waitFor( () =>
expect( screen.getByRole( 'tooltip' ) ).toBeVisible()
);
} );
it( 'should render inline regardless of slot name', async () => {
const { container } = render(
<Popover inline __unstableSlotName="Popover">
Hello
</Popover>
);
await waitFor( () =>
// We want to explicitly check if it's within the container.
// eslint-disable-next-line testing-library/prefer-screen-queries
expect( getByText( container, 'Hello' ) ).toBeVisible()
);
} );
} );
describe( 'anchor', () => {
it( 'should render correctly when anchor is provided', async () => {
const PopoverWithAnchor = ( args: PopoverProps ) => {
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ anchor, setAnchor ] =
useState< HTMLParagraphElement | null >( null );
return (
<div>
<p ref={ setAnchor }>Anchor</p>
<Popover { ...args } anchor={ anchor } />
</div>
);
};
render(
<PopoverWithAnchor>Popover content</PopoverWithAnchor>
);
await waitFor( () =>
expect(
screen.getByText( 'Popover content' )
).toBeVisible()
);
} );
} );
describe( 'focus behavior', () => {
it( 'should focus the popover container when opened', async () => {
render(
<Popover
focusOnMount={ true }
data-testid="popover-element"
>
Popover content
</Popover>
);
const popover = screen.getByTestId( 'popover-element' );
await waitFor( () => expect( popover ).toBeVisible() );
expect( popover ).toHaveFocus();
} );
it( 'should allow focus-on-open behavior to be disabled', async () => {
render(
<Popover focusOnMount={ false }>Popover content</Popover>
);
await waitFor( () =>
expect(
screen.getByText( 'Popover content' )
).toBeVisible()
);
expect( document.body ).toHaveFocus();
} );
} );
} );
describe( 'Slot outside iframe', () => {
it( 'should support cross-document rendering', async () => {
render(
<PopoverInsideIframeRenderedInExternalSlot>
<span>content</span>
</PopoverInsideIframeRenderedInExternalSlot>
);
await waitFor( async () =>
expect( screen.getByText( 'content' ) ).toBeVisible()
);
} );
} );
describe( 'positionToPlacement', () => {
it.each( ALL_POSITIONS_TO_EXPECTED_PLACEMENTS )(
'converts `%s` to `%s`',
( inputPosition, expectedPlacement ) => {
expect( positionToPlacement( inputPosition ) ).toEqual(
expectedPlacement
);
}
);
} );
describe( 'placementToMotionAnimationProps', () => {
describe( 'animation origin', () => {
it.each( [
[ 'top', 0.5, 1 ],
[ 'top-start', 0, 1 ],
[ 'top-end', 1, 1 ],
[ 'right', 0, 0.5 ],
[ 'right-start', 0, 0 ],
[ 'right-end', 0, 1 ],
[ 'bottom', 0.5, 0 ],
[ 'bottom-start', 0, 0 ],
[ 'bottom-end', 1, 0 ],
[ 'left', 1, 0.5 ],
[ 'left-start', 1, 0 ],
[ 'left-end', 1, 1 ],
] as PlacementToAnimationOriginTuple[] )(
'for the `%s` placement computes an animation origin of (%d, %d)',
( inputPlacement, expectedOriginX, expectedOriginY ) => {
expect(
placementToMotionAnimationProps( inputPlacement )
).toEqual(
expect.objectContaining( {
style: expect.objectContaining( {
originX: expectedOriginX,
originY: expectedOriginY,
} ),
} )
);
}
);
} );
describe( 'initial translation', () => {
it.each( [
[ 'top', 'translateY', '2em' ],
[ 'top-start', 'translateY', '2em' ],
[ 'top-end', 'translateY', '2em' ],
[ 'right', 'translateX', '-2em' ],
[ 'right-start', 'translateX', '-2em' ],
[ 'right-end', 'translateX', '-2em' ],
[ 'bottom', 'translateY', '-2em' ],
[ 'bottom-start', 'translateY', '-2em' ],
[ 'bottom-end', 'translateY', '-2em' ],
[ 'left', 'translateX', '2em' ],
[ 'left-start', 'translateX', '2em' ],
[ 'left-end', 'translateX', '2em' ],
] as PlacementToInitialTranslationTuple[] )(
'for the `%s` placement computes an initial `%s` of `%s',
(
inputPlacement,
expectedTranslationProp,
expectedTranslationValue
) => {
expect(
placementToMotionAnimationProps( inputPlacement )
).toEqual(
expect.objectContaining( {
initial: expect.objectContaining( {
[ expectedTranslationProp ]:
expectedTranslationValue,
} ),
} )
);
}
);
} );
} );
describe( 'computePopoverPosition', () => {
it.each( [
[ 14, 14 ], // valid integers shouldn't be changes
[ 14.02, 14 ], // floating numbers are parsed to integers
[ 0, 0 ], // zero remains zero
[ null, undefined ],
[ NaN, undefined ],
] )(
'converts `%s` to `%s`',
( inputCoordinate, expectedCoordinated ) => {
expect( computePopoverPosition( inputCoordinate ) ).toEqual(
expectedCoordinated
);
}
);
} );
} );

View File

@@ -0,0 +1,87 @@
/**
* WordPress dependencies
*/
import { createPortal, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import Popover from '../..';
import { Provider as SlotFillProvider } from '../../../slot-fill';
import type { WordPressComponentProps } from '../../../context';
const GenericIframe = ( {
children,
...props
}: WordPressComponentProps< { children: React.ReactNode }, 'iframe' > ) => {
const [ containerNode, setContainerNode ] = useState< HTMLElement >();
return (
<iframe
{ ...props }
title="My Iframe"
srcDoc="<!doctype html><html><body></body></html>"
// Waiting for the load event ensures that this works in Firefox.
// See https://github.com/facebook/react/issues/22847#issuecomment-991394558
onLoad={ ( event ) => {
if ( event.currentTarget.contentDocument ) {
setContainerNode(
event.currentTarget.contentDocument.body
);
}
} }
>
{ containerNode && createPortal( children, containerNode ) }
</iframe>
);
};
export const PopoverInsideIframeRenderedInExternalSlot = (
props: React.ComponentProps< typeof Popover >
) => {
const SLOT_NAME = 'my-slot';
const [ anchorRef, setAnchorRef ] = useState< HTMLParagraphElement | null >(
null
);
return (
<SlotFillProvider>
{ /* @ts-expect-error Slot is not currently typed on Popover */ }
<Popover.Slot name={ SLOT_NAME } />
<GenericIframe
style={ {
width: '100%',
height: '400px',
border: '0',
outline: '1px solid purple',
} }
>
<div
style={ {
height: '200vh',
paddingTop: '10vh',
} }
>
<p
style={ {
padding: '8px',
background: 'salmon',
maxWidth: '200px',
marginTop: '100px',
marginLeft: 'auto',
marginRight: 'auto',
} }
ref={ setAnchorRef }
>
Popover&apos;s anchor
</p>
<Popover
{ ...props }
__unstableSlotName={ SLOT_NAME }
anchor={ anchorRef }
/>
</div>
</GenericIframe>
</SlotFillProvider>
);
};

199
node_modules/@wordpress/components/src/popover/types.ts generated vendored Normal file
View File

@@ -0,0 +1,199 @@
/**
* External dependencies
*/
import type { ReactNode, MutableRefObject, SyntheticEvent } from 'react';
import type { Placement } from '@floating-ui/react-dom';
type PositionYAxis = 'top' | 'middle' | 'bottom';
type PositionXAxis = 'left' | 'center' | 'right';
type PositionCorner = 'top' | 'right' | 'bottom' | 'left';
type DomRectWithOwnerDocument = DOMRect & {
ownerDocument?: Document;
};
type PopoverPlacement = Placement | 'overlay';
export type PopoverAnchorRefReference = MutableRefObject<
Element | null | undefined
>;
export type PopoverAnchorRefTopBottom = { top: Element; bottom: Element };
export type VirtualElement = Pick< Element, 'getBoundingClientRect' > & {
ownerDocument?: Document;
};
export type PopoverProps = {
/**
* The name of the Slot in which the popover should be rendered. It should
* be also passed to the corresponding `PopoverSlot` component.
*
* @default 'Popover'
*/
__unstableSlotName?: string;
/**
* The element that should be used by the popover as its anchor. It can either
* be an `Element` or, alternatively, a `VirtualElement` — ie. an object with
* the `getBoundingClientRect()` and the `ownerDocument` properties defined.
*
* **The anchor element should be stored in local state** rather than a
* plain React ref to ensure reactive updating when it changes.
*/
anchor?: Element | VirtualElement | null;
/**
* Whether the popover should animate when opening.
*
* @default true
*/
animate?: boolean;
/**
* The `children` elements rendered as the popover's content.
*/
children: ReactNode;
/**
* Show the popover fullscreen on mobile viewports.
*/
expandOnMobile?: boolean;
/**
* Specifies whether the popover should flip across its axis if there isn't
* space for it in the normal placement.
* When the using a 'top' placement, the popover will switch to a 'bottom'
* placement. When using a 'left' placement, the popover will switch to a
* `right' placement.
* The popover will retain its alignment of 'start' or 'end' when flipping.
*
* @default true
*/
flip?: boolean;
/**
* By default, the _first tabbable element_ in the popover will receive focus
* when it mounts. This is the same as setting this prop to `"firstElement"`.
* Specifying a `false` value disables the focus handling entirely (this
* should only be done when an appropriately accessible substitute behavior
* exists).
*
* @default 'firstElement'
*/
focusOnMount?: 'firstElement' | boolean;
/**
* A callback invoked when the focus leaves the opened popover. This should
* only be provided in advanced use-cases when a popover should close under
* specific circumstances (for example, if the new `document.activeElement`
* is content of or otherwise controlling popover visibility).
*
* When not provided, the `onClose` callback will be called instead.
*/
onFocusOutside?: ( event: SyntheticEvent ) => void;
/**
* Used to customize the header text shown when the popover is toggled to
* fullscreen on mobile viewports (see the `expandOnMobile` prop).
*/
headerTitle?: string;
/**
* Used to show/hide the arrow that points at the popover's anchor.
*
* @default true
*/
noArrow?: boolean;
/**
* The distance (in px) between the anchor and the popover.
*/
offset?: number;
/**
* A callback invoked when the popover should be closed.
*/
onClose?: () => void;
/**
* Used to specify the popover's position with respect to its anchor.
*
* @default 'bottom-start'
*/
placement?: PopoverPlacement;
/**
* Legacy way to specify the popover's position with respect to its anchor.
* _Note: this prop is deprecated. Use the `placement` prop instead._
*/
position?:
| `${ PositionYAxis }`
| `${ PositionYAxis } ${ PositionXAxis }`
| `${ PositionYAxis } ${ PositionXAxis } ${ PositionCorner }`;
/**
* Adjusts the size of the popover to prevent its contents from going out of
* view when meeting the viewport edges.
*
* @default true
*/
resize?: boolean;
/**
* Enables the `Popover` to shift in order to stay in view when meeting the
* viewport edges.
*
* @default false
*/
shift?: boolean;
/**
* Specifies the popover's style.
*
* Leave undefined for the default style. Other values are:
* - 'unstyled': The popover is essentially without any visible style, it
* has no background, border, outline or drop shadow, but
* the popover contents are still displayed.
* - 'toolbar': A style that has no elevation, but a high contrast with
* other elements. This is matches the style of the
* `Toolbar` component.
*
* @default undefined
*/
variant?: 'unstyled' | 'toolbar';
/**
* Whether to render the popover inline or within the slot.
*
* @default false
*/
inline?: boolean;
// Deprecated props
/**
* Prevent the popover from flipping and resizing when meeting the viewport
* edges. _Note: this prop is deprecated. Instead, provide use the individual
* `flip` and `resize` props._
*
* @deprecated
*/
__unstableForcePosition?: boolean;
/**
* An object extending a `DOMRect` with an additional optional `ownerDocument`
* property, used to specify a fixed popover position.
*
* @deprecated
*/
anchorRect?: DomRectWithOwnerDocument;
/**
* Used to specify a fixed popover position. It can be an `Element`, a React
* reference to an `element`, an object with a `top` and a `bottom` properties
* (both pointing to elements), or a `range`.
*
* @deprecated
*/
anchorRef?:
| Element
| PopoverAnchorRefReference
| PopoverAnchorRefTopBottom
| Range;
/**
* A function returning the same value as the one expected by the `anchorRect`
* prop, used to specify a dynamic popover position.
*
* @deprecated
*/
getAnchorRect?: (
fallbackReferenceElement: Element | null
) => DomRectWithOwnerDocument;
/**
* Used to enable a different visual style for the popover.
* _Note: this prop is deprecated. Use the `variant` prop with the
* 'toolbar' value instead._
*
* @deprecated
*/
isAlternate?: boolean;
};

233
node_modules/@wordpress/components/src/popover/utils.ts generated vendored Normal file
View File

@@ -0,0 +1,233 @@
/**
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import type { MotionProps } from 'framer-motion';
import type { Placement, ReferenceType } from '@floating-ui/react-dom';
/**
* Internal dependencies
*/
import type {
PopoverProps,
PopoverAnchorRefReference,
PopoverAnchorRefTopBottom,
} from './types';
const POSITION_TO_PLACEMENT: Record<
NonNullable< PopoverProps[ 'position' ] >,
Placement
> = {
bottom: 'bottom',
top: 'top',
'middle left': 'left',
'middle right': 'right',
'bottom left': 'bottom-end',
'bottom center': 'bottom',
'bottom right': 'bottom-start',
'top left': 'top-end',
'top center': 'top',
'top right': 'top-start',
'middle left left': 'left',
'middle left right': 'left',
'middle left bottom': 'left-end',
'middle left top': 'left-start',
'middle right left': 'right',
'middle right right': 'right',
'middle right bottom': 'right-end',
'middle right top': 'right-start',
'bottom left left': 'bottom-end',
'bottom left right': 'bottom-end',
'bottom left bottom': 'bottom-end',
'bottom left top': 'bottom-end',
'bottom center left': 'bottom',
'bottom center right': 'bottom',
'bottom center bottom': 'bottom',
'bottom center top': 'bottom',
'bottom right left': 'bottom-start',
'bottom right right': 'bottom-start',
'bottom right bottom': 'bottom-start',
'bottom right top': 'bottom-start',
'top left left': 'top-end',
'top left right': 'top-end',
'top left bottom': 'top-end',
'top left top': 'top-end',
'top center left': 'top',
'top center right': 'top',
'top center bottom': 'top',
'top center top': 'top',
'top right left': 'top-start',
'top right right': 'top-start',
'top right bottom': 'top-start',
'top right top': 'top-start',
// `middle`/`middle center [corner?]` positions are associated to a fallback
// `bottom` placement because there aren't any corresponding placement values.
middle: 'bottom',
'middle center': 'bottom',
'middle center bottom': 'bottom',
'middle center left': 'bottom',
'middle center right': 'bottom',
'middle center top': 'bottom',
};
/**
* Converts the `Popover`'s legacy "position" prop to the new "placement" prop
* (used by `floating-ui`).
*
* @param position The legacy position
* @return The corresponding placement
*/
export const positionToPlacement = (
position: NonNullable< PopoverProps[ 'position' ] >
) => POSITION_TO_PLACEMENT[ position ] ?? 'bottom';
/**
* @typedef AnimationOrigin
* @type {Object}
* @property {number} originX A number between 0 and 1 (in CSS logical properties jargon, 0 is "start", 0.5 is "center", and 1 is "end")
* @property {number} originY A number between 0 and 1 (0 is top, 0.5 is center, and 1 is bottom)
*/
const PLACEMENT_TO_ANIMATION_ORIGIN: Record<
NonNullable< PopoverProps[ 'placement' ] >,
{ originX: number; originY: number }
> = {
top: { originX: 0.5, originY: 1 }, // open from bottom, center
'top-start': { originX: 0, originY: 1 }, // open from bottom, left
'top-end': { originX: 1, originY: 1 }, // open from bottom, right
right: { originX: 0, originY: 0.5 }, // open from middle, left
'right-start': { originX: 0, originY: 0 }, // open from top, left
'right-end': { originX: 0, originY: 1 }, // open from bottom, left
bottom: { originX: 0.5, originY: 0 }, // open from top, center
'bottom-start': { originX: 0, originY: 0 }, // open from top, left
'bottom-end': { originX: 1, originY: 0 }, // open from top, right
left: { originX: 1, originY: 0.5 }, // open from middle, right
'left-start': { originX: 1, originY: 0 }, // open from top, right
'left-end': { originX: 1, originY: 1 }, // open from bottom, right
overlay: { originX: 0.5, originY: 0.5 }, // open from center, center
};
/**
* Given the floating-ui `placement`, compute the framer-motion props for the
* popover's entry animation.
*
* @param placement A placement string from floating ui
* @return The object containing the motion props
*/
export const placementToMotionAnimationProps = (
placement: NonNullable< PopoverProps[ 'placement' ] >
): MotionProps => {
const translateProp =
placement.startsWith( 'top' ) || placement.startsWith( 'bottom' )
? 'translateY'
: 'translateX';
const translateDirection =
placement.startsWith( 'top' ) || placement.startsWith( 'left' )
? 1
: -1;
return {
style: PLACEMENT_TO_ANIMATION_ORIGIN[ placement ],
initial: {
opacity: 0,
scale: 0,
[ translateProp ]: `${ 2 * translateDirection }em`,
},
animate: { opacity: 1, scale: 1, [ translateProp ]: 0 },
transition: { duration: 0.1, ease: [ 0, 0, 0.2, 1 ] },
};
};
function isTopBottom(
anchorRef: PopoverProps[ 'anchorRef' ]
): anchorRef is PopoverAnchorRefTopBottom {
return !! ( anchorRef as PopoverAnchorRefTopBottom )?.top;
}
function isRef(
anchorRef: PopoverProps[ 'anchorRef' ]
): anchorRef is PopoverAnchorRefReference {
return !! ( anchorRef as PopoverAnchorRefReference )?.current;
}
export const getReferenceElement = ( {
anchor,
anchorRef,
anchorRect,
getAnchorRect,
fallbackReferenceElement,
}: Pick<
PopoverProps,
'anchorRef' | 'anchorRect' | 'getAnchorRect' | 'anchor'
> & {
fallbackReferenceElement: Element | null;
} ): ReferenceType | null => {
let referenceElement = null;
if ( anchor ) {
referenceElement = anchor;
} else if ( isTopBottom( anchorRef ) ) {
// Create a virtual element for the ref. The expectation is that
// if anchorRef.top is defined, then anchorRef.bottom is defined too.
// Seems to be used by the block toolbar, when multiple blocks are selected
// (top and bottom blocks are used to calculate the resulting rect).
referenceElement = {
getBoundingClientRect() {
const topRect = anchorRef.top.getBoundingClientRect();
const bottomRect = anchorRef.bottom.getBoundingClientRect();
return new window.DOMRect(
topRect.x,
topRect.y,
topRect.width,
bottomRect.bottom - topRect.top
);
},
};
} else if ( isRef( anchorRef ) ) {
// Standard React ref.
referenceElement = anchorRef.current;
} else if ( anchorRef ) {
// If `anchorRef` holds directly the element's value (no `current` key)
// This is a weird scenario and should be deprecated.
referenceElement = anchorRef as Element;
} else if ( anchorRect ) {
// Create a virtual element for the ref.
referenceElement = {
getBoundingClientRect() {
return anchorRect;
},
};
} else if ( getAnchorRect ) {
// Create a virtual element for the ref.
referenceElement = {
getBoundingClientRect() {
const rect = getAnchorRect( fallbackReferenceElement );
return new window.DOMRect(
rect.x ?? rect.left,
rect.y ?? rect.top,
rect.width ?? rect.right - rect.left,
rect.height ?? rect.bottom - rect.top
);
},
};
} else if ( fallbackReferenceElement ) {
// If no explicit ref is passed via props, fall back to
// anchoring to the popover's parent node.
referenceElement = fallbackReferenceElement.parentElement;
}
// Convert any `undefined` value to `null`.
return referenceElement ?? null;
};
/**
* Computes the final coordinate that needs to be applied to the floating
* element when applying transform inline styles, defaulting to `undefined`
* if the provided value is `null` or `NaN`.
*
* @param c input coordinate (usually as returned from floating-ui)
* @return The coordinate's value to be used for inline styles. An `undefined`
* return value means "no style set" for this coordinate.
*/
export const computePopoverPosition = ( c: number | null ) =>
c === null || Number.isNaN( c ) ? undefined : Math.round( c );