diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db64ce5c4..64d7c1c05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - ESC key pressed on a trigger element should propagate event if `Popup` is closed @sophieH29 ([#1373](https://github.com/stardust-ui/react/pull/1373)) ### Features -- Add keyboard navigation and screen reader support for `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) -- Add `expanded` prop to `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) +- Add keyboard navigation and screen reader support for `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) +- Add `expanded` prop to `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) +- Replace `react-popper` package with custom `Popper` component and exposed as `UNSTABLE_Popper` positioning helper @Bugaa92 ([#1358](https://github.com/stardust-ui/react/pull/1358)) ### Fixes - Changing icon behavior as for some cases icon could be visible ([#1327](https://github.com/stardust-ui/react/pull/1327)) diff --git a/docs/src/prototypes/MenuButton/MenuButton.tsx b/docs/src/prototypes/MenuButton/MenuButton.tsx index bb6a7fc3ee..d4e1d4ef8a 100644 --- a/docs/src/prototypes/MenuButton/MenuButton.tsx +++ b/docs/src/prototypes/MenuButton/MenuButton.tsx @@ -8,12 +8,14 @@ import { MenuProps, Ref, ShorthandValue, + Alignment, + Position, + UNSTABLE_Popper, } from '@stardust-ui/react' import * as _ from 'lodash' import * as keyboardKey from 'keyboard-key' import * as PopperJS from 'popper.js' import * as React from 'react' -import { Manager as PopperManager, Reference as PopperReference, Popper } from 'react-popper' import { focusMenuItem, focusNearest } from './focusUtils' import menuButtonBehavior from './menuButtonBehavior' @@ -44,15 +46,15 @@ class MenuButton extends React.Component { menuOpen: false, } - buttonNode: HTMLButtonElement - menuNode: HTMLUListElement + buttonRef = React.createRef() + menuRef = React.createRef() componentDidUpdate(_, prevState: MenuButtonState) { if (!prevState.menuOpen && this.state.menuOpen) { document.addEventListener('click', this.handleDocumentClick) focusMenuItem( - this.menuNode, + this.menuRef.current, this.state.lastKeyCode === keyboardKey.ArrowUp ? 'last' : 'first', ) } @@ -63,11 +65,11 @@ class MenuButton extends React.Component { switch (this.state.lastKeyCode) { case keyboardKey.Enter: case keyboardKey.Escape: - this.buttonNode.focus() + this.buttonRef.current.focus() break case keyboardKey.Tab: - focusNearest(this.buttonNode, this.state.lastShiftKey ? 'previous' : 'next') + focusNearest(this.buttonRef.current, this.state.lastShiftKey ? 'previous' : 'next') break } } @@ -87,7 +89,8 @@ class MenuButton extends React.Component { const { menuOpen } = this.state const target = e.target as HTMLElement const isInside = - _.invoke(this.buttonNode, 'contains', target) || _.invoke(this.menuNode, 'contains', target) + _.invoke(this.buttonRef.current, 'contains', target) || + _.invoke(this.menuRef.current, 'contains', target) if (menuOpen && !isInside) { this.setState({ lastKeyCode: null, menuOpen: false }) @@ -146,6 +149,7 @@ class MenuButton extends React.Component { render() { const { button, disabled, menu, placement } = this.props const { menuOpen } = this.state + const [position, align] = _.split(placement, '-') as [Position, Alignment] const accessibilityBehavior: AccessibilityBehavior = menuButtonBehavior({ ...this.props, ...this.state, @@ -156,53 +160,32 @@ class MenuButton extends React.Component { onKeyDown={this.handleKeyDown} style={{ boxSizing: 'border-box', display: 'inline-block' }} > - - - {({ ref }) => ( - { - this.buttonNode = buttonNode - ref(buttonNode) - }} - > - {Button.create(button, { - defaultProps: { - ...accessibilityBehavior.attributes.button, - disabled, - }, - overrideProps: this.handleButtonOverrides, - })} - - )} - - - {({ placement, ref, style }) => - menuOpen && ( - { - this.menuNode = menuNode - ref(menuNode) - }} - > - {Menu.create(menu, { - defaultProps: { - ...accessibilityBehavior.attributes.menu, - 'data-placement': placement, - styles: { background: '#fff', zIndex: 1 }, - vertical: true, - }, - overrideProps: { - items: this.handleMenuItemOverrides( - accessibilityBehavior.attributes.menuItem, - ), - style, - }, - })} - - ) - } - - + + {Button.create(button, { + defaultProps: { + ...accessibilityBehavior.attributes.button, + disabled, + }, + overrideProps: this.handleButtonOverrides, + })} + + {menuOpen && ( + + + {Menu.create(menu, { + defaultProps: { + ...accessibilityBehavior.attributes.menu, + 'data-placement': placement, + styles: { background: '#fff', zIndex: 1 }, + vertical: true, + }, + overrideProps: { + items: this.handleMenuItemOverrides(accessibilityBehavior.attributes.menuItem), + }, + })} + + + )} ) } diff --git a/docs/src/prototypes/MenuButton/index.tsx b/docs/src/prototypes/MenuButton/index.tsx index 8b1c4f600c..1b31771792 100644 --- a/docs/src/prototypes/MenuButton/index.tsx +++ b/docs/src/prototypes/MenuButton/index.tsx @@ -36,19 +36,18 @@ class MenuButtonPrototype extends React.Component<{}, MenuButtonPrototypeState>
, ChildrenComponentProps, - ContentComponentProps { + ContentComponentProps, + PositioningProps { /** * Accessibility behavior if overridden by the user. * @default popupBehavior @@ -67,9 +66,6 @@ export interface PopupProps * */ accessibility?: Accessibility - /** Alignment for the popup. */ - align?: Alignment - /** Additional CSS class name(s) to apply. */ className?: string @@ -88,15 +84,6 @@ export interface PopupProps /** Delay in ms for the mouse leave event, before the popup will be closed. */ mouseLeaveDelay?: number - /** Offset value to apply to rendered popup. Accepts the following units: - * - px or unit-less, interpreted as pixels - * - %, percentage relative to the length of the trigger element - * - %p, percentage relative to the length of the popup element - * - vw, CSS viewport width unit - * - vh, CSS viewport height unit - */ - offset?: string - /** Events triggering the popup. */ on?: PopupEvents | PopupEventsArray @@ -113,13 +100,6 @@ export interface PopupProps /** A popup can show a pointer to trigger. */ pointing?: boolean - /** - * Position for the popup. Position has higher priority than align. If position is vertical ('above' | 'below') - * and align is also vertical ('top' | 'bottom') or if both position and align are horizontal ('before' | 'after' - * and 'start' | 'end' respectively), then provided value for 'align' will be ignored and 'center' will be used instead. - */ - position?: Position - /** * Function to render popup content. * @param {Function} updatePosition - function to request popup position update. @@ -140,7 +120,6 @@ export interface PopupProps export interface PopupState { open: boolean - target: HTMLElement } /** @@ -171,6 +150,7 @@ export default class Popup extends AutoControlledComponent() triggerRef = React.createRef() as React.MutableRefObject // focusable element which has triggered Popup, can be either triggerDomElement or the element inside it triggerFocusableDomElement = null @@ -407,27 +388,17 @@ export default class Popup extends AutoControlledComponent ) } @@ -436,17 +407,9 @@ export default class Popup extends AutoControlledComponent { const { content: propsContent, renderContent, contentRef, mountDocument, pointing } = this.props - const popupPlacementStyles = _.omitBy(popupPlacementStylesRaw, _.isNaN) const content = renderContent ? renderContent(scheduleUpdate) : propsContent const documentRef = toRefObject(mountDocument) @@ -455,7 +418,6 @@ export default class Popup extends AutoControlledComponent { - ref(domElement) this.popupDomElement = domElement handleRef(contentRef, domElement) handleRef(nestingRef, domElement) diff --git a/packages/react/src/components/Popup/PopupContent.tsx b/packages/react/src/components/Popup/PopupContent.tsx index c70d1ef022..357fe3165b 100644 --- a/packages/react/src/components/Popup/PopupContent.tsx +++ b/packages/react/src/components/Popup/PopupContent.tsx @@ -1,8 +1,8 @@ import { Ref } from '@stardust-ui/react-component-ref' import * as React from 'react' -import { PopperChildrenProps } from 'react-popper' import * as PropTypes from 'prop-types' import * as _ from 'lodash' +import * as customPropTypes from '@stardust-ui/react-proptypes' import { childrenExist, @@ -17,6 +17,7 @@ import { } from '../../lib' import { Accessibility } from '../../lib/accessibility/types' import { defaultBehavior } from '../../lib/accessibility' +import { PopperChildrenProps } from '../../lib/positioner' import { WithAsProp, ComponentEventHandler, withSafeTypeForAs } from '../../types' import Box from '../Box/Box' @@ -51,13 +52,10 @@ export interface PopupContentProps pointing?: boolean /** A ref to a pointer element. */ - pointerRef?: PopperChildrenProps['arrowProps']['ref'] - - /** An object with positioning styles fof a pointer. */ - pointerStyle?: PopperChildrenProps['arrowProps']['style'] + pointerRef?: React.Ref } -class PopupContent extends UIComponent, any> { +class PopupContent extends UIComponent> { public static create: Function public static displayName = 'PopupContent' @@ -69,8 +67,7 @@ class PopupContent extends UIComponent, any> { pointing: PropTypes.bool, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, - pointerRef: PropTypes.func, - pointerStyle: PropTypes.object, + pointerRef: customPropTypes.ref, } static defaultProps = { @@ -92,7 +89,7 @@ class PopupContent extends UIComponent, any> { unhandledProps, styles, }: RenderResultConfig): React.ReactNode { - const { children, content, pointing, pointerRef, pointerStyle } = this.props + const { children, content, pointing, pointerRef } = this.props return ( , any> { > {pointing && ( - {Box.create( - {}, - { - defaultProps: { - style: pointerStyle, - styles: styles.pointer, - }, - }, - )} + {Box.create({}, { defaultProps: { styles: styles.pointer } })} )} diff --git a/packages/react/src/components/Popup/createPopperReferenceProxy.ts b/packages/react/src/components/Popup/createPopperReferenceProxy.ts deleted file mode 100644 index 48aca30e5a..0000000000 --- a/packages/react/src/components/Popup/createPopperReferenceProxy.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { isRefObject, toRefObject } from '@stardust-ui/react-component-ref' -import * as _ from 'lodash' -import * as React from 'react' -import * as PopperJS from 'popper.js' - -class ReferenceProxy implements PopperJS.ReferenceObject { - ref: React.RefObject - - constructor(refObject) { - this.ref = refObject - } - - getBoundingClientRect() { - return _.invoke(this.ref.current, 'getBoundingClientRect', {}) - } - - get clientWidth() { - return this.getBoundingClientRect().width - } - - get clientHeight() { - return this.getBoundingClientRect().height - } - - /** - * Required to allow properly finding a node to attach scroll. - * - * @see https://github.com/FezVrasta/popper.js/blob/v1.15.0/packages/popper/src/utils/getParentNode.js#L12 - */ - get parentNode() { - return this.ref.current ? this.ref.current.parentNode : undefined - } -} - -/** - * Popper.js does not support ref objects from `createRef()` as referenceElement. If we will pass - * directly `ref`, `ref.current` will be `null` at the render process. - * - * @see https://popper.js.org/popper-documentation.html#referenceObject - * @see https://github.com/FezVrasta/react-popper/blob/v1.3.3/src/Popper.js#L166 - */ -const createPopperReferenceProxy = (reference: HTMLElement | React.RefObject) => { - const referenceRef = isRefObject(reference) ? reference : toRefObject(reference) - - return new ReferenceProxy(referenceRef) -} - -export default createPopperReferenceProxy diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0a25b1df7d..9f24e8e1f5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -116,7 +116,6 @@ export * from './components/Popup/Popup' export { default as Popup } from './components/Popup/Popup' export * from './components/Popup/PopupContent' export { default as PopupContent } from './components/Popup/PopupContent' -export * from './components/Popup/positioningHelper' export * from './components/Portal/Portal' export { default as Portal } from './components/Portal/Portal' @@ -199,6 +198,8 @@ export { default as mergeThemes } from './lib/mergeThemes' export * from './lib/createStardustComponent' export * from './lib' export * from './types' +export { Popper as UNSTABLE_Popper } from './lib/positioner' +export * from './lib/positioner/types' // // FocusZone diff --git a/packages/react/src/lib/positioner/Popper.tsx b/packages/react/src/lib/positioner/Popper.tsx new file mode 100644 index 0000000000..c6e6715a15 --- /dev/null +++ b/packages/react/src/lib/positioner/Popper.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import PopperJS from 'popper.js' +import { Ref } from '@stardust-ui/react-component-ref' + +import { getPlacement, applyRtlToOffset } from './positioningHelper' +import { PopperProps, PopperChildrenFn } from './types' + +/** + * Popper relies on the 3rd party library [Popper.js](https://github.com/FezVrasta/popper.js) for positioning. + */ +const Popper: React.FunctionComponent = props => { + const { + align, + children, + eventsEnabled, + modifiers, + offset, + pointerTargetRef, + position, + positionFixed, + positioningDependencies = [], + rtl, + targetRef, + } = props + + const proposedPlacement = getPlacement({ align, position, rtl }) + + const popperRef = React.useRef() + const contentRef = React.useRef(null) + const latestPlacement = React.useRef(proposedPlacement) + const [computedPlacement, setComputedPlacement] = React.useState( + proposedPlacement, + ) + + const computedModifiers: PopperJS.Modifiers = React.useMemo( + () => + offset && { + offset: { offset: rtl ? applyRtlToOffset(offset, position) : offset }, + keepTogether: { enabled: false }, + }, + [rtl, offset, position], + ) + + React.useEffect( + () => { + const handleUpdate = (data: PopperJS.Data) => { + // PopperJS performs computations that might update the computed placement: auto positioning, flipping the + // placement in case the popper box should be rendered at the edge of the viewport and does not fit + if (data.placement !== latestPlacement.current) { + latestPlacement.current = data.placement + setComputedPlacement(data.placement) + } + } + + const pointerTargetRefElement = pointerTargetRef && pointerTargetRef.current + const options: PopperJS.PopperOptions = { + placement: proposedPlacement, + eventsEnabled, + positionFixed, + modifiers: { + ...computedModifiers, + ...modifiers, + arrow: { + enabled: !!pointerTargetRefElement, + element: pointerTargetRefElement, + }, + }, + onCreate: handleUpdate, + onUpdate: handleUpdate, + } + + popperRef.current = new PopperJS(targetRef.current, contentRef.current, options) + return () => popperRef.current.destroy() + }, + [computedModifiers, eventsEnabled, modifiers, positionFixed, proposedPlacement], + ) + + React.useEffect( + () => { + popperRef.current.scheduleUpdate() + }, + [...positioningDependencies, computedPlacement], + ) + + const child = + typeof children === 'function' + ? (children as PopperChildrenFn)({ + placement: computedPlacement, + scheduleUpdate: () => popperRef.current && popperRef.current.scheduleUpdate(), + }) + : React.Children.only(children) + + return {child as React.ReactElement} +} + +Popper.defaultProps = { + eventsEnabled: true, + positionFixed: false, + positioningDependencies: [], +} + +export default Popper diff --git a/packages/react/src/lib/positioner/index.ts b/packages/react/src/lib/positioner/index.ts new file mode 100644 index 0000000000..888669aa5e --- /dev/null +++ b/packages/react/src/lib/positioner/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './types.internal' +export { default as Popper } from './Popper' diff --git a/packages/react/src/components/Popup/positioningHelper.ts b/packages/react/src/lib/positioner/positioningHelper.ts similarity index 63% rename from packages/react/src/components/Popup/positioningHelper.ts rename to packages/react/src/lib/positioner/positioningHelper.ts index ef27ed32fc..b128a7d7a3 100644 --- a/packages/react/src/components/Popup/positioningHelper.ts +++ b/packages/react/src/lib/positioner/positioningHelper.ts @@ -1,21 +1,6 @@ -export type Placement = - | 'auto-start' - | 'auto' - | 'auto-end' - | 'top-start' - | 'top' - | 'top-end' - | 'right-start' - | 'right' - | 'right-end' - | 'bottom-end' - | 'bottom' - | 'bottom-start' - | 'left-end' - | 'left' - | 'left-start' -export type Position = 'above' | 'below' | 'before' | 'after' -export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center' +import { Placement } from 'popper.js' + +import { Alignment, Position } from './types' enum PlacementParts { top = 'top', @@ -27,22 +12,20 @@ enum PlacementParts { center = '', } -type PlacementCb = (rtl: boolean) => PlacementParts - -const positionMap: Map = new Map([ - ['above', () => PlacementParts.top], - ['below', () => PlacementParts.bottom], - ['before', rtl => (rtl ? PlacementParts.right : PlacementParts.left)], - ['after', rtl => (rtl ? PlacementParts.left : PlacementParts.right)], -] as [Position, PlacementCb][]) +const getPositionMap = (rtl: boolean): { [key in Position]: PlacementParts } => ({ + above: PlacementParts.top, + below: PlacementParts.bottom, + before: rtl ? PlacementParts.right : PlacementParts.left, + after: rtl ? PlacementParts.left : PlacementParts.right, +}) -const alignmentMap: Map = new Map([ - ['start', rtl => (rtl ? PlacementParts.end : PlacementParts.start)], - ['end', rtl => (rtl ? PlacementParts.start : PlacementParts.end)], - ['top', () => PlacementParts.start], - ['bottom', () => PlacementParts.end], - ['center', () => PlacementParts.center], -] as [Alignment, PlacementCb][]) +const getAlignmentMap = (rtl: boolean): { [key in Alignment]: PlacementParts } => ({ + start: rtl ? PlacementParts.end : PlacementParts.start, + end: rtl ? PlacementParts.start : PlacementParts.end, + top: PlacementParts.start, + bottom: PlacementParts.end, + center: PlacementParts.center, +}) const shouldAlignToCenter = (p: Position, a: Alignment) => { const positionedVertically = p === 'above' || p === 'below' @@ -69,7 +52,7 @@ const shouldAlignToCenter = (p: Position, a: Alignment) => { * | after | center | right | left * | after | bottom | right-end | left-end */ -export const getPopupPlacement = ({ +export const getPlacement = ({ align, position, rtl, @@ -79,10 +62,8 @@ export const getPopupPlacement = ({ rtl: boolean }): Placement => { const alignment: Alignment = shouldAlignToCenter(position, align) ? 'center' : align - - const computedPosition = positionMap.get(position)(rtl) - const computedAlignmnent = alignmentMap.get(alignment)(rtl) - + const computedPosition = getPositionMap(rtl)[position] + const computedAlignmnent = getAlignmentMap(rtl)[alignment] const stringifiedAlignment = computedAlignmnent && `-${computedAlignmnent}` return `${computedPosition}${stringifiedAlignment}` as Placement diff --git a/packages/react/src/lib/positioner/types.internal.ts b/packages/react/src/lib/positioner/types.internal.ts new file mode 100644 index 0000000000..4150ea9e04 --- /dev/null +++ b/packages/react/src/lib/positioner/types.internal.ts @@ -0,0 +1,4 @@ +import { Alignment, Position } from './types' + +export const ALIGNMENTS: Alignment[] = ['top', 'bottom', 'start', 'end', 'center'] +export const POSITIONS: Position[] = ['above', 'below', 'before', 'after'] diff --git a/packages/react/src/lib/positioner/types.ts b/packages/react/src/lib/positioner/types.ts new file mode 100644 index 0000000000..1700ab2657 --- /dev/null +++ b/packages/react/src/lib/positioner/types.ts @@ -0,0 +1,87 @@ +import PopperJS from 'popper.js' + +export type Position = 'above' | 'below' | 'before' | 'after' +export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center' + +export type PopperChildrenFn = (props: PopperChildrenProps) => React.ReactNode + +export interface PositioningProps { + /** + * Alignment for the component. + */ + align?: Alignment + + /** + * Offset value to apply to rendered component. Accepts the following units: + * - px or unit-less, interpreted as pixels + * - %, percentage relative to the length of the trigger element + * - %p, percentage relative to the length of the component element + * - vw, CSS viewport width unit + * - vh, CSS viewport height unit + */ + offset?: string + + /** + * Position for the component. Position has higher priority than align. If position is vertical ('above' | 'below') + * and align is also vertical ('top' | 'bottom') or if both position and align are horizontal ('before' | 'after' + * and 'start' | 'end' respectively), then provided value for 'align' will be ignored and 'center' will be used instead. + */ + position?: Position +} + +export interface PopperProps extends PositioningProps { + /** + * Ref object containing the pointer node. + */ + pointerTargetRef?: React.RefObject + + /** + * The content of the Popper box (the element that is going to be repositioned). + */ + children: PopperChildrenFn | React.ReactNode + + /** + * Enables events (resize, scroll). + * @prop {Boolean} eventsEnabled=true + */ + eventsEnabled?: boolean + + /** + * List of modifiers used to modify the offsets before they are applied to the Popper box. + * They provide most of the functionality of Popper.js. + */ + modifiers?: PopperJS.Modifiers + + /** + * Array of conditions to be met in order to trigger a subsequent render to reposition the elements. + */ + positioningDependencies?: React.DependencyList + + /** + * Enables the Popper box to position itself in 'fixed' mode (default value is position: 'absolute') + * @prop {Boolean} positionFixed=false + */ + positionFixed?: boolean + + /** + * Ref object containing the target node (the element that we're using as reference for Popper box). + */ + targetRef?: React.RefObject + + /** + * Rtl attribute for the component. + */ + rtl?: boolean +} + +export interface PopperChildrenProps { + /** + * Popper's placement. + */ + placement: PopperJS.Placement + + /** + * Function that updates the position of the Popper box, computing the new offsets and applying the new style. + */ + scheduleUpdate(): void +} diff --git a/packages/react/src/themes/teams/components/Popup/popupContentStyles.ts b/packages/react/src/themes/teams/components/Popup/popupContentStyles.ts index aee6ef5ea1..e880e48360 100644 --- a/packages/react/src/themes/teams/components/Popup/popupContentStyles.ts +++ b/packages/react/src/themes/teams/components/Popup/popupContentStyles.ts @@ -1,4 +1,3 @@ -import { PopperChildrenProps } from 'react-popper' import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types' import { PopupContentProps } from '../../../../components/Popup/PopupContent' import { PopupContentVariables } from './popupContentVariables' @@ -11,7 +10,7 @@ const rtlMapping = { const getPointerStyles = ( v: PopupContentVariables, rtl: boolean, - popperPlacement?: PopperChildrenProps['placement'], + popperPlacement?: PopupContentProps['placement'], ) => { const placementValue = (popperPlacement || '').split('-', 1).pop() const placement = (rtl && rtlMapping[placementValue]) || placementValue diff --git a/packages/react/test/specs/components/Popup/Popup-test.tsx b/packages/react/test/specs/components/Popup/Popup-test.tsx index e392d2f93a..8c2239f712 100644 --- a/packages/react/test/specs/components/Popup/Popup-test.tsx +++ b/packages/react/test/specs/components/Popup/Popup-test.tsx @@ -1,12 +1,5 @@ -import { Placement } from 'popper.js' import * as React from 'react' -import { - getPopupPlacement, - applyRtlToOffset, - Position, - Alignment, -} from 'src/components/Popup/positioningHelper' import Popup, { PopupEvents } from 'src/components/Popup/Popup' import { Accessibility } from 'src/lib/accessibility/types' import { popupFocusTrapBehavior, popupBehavior, dialogBehavior } from 'src/lib/accessibility/index' @@ -15,33 +8,9 @@ import { domEvent, mountWithProvider } from '../../../utils' import * as keyboardKey from 'keyboard-key' import { ReactWrapper } from 'enzyme' -type PositionTestInput = { - align: Alignment - position: Position - expectedPlacement: Placement - rtl?: boolean -} - describe('Popup', () => { const triggerId = 'triggerElement' const contentId = 'contentId' - const testPopupPosition = ({ - align, - position, - expectedPlacement, - rtl = false, - }: PositionTestInput) => - it(`Popup ${position} position is transformed to ${expectedPlacement} Popper's placement`, () => { - const actualPlacement = getPopupPlacement({ align, position, rtl }) - expect(actualPlacement).toEqual(expectedPlacement) - }) - - const testPopupPositionInRtl = ({ - align, - position, - expectedPlacement, - }: PositionTestInput & { rtl?: never }) => - testPopupPosition({ align, position, expectedPlacement, rtl: true }) const getPopupContent = (popup: ReactWrapper) => { return popup.find(`div#${contentId}`) @@ -76,77 +45,6 @@ describe('Popup', () => { expect(getPopupContent(popup).exists()).toBe(false) } - describe('handles Popup position correctly in ltr', () => { - testPopupPosition({ position: 'above', align: 'start', expectedPlacement: 'top-start' }) - testPopupPosition({ position: 'above', align: 'center', expectedPlacement: 'top' }) - testPopupPosition({ position: 'above', align: 'end', expectedPlacement: 'top-end' }) - testPopupPosition({ position: 'below', align: 'start', expectedPlacement: 'bottom-start' }) - testPopupPosition({ position: 'below', align: 'center', expectedPlacement: 'bottom' }) - testPopupPosition({ position: 'below', align: 'end', expectedPlacement: 'bottom-end' }) - testPopupPosition({ position: 'before', align: 'top', expectedPlacement: 'left-start' }) - testPopupPosition({ position: 'before', align: 'center', expectedPlacement: 'left' }) - testPopupPosition({ position: 'before', align: 'bottom', expectedPlacement: 'left-end' }) - testPopupPosition({ position: 'after', align: 'top', expectedPlacement: 'right-start' }) - testPopupPosition({ position: 'after', align: 'center', expectedPlacement: 'right' }) - testPopupPosition({ position: 'after', align: 'bottom', expectedPlacement: 'right-end' }) - }) - - describe('handles Popup position correctly in rtl', () => { - testPopupPositionInRtl({ position: 'above', align: 'start', expectedPlacement: 'top-end' }) - testPopupPositionInRtl({ position: 'above', align: 'center', expectedPlacement: 'top' }) - testPopupPositionInRtl({ position: 'above', align: 'end', expectedPlacement: 'top-start' }) - testPopupPositionInRtl({ position: 'below', align: 'start', expectedPlacement: 'bottom-end' }) - testPopupPositionInRtl({ position: 'below', align: 'center', expectedPlacement: 'bottom' }) - testPopupPositionInRtl({ position: 'below', align: 'end', expectedPlacement: 'bottom-start' }) - testPopupPositionInRtl({ position: 'before', align: 'top', expectedPlacement: 'right-start' }) - testPopupPositionInRtl({ position: 'before', align: 'center', expectedPlacement: 'right' }) - testPopupPositionInRtl({ position: 'before', align: 'bottom', expectedPlacement: 'right-end' }) - testPopupPositionInRtl({ position: 'after', align: 'top', expectedPlacement: 'left-start' }) - testPopupPositionInRtl({ position: 'after', align: 'center', expectedPlacement: 'left' }) - testPopupPositionInRtl({ position: 'after', align: 'bottom', expectedPlacement: 'left-end' }) - }) - - describe('Popup offset transformed correctly in RTL', () => { - it("applies transform only for 'above' and 'below' postioning", () => { - const originalOffsetValue = '100%' - - expect(applyRtlToOffset(originalOffsetValue, 'above')).not.toBe(originalOffsetValue) - expect(applyRtlToOffset(originalOffsetValue, 'below')).not.toBe(originalOffsetValue) - - expect(applyRtlToOffset(originalOffsetValue, 'before')).toBe(originalOffsetValue) - expect(applyRtlToOffset(originalOffsetValue, 'after')).toBe(originalOffsetValue) - }) - - const expectOffsetTransformResult = (originalOffset, resultOffset) => { - expect(applyRtlToOffset(originalOffset, 'above')).toBe(resultOffset) - } - - it('flips sign of simple expressions', () => { - expectOffsetTransformResult('100%', '-100%') - expectOffsetTransformResult(' 2000%p ', '-2000%p') - expectOffsetTransformResult('100 ', '-100') - expectOffsetTransformResult(' - 200vh', '200vh') - }) - - it('flips sign of complex expressions', () => { - expectOffsetTransformResult('100% + 200', '-100% - 200') - expectOffsetTransformResult(' - 2000%p - 400 +800vh ', '2000%p + 400 -800vh') - }) - - it('transforms only horizontal offset value', () => { - const xOffset = '-100%' - const yOffset = '800vh' - - const offsetValue = [xOffset, yOffset].join(',') - const [xOffsetTransformed, yOffsetTransformed] = applyRtlToOffset(offsetValue, 'above').split( - ',', - ) - - expect(xOffsetTransformed.trim()).not.toBe(xOffset) - expect(yOffsetTransformed.trim()).toBe(yOffset) - }) - }) - describe('onOpenChange', () => { test('is called on click', () => { const spy = jest.fn() diff --git a/packages/react/test/specs/lib/positioner/positioningHelper-test.ts b/packages/react/test/specs/lib/positioner/positioningHelper-test.ts new file mode 100644 index 0000000000..370b6e19dd --- /dev/null +++ b/packages/react/test/specs/lib/positioner/positioningHelper-test.ts @@ -0,0 +1,122 @@ +import { Placement } from 'popper.js' + +import { Alignment, Position } from 'src/lib/positioner' +import { getPlacement, applyRtlToOffset } from 'src/lib/positioner/positioningHelper' + +type PositionTestInput = { + align: Alignment + position: Position + expectedPlacement: Placement + rtl?: boolean +} + +describe('positioningHelper', () => { + const testPositioningHelper = ({ + align, + position, + expectedPlacement, + rtl = false, + }: PositionTestInput) => + it(`positioningHelper ${position} position argument is transformed to ${expectedPlacement} Popper's placement`, () => { + const actualPlacement = getPlacement({ align, position, rtl }) + expect(actualPlacement).toEqual(expectedPlacement) + }) + + const testPositioningHelperInRtl = ({ + align, + position, + expectedPlacement, + }: PositionTestInput & { rtl?: never }) => + testPositioningHelper({ align, position, expectedPlacement, rtl: true }) + + describe('handles positioningHelper position argument correctly in ltr', () => { + testPositioningHelper({ position: 'above', align: 'start', expectedPlacement: 'top-start' }) + testPositioningHelper({ position: 'above', align: 'center', expectedPlacement: 'top' }) + testPositioningHelper({ position: 'above', align: 'end', expectedPlacement: 'top-end' }) + testPositioningHelper({ position: 'below', align: 'start', expectedPlacement: 'bottom-start' }) + testPositioningHelper({ position: 'below', align: 'center', expectedPlacement: 'bottom' }) + testPositioningHelper({ position: 'below', align: 'end', expectedPlacement: 'bottom-end' }) + testPositioningHelper({ position: 'before', align: 'top', expectedPlacement: 'left-start' }) + testPositioningHelper({ position: 'before', align: 'center', expectedPlacement: 'left' }) + testPositioningHelper({ position: 'before', align: 'bottom', expectedPlacement: 'left-end' }) + testPositioningHelper({ position: 'after', align: 'top', expectedPlacement: 'right-start' }) + testPositioningHelper({ position: 'after', align: 'center', expectedPlacement: 'right' }) + testPositioningHelper({ position: 'after', align: 'bottom', expectedPlacement: 'right-end' }) + }) + + describe('handles positioningHelper position argument correctly in rtl', () => { + testPositioningHelperInRtl({ position: 'above', align: 'start', expectedPlacement: 'top-end' }) + testPositioningHelperInRtl({ position: 'above', align: 'center', expectedPlacement: 'top' }) + testPositioningHelperInRtl({ position: 'above', align: 'end', expectedPlacement: 'top-start' }) + testPositioningHelperInRtl({ + position: 'below', + align: 'start', + expectedPlacement: 'bottom-end', + }) + testPositioningHelperInRtl({ position: 'below', align: 'center', expectedPlacement: 'bottom' }) + testPositioningHelperInRtl({ + position: 'below', + align: 'end', + expectedPlacement: 'bottom-start', + }) + testPositioningHelperInRtl({ + position: 'before', + align: 'top', + expectedPlacement: 'right-start', + }) + testPositioningHelperInRtl({ position: 'before', align: 'center', expectedPlacement: 'right' }) + testPositioningHelperInRtl({ + position: 'before', + align: 'bottom', + expectedPlacement: 'right-end', + }) + testPositioningHelperInRtl({ position: 'after', align: 'top', expectedPlacement: 'left-start' }) + testPositioningHelperInRtl({ position: 'after', align: 'center', expectedPlacement: 'left' }) + testPositioningHelperInRtl({ + position: 'after', + align: 'bottom', + expectedPlacement: 'left-end', + }) + }) + + describe('positioningHelper offset argument transformed correctly in RTL', () => { + it("applies transform only for 'above' and 'below' postioning", () => { + const originalOffsetValue = '100%' + + expect(applyRtlToOffset(originalOffsetValue, 'above')).not.toBe(originalOffsetValue) + expect(applyRtlToOffset(originalOffsetValue, 'below')).not.toBe(originalOffsetValue) + + expect(applyRtlToOffset(originalOffsetValue, 'before')).toBe(originalOffsetValue) + expect(applyRtlToOffset(originalOffsetValue, 'after')).toBe(originalOffsetValue) + }) + + const expectOffsetTransformResult = (originalOffset, resultOffset) => { + expect(applyRtlToOffset(originalOffset, 'above')).toBe(resultOffset) + } + + it('flips sign of simple expressions', () => { + expectOffsetTransformResult('100%', '-100%') + expectOffsetTransformResult(' 2000%p ', '-2000%p') + expectOffsetTransformResult('100 ', '-100') + expectOffsetTransformResult(' - 200vh', '200vh') + }) + + it('flips sign of complex expressions', () => { + expectOffsetTransformResult('100% + 200', '-100% - 200') + expectOffsetTransformResult(' - 2000%p - 400 +800vh ', '2000%p + 400 -800vh') + }) + + it('transforms only horizontal offset value', () => { + const xOffset = '-100%' + const yOffset = '800vh' + + const offsetValue = [xOffset, yOffset].join(',') + const [xOffsetTransformed, yOffsetTransformed] = applyRtlToOffset(offsetValue, 'above').split( + ',', + ) + + expect(xOffsetTransformed.trim()).not.toBe(xOffset) + expect(yOffsetTransformed.trim()).toBe(yOffset) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index a414fe4d85..b0c0c9c14d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3983,14 +3983,6 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" -create-react-context@<=0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.2.tgz#9836542f9aaa22868cd7d4a6f82667df38019dca" - integrity sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A== - dependencies: - fbjs "^0.8.0" - gud "^1.0.0" - cross-env@^5.1.4: version "5.2.0" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2" @@ -5621,7 +5613,7 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" -fbjs@^0.8.0, fbjs@^0.8.4: +fbjs@^0.8.4: version "0.8.17" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= @@ -6538,11 +6530,6 @@ growly@^1.3.0: resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= -gud@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" - integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== - gulp-cache@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/gulp-cache/-/gulp-cache-1.0.2.tgz#064ed713de47b0ff26b491abd899590891e426f4" @@ -10859,10 +10846,10 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== -popper.js@^1.14.4: - version "1.14.6" - resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.6.tgz#ab20dd4edf9288b8b3b6531c47c361107b60b4b0" - integrity sha512-AGwHGQBKumlk/MDfrSOf0JHhJCImdDMcGNoqKmKkU+68GFazv3CQ6q9r7Ja1sKDZmYWTckY/uLyEznheTDycnA== +popper.js@^1.14.6: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== portfinder@^1.0.20, portfinder@~1.0.10: version "1.0.20" @@ -11375,18 +11362,6 @@ react-node-resolver@^1.0.1: resolved "https://registry.yarnpkg.com/react-node-resolver/-/react-node-resolver-1.0.1.tgz#1798a729c0e218bf2f0e8ddf79c550d4af61d83a" integrity sha1-F5inKcDiGL8vDo3fecVQ1K9h2Do= -react-popper@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.2.tgz#e723a0a7fe1c42099a13e5d494e9d7d74b352af4" - integrity sha512-UbFWj55Yt9uqvy0oZ+vULDL2Bw1oxeZF9/JzGyxQ5ypgauRH/XlarA5+HLZWro/Zss6Ht2kqpegtb6sYL8GUGw== - dependencies: - "@babel/runtime" "^7.1.2" - create-react-context "<=0.2.2" - popper.js "^1.14.4" - prop-types "^15.6.1" - typed-styles "^0.0.7" - warning "^4.0.2" - react-router-dom@^4.1.2: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" @@ -13748,11 +13723,6 @@ type-is@~1.6.15, type-is@~1.6.16: media-typer "0.3.0" mime-types "~2.1.18" -typed-styles@^0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" - integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== - typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -14242,7 +14212,7 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" -warning@^4.0.1, warning@^4.0.2: +warning@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607" integrity sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==