From 89a724a5607fb995807c4d12d9ea3f4862090260 Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Thu, 9 May 2019 19:48:51 +0200 Subject: [PATCH 01/30] feat(dropdown): align, position, offset props + automatic position --- .../DropdownExampleOffset.shorthand.tsx | 12 ++ .../DropdownExamplePosition.shorthand.tsx | 38 ++++++ .../components/Dropdown/Variations/index.tsx | 10 ++ packages/react-component-ref/src/Ref.tsx | 2 +- .../src/components/Dropdown/Dropdown.tsx | 74 ++++++++--- packages/react/src/components/Popup/Popup.tsx | 59 ++------- packages/react/src/index.ts | 2 +- .../react/src/lib/positioner/Positioner.tsx | 64 +++++++++ .../src/lib/positioner/UpdatableComponent.tsx | 43 ++++++ .../positioner}/createPopperReferenceProxy.ts | 6 +- packages/react/src/lib/positioner/index.ts | 7 + .../positioner}/positioningHelper.ts | 6 +- .../specs/components/Popup/Popup-test.tsx | 102 --------------- .../lib/positioner/positioningHelper-test.ts | 122 ++++++++++++++++++ 14 files changed, 370 insertions(+), 177 deletions(-) create mode 100644 docs/src/examples/components/Dropdown/Variations/DropdownExampleOffset.shorthand.tsx create mode 100644 docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx create mode 100644 packages/react/src/lib/positioner/Positioner.tsx create mode 100644 packages/react/src/lib/positioner/UpdatableComponent.tsx rename packages/react/src/{components/Popup => lib/positioner}/createPopperReferenceProxy.ts (92%) create mode 100644 packages/react/src/lib/positioner/index.ts rename packages/react/src/{components/Popup => lib/positioner}/positioningHelper.ts (93%) create mode 100644 packages/react/test/specs/lib/positioner/positioningHelper-test.ts diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExampleOffset.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExampleOffset.shorthand.tsx new file mode 100644 index 0000000000..1ad84e8708 --- /dev/null +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExampleOffset.shorthand.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' +import { Grid, Dropdown } from '@stardust-ui/react' + +const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth'] + +const DropdownExamplePosition = () => ( + + + +) + +export default DropdownExamplePosition diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx new file mode 100644 index 0000000000..fa68a71939 --- /dev/null +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' +import { Grid, Dropdown } from '@stardust-ui/react' + +const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth'] + +const DropdownArrowExample = props => { + const { position, align } = props + + return ( + + ) +} + +const triggers = [ + { position: 'above', align: 'start' }, + { position: 'below', align: 'start' }, + { position: 'above', align: 'end' }, + { position: 'below', align: 'end' }, + { position: 'after', align: 'top' }, + { position: 'before', align: 'top' }, + { position: 'after', align: 'bottom' }, + { position: 'before', align: 'bottom' }, +] + +const DropdownExamplePosition = () => ( + + {triggers.map(({ position, align }) => ( + + ))} + +) + +export default DropdownExamplePosition diff --git a/docs/src/examples/components/Dropdown/Variations/index.tsx b/docs/src/examples/components/Dropdown/Variations/index.tsx index f9978ec1b5..52a97965ec 100644 --- a/docs/src/examples/components/Dropdown/Variations/index.tsx +++ b/docs/src/examples/components/Dropdown/Variations/index.tsx @@ -19,6 +19,16 @@ const Variations = () => ( description="A multiple search dropdown that uses French to provide information and accessibility." examplePath="components/Dropdown/Variations/DropdownExampleSearchMultipleFrenchLanguage" /> + + ) diff --git a/packages/react-component-ref/src/Ref.tsx b/packages/react-component-ref/src/Ref.tsx index ee2face475..27afbacfca 100644 --- a/packages/react-component-ref/src/Ref.tsx +++ b/packages/react-component-ref/src/Ref.tsx @@ -14,7 +14,7 @@ export interface RefProps { * * @param {HTMLElement} node - Referred node. */ - innerRef: React.Ref + innerRef: React.Ref } const Ref: React.FunctionComponent = props => { diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index 15f5bf9656..8d48c18974 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -32,7 +32,7 @@ import { UIComponentProps, isFromKeyboard, } from '../../lib' -import List from '../List/List' +import List, { ListProps } from '../List/List' import DropdownItem, { DropdownItemProps } from './DropdownItem' import DropdownSelectedItem, { DropdownSelectedItemProps } from './DropdownSelectedItem' import DropdownSearchInput, { DropdownSearchInputProps } from './DropdownSearchInput' @@ -41,6 +41,13 @@ import { screenReaderContainerStyles } from '../../lib/accessibility/Styles/acce import ListItem from '../List/ListItem' import Icon, { IconProps } from '../Icon/Icon' import Portal from '../Portal/Portal' +import { + ALIGNMENTS, + POSITIONS, + Positioner, + PositionCommonProps, + UpdatableComponent, +} from '../../lib/positioner' export interface DropdownSlotClassNames { clearIndicator: string @@ -54,7 +61,9 @@ export interface DropdownSlotClassNames { triggerButton: string } -export interface DropdownProps extends UIComponentProps { +export interface DropdownProps + extends UIComponentProps, + PositionCommonProps { /** The index of the currently active selected item, if dropdown has a multiple selection. */ activeSelectedIndex?: number @@ -238,6 +247,7 @@ class Dropdown extends AutoControlledComponent, Dropdo content: false, }), activeSelectedIndex: PropTypes.number, + align: PropTypes.oneOf(_.without(ALIGNMENTS)), clearable: PropTypes.bool, clearIndicator: customPropTypes.itemShorthand, defaultActiveSelectedIndex: PropTypes.number, @@ -266,6 +276,7 @@ class Dropdown extends AutoControlledComponent, Dropdo onSelectedChange: PropTypes.func, open: PropTypes.bool, placeholder: PropTypes.string, + position: PropTypes.oneOf(POSITIONS), renderItem: PropTypes.func, renderSelectedItem: PropTypes.func, search: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), @@ -280,6 +291,7 @@ class Dropdown extends AutoControlledComponent, Dropdo } static defaultProps = { + align: 'start', as: 'div', clearIndicator: 'stardust-close', itemToString: item => { @@ -290,6 +302,7 @@ class Dropdown extends AutoControlledComponent, Dropdo // targets DropdownItem shorthand objects return (item as any).header || String(item) }, + position: 'below', toggleIndicator: {}, triggerButton: {}, } @@ -472,7 +485,7 @@ class Dropdown extends AutoControlledComponent, Dropdo }, }), })} - {this.renderItemsList( + {this.preparePropsAndRenderItemsList( styles, variables, highlightedIndex, @@ -482,6 +495,7 @@ class Dropdown extends AutoControlledComponent, Dropdo getItemProps, getInputProps, value, + rtl, )} @@ -592,7 +606,7 @@ class Dropdown extends AutoControlledComponent, Dropdo }) } - private renderItemsList( + private preparePropsAndRenderItemsList( styles: ComponentSlotStylesInput, variables: ComponentVariablesInput, highlightedIndex: number, @@ -602,6 +616,7 @@ class Dropdown extends AutoControlledComponent, Dropdo getItemProps: (options: GetItemPropsOptions) => any, getInputProps: (options?: GetInputPropsOptions) => any, value: ShorthandValue | ShorthandCollection, + rtl: boolean, ) { const { search } = this.props const { open } = this.state @@ -634,22 +649,49 @@ class Dropdown extends AutoControlledComponent, Dropdo handleRef(innerRef, listElement) }} > - + {this.renderItemsList( + { + className: Dropdown.slotClassNames.itemsList, + ...accessibilityMenuProps, + styles: styles.list, + tabIndex: search ? undefined : -1, // needs to be focused when trigger button is activated. + 'aria-hidden': !open, + onFocus: this.handleTriggerButtonOrListFocus, + onBlur: this.handleListBlur, + items: open + ? this.renderItems(styles, variables, getItemProps, highlightedIndex, value) + : [], + }, + rtl, + )} ) } + private renderItemsList(listProps: ListProps, rtl: boolean): JSX.Element { + const { align, position, offset } = this.props + + return ( + ( + + )} + /> + ) + } + private renderItems( styles: ComponentSlotStylesInput, variables: ComponentVariablesInput, diff --git a/packages/react/src/components/Popup/Popup.tsx b/packages/react/src/components/Popup/Popup.tsx index c0e265a3fb..0722c77142 100644 --- a/packages/react/src/components/Popup/Popup.tsx +++ b/packages/react/src/components/Popup/Popup.tsx @@ -7,7 +7,7 @@ import * as ReactDOM from 'react-dom' import * as PropTypes from 'prop-types' import * as keyboardKey from 'keyboard-key' import * as _ from 'lodash' -import { Popper, PopperChildrenProps } from 'react-popper' +import { PopperChildrenProps } from 'react-popper' import { applyAccessibilityKeyHandlers, @@ -24,12 +24,8 @@ import { setWhatInputSource, } from '../../lib' import { ComponentEventHandler, ShorthandValue } from '../../types' - -import { getPopupPlacement, applyRtlToOffset, Alignment, Position } from './positioningHelper' -import createPopperReferenceProxy from './createPopperReferenceProxy' - +import { ALIGNMENTS, POSITIONS, Positioner, PositionCommonProps } from '../../lib/positioner' import PopupContent from './PopupContent' - import { popupBehavior } from '../../lib/accessibility' import { AutoFocusZone, @@ -44,9 +40,6 @@ import { AccessibilityBehavior, } from '../../lib/accessibility/types' -const POSITIONS: Position[] = ['above', 'below', 'before', 'after'] -const ALIGNMENTS: Alignment[] = ['top', 'bottom', 'start', 'end', 'center'] - export type PopupEvents = 'click' | 'hover' | 'focus' export type RestrictedClickEvents = 'click' | 'focus' export type RestrictedHoverEvents = 'hover' | 'focus' @@ -59,7 +52,8 @@ export interface PopupSlotClassNames { export interface PopupProps extends StyledComponentProps, ChildrenComponentProps, - ContentComponentProps { + ContentComponentProps, + PositionCommonProps { /** * Accessibility behavior if overridden by the user. * @default popupBehavior @@ -67,9 +61,6 @@ export interface PopupProps * */ accessibility?: Accessibility - /** Alignment for the popup. */ - align?: Alignment - /** Additional CSS class name(s) to apply. */ className?: string @@ -88,15 +79,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 +95,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 +115,6 @@ export interface PopupProps export interface PopupState { open: boolean - target: HTMLElement } /** @@ -409,27 +383,16 @@ export default class Popup extends AutoControlledComponent ) } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 666a82bc0f..e4fdc4e73a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -113,7 +113,7 @@ export { PopupEventsArray, } from './components/Popup/Popup' export { default as PopupContent, PopupContentProps } from './components/Popup/PopupContent' -export { Placement, Alignment, Position } from './components/Popup/positioningHelper' +export { Alignment, Position } from './lib/positioner' export { default as Portal, diff --git a/packages/react/src/lib/positioner/Positioner.tsx b/packages/react/src/lib/positioner/Positioner.tsx new file mode 100644 index 0000000000..f0577bf4e1 --- /dev/null +++ b/packages/react/src/lib/positioner/Positioner.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import { Popper, PopperChildrenProps, PopperProps } from 'react-popper' +import { Modifiers } from 'popper.js' + +import { Alignment, Position } from './index' +import { getPlacement, applyRtlToOffset } from './positioningHelper' +import createPopperReferenceProxy from './createPopperReferenceProxy' + +export interface PositionCommonProps { + /** 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 +} + +interface PositionerProps extends PopperProps, PositionCommonProps { + /** + * Content for children using render props API + */ + children: (props: PopperChildrenProps) => React.ReactNode + + /** + * rtl attribute for the component + */ + rtl?: boolean + + target?: HTMLElement | React.RefObject +} + +const Positioner: React.FunctionComponent = props => { + const { align, children, position, offset, rtl, target, ...rest } = props + // https://popper.js.org/popper-documentation.html#modifiers..offset + const popperModifiers: Modifiers = offset && { + offset: { offset: rtl ? applyRtlToOffset(offset, position) : offset }, + keepTogether: { enabled: false }, + } + + return ( + + ) +} + +export default Positioner diff --git a/packages/react/src/lib/positioner/UpdatableComponent.tsx b/packages/react/src/lib/positioner/UpdatableComponent.tsx new file mode 100644 index 0000000000..c53791c9f6 --- /dev/null +++ b/packages/react/src/lib/positioner/UpdatableComponent.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import { Ref } from '@stardust-ui/react-component-ref' + +import { Extendable } from '../../types' + +interface UpdatableListProps { + /** + * Component that will be rendered. + */ + Component: React.ComponentType + + /** + * Called when a child component will be mounted or updated. + * + * @param {HTMLElement} node - Referred node. + */ + innerRef?: React.Ref + + /** + * Function that will trigger the rerender. + */ + scheduleUpdate: Function + + /** + * Array of conditions to be met in order to trigger a subsequent render. + */ + updateDependencies: any[] +} + +const UpdatableComponent: React.FunctionComponent> = props => { + const { Component, innerRef, scheduleUpdate, updateDependencies, ...rest } = props + + React.useEffect(() => scheduleUpdate && scheduleUpdate(), updateDependencies) + + if (!innerRef) return + return ( + + + + ) +} + +export default UpdatableComponent diff --git a/packages/react/src/components/Popup/createPopperReferenceProxy.ts b/packages/react/src/lib/positioner/createPopperReferenceProxy.ts similarity index 92% rename from packages/react/src/components/Popup/createPopperReferenceProxy.ts rename to packages/react/src/lib/positioner/createPopperReferenceProxy.ts index 59bd7249c7..334d129673 100644 --- a/packages/react/src/components/Popup/createPopperReferenceProxy.ts +++ b/packages/react/src/lib/positioner/createPopperReferenceProxy.ts @@ -4,11 +4,7 @@ import * as React from 'react' import * as PopperJS from 'popper.js' class ReferenceProxy implements PopperJS.ReferenceObject { - ref: React.RefObject - - constructor(refObject) { - this.ref = refObject - } + constructor(private ref: React.RefObject) {} getBoundingClientRect() { return _.invoke(this.ref.current, 'getBoundingClientRect', {}) diff --git a/packages/react/src/lib/positioner/index.ts b/packages/react/src/lib/positioner/index.ts new file mode 100644 index 0000000000..a538bcac98 --- /dev/null +++ b/packages/react/src/lib/positioner/index.ts @@ -0,0 +1,7 @@ +export type Position = 'above' | 'below' | 'before' | 'after' +export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center' +export const POSITIONS: Position[] = ['above', 'below', 'before', 'after'] +export const ALIGNMENTS: Alignment[] = ['top', 'bottom', 'start', 'end', 'center'] + +export { default as Positioner, PositionCommonProps } from './Positioner' +export { default as UpdatableComponent } from './UpdatableComponent' diff --git a/packages/react/src/components/Popup/positioningHelper.ts b/packages/react/src/lib/positioner/positioningHelper.ts similarity index 93% rename from packages/react/src/components/Popup/positioningHelper.ts rename to packages/react/src/lib/positioner/positioningHelper.ts index 92e74d367a..8a32f642e6 100644 --- a/packages/react/src/components/Popup/positioningHelper.ts +++ b/packages/react/src/lib/positioner/positioningHelper.ts @@ -1,8 +1,6 @@ -export { Placement } from 'popper.js' import { Placement } from 'popper.js' -export type Position = 'above' | 'below' | 'before' | 'after' -export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center' +import { Alignment, Position } from './index' enum PlacementParts { top = 'top', @@ -56,7 +54,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, 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) + }) + }) +}) From e04a12466f470502013d53ab3dc87929f91ef9fc Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Thu, 9 May 2019 20:37:53 +0200 Subject: [PATCH 02/30] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f800aa84e..c36ce8a99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `mountNode` and `mountDocument` props to allow proper multi-window rendering @layershifter ([#1288](https://github.com/stardust-ui/react/pull/1288)) - Added default and brand color schemes in Teams' theme @mnajdova ([#1069](https://github.com/stardust-ui/react/pull/1069)) - Export `files-upload` SVG icon for `Teams` theme @manindr ([#1293](https://github.com/stardust-ui/react/pull/1293)) +- Add `align`, `position`, `offset` props for `Dropdown` component @Bugaa92 ([#1312](https://github.com/stardust-ui/react/pull/1312)) ## [v0.29.1](https://github.com/stardust-ui/react/tree/v0.29.1) (2019-05-01) From 9db8569eaad2fbaa7e5b399a479dc510619b5476 Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Fri, 10 May 2019 13:56:39 +0200 Subject: [PATCH 03/30] addresed part of PR comments --- packages/react-component-ref/src/Ref.tsx | 2 +- packages/react/src/components/Dropdown/Dropdown.tsx | 2 +- packages/react/src/lib/positioner/UpdatableComponent.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-component-ref/src/Ref.tsx b/packages/react-component-ref/src/Ref.tsx index 27afbacfca..ee2face475 100644 --- a/packages/react-component-ref/src/Ref.tsx +++ b/packages/react-component-ref/src/Ref.tsx @@ -14,7 +14,7 @@ export interface RefProps { * * @param {HTMLElement} node - Referred node. */ - innerRef: React.Ref + innerRef: React.Ref } const Ref: React.FunctionComponent = props => { diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index 8d48c18974..ca557d586c 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -247,7 +247,7 @@ class Dropdown extends AutoControlledComponent, Dropdo content: false, }), activeSelectedIndex: PropTypes.number, - align: PropTypes.oneOf(_.without(ALIGNMENTS)), + align: PropTypes.oneOf(ALIGNMENTS), clearable: PropTypes.bool, clearIndicator: customPropTypes.itemShorthand, defaultActiveSelectedIndex: PropTypes.number, diff --git a/packages/react/src/lib/positioner/UpdatableComponent.tsx b/packages/react/src/lib/positioner/UpdatableComponent.tsx index c53791c9f6..0d4180c5dc 100644 --- a/packages/react/src/lib/positioner/UpdatableComponent.tsx +++ b/packages/react/src/lib/positioner/UpdatableComponent.tsx @@ -30,7 +30,7 @@ interface UpdatableListProps { const UpdatableComponent: React.FunctionComponent> = props => { const { Component, innerRef, scheduleUpdate, updateDependencies, ...rest } = props - React.useEffect(() => scheduleUpdate && scheduleUpdate(), updateDependencies) + React.useEffect(() => scheduleUpdate(), updateDependencies) if (!innerRef) return return ( From 36c337a214bff3632294f583ddd00e18636821eb Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Fri, 10 May 2019 18:01:54 +0200 Subject: [PATCH 04/30] - fixed dropdown toggle indicator icon direction; - implemented knobs for dropdown and popup examples for position --- .../DropdownExamplePosition.shorthand.tsx | 68 +++++---- .../PopupExamplePosition.shorthand.tsx | 127 +++++++++++------ .../Popup/Variations/PopupExamplePosition.tsx | 130 +++++++++++------- .../src/knobs/useSelectKnob.ts | 6 +- .../src/components/Dropdown/Dropdown.tsx | 11 +- 5 files changed, 214 insertions(+), 128 deletions(-) diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx index fa68a71939..3fc4d19c0e 100644 --- a/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExamplePosition.shorthand.tsx @@ -1,38 +1,48 @@ import * as React from 'react' -import { Grid, Dropdown } from '@stardust-ui/react' +import { Dropdown, Grid, Alignment, Position } from '@stardust-ui/react' +import { useSelectKnob, useBooleanKnob } from '@stardust-ui/docs-components' -const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth'] +const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange'] -const DropdownArrowExample = props => { - const { position, align } = props +const DropdownExamplePosition = () => { + const [open] = useBooleanKnob({ name: 'dropdown open', initialValue: true }) + + const [position] = useSelectKnob({ + name: 'position', + initialValue: 'below', + values: ['above', 'below', 'before', 'after'], + }) + + const [positionBeforeOrAfter, setPositionBeforeOrAfter] = React.useState( + isPositionBeforeOrAfter(position), + ) + + const [align] = useSelectKnob({ + name: 'align N.A.', + ...(positionBeforeOrAfter && { + name: 'align', + initialValue: 'top', + values: ['top', 'bottom'], + }), + }) + + React.useEffect(() => setPositionBeforeOrAfter(isPositionBeforeOrAfter(position)), [position]) return ( - + + + ) } -const triggers = [ - { position: 'above', align: 'start' }, - { position: 'below', align: 'start' }, - { position: 'above', align: 'end' }, - { position: 'below', align: 'end' }, - { position: 'after', align: 'top' }, - { position: 'before', align: 'top' }, - { position: 'after', align: 'bottom' }, - { position: 'before', align: 'bottom' }, -] - -const DropdownExamplePosition = () => ( - - {triggers.map(({ position, align }) => ( - - ))} - -) - export default DropdownExamplePosition + +const isPositionBeforeOrAfter = (position: Position) => + position === 'before' || position === 'after' diff --git a/docs/src/examples/components/Popup/Variations/PopupExamplePosition.shorthand.tsx b/docs/src/examples/components/Popup/Variations/PopupExamplePosition.shorthand.tsx index 29c10911e8..cceec68db8 100644 --- a/docs/src/examples/components/Popup/Variations/PopupExamplePosition.shorthand.tsx +++ b/docs/src/examples/components/Popup/Variations/PopupExamplePosition.shorthand.tsx @@ -1,54 +1,91 @@ import * as React from 'react' -import { Button, Grid, Popup } from '@stardust-ui/react' +import { Button, Grid, Popup, Alignment, Position } from '@stardust-ui/react' +import { useBooleanKnob, useSelectKnob } from '@stardust-ui/docs-components' -const PopupWithButton = props => { - const { position, align, icon, padding } = props +const PopupExamplePosition = () => { + const [open] = useBooleanKnob({ name: 'open shorthand', initialValue: true }) - return ( - } - content={{ - content: ( -

- The popup is rendered {position} the trigger -
- aligned to the {align}. -

- ), - }} - /> + const [position] = useSelectKnob({ + name: 'position shorthand', + initialValue: 'above', + values: ['above', 'below', 'before', 'after'], + }) + + const [positionBeforeOrAfter, setPositionBeforeOrAfter] = React.useState( + isPositionBeforeOrAfter(position), ) -} -const triggers = [ - { position: 'above', align: 'start', icon: 'arrow circle up', padding: '5px 42px 18px 5px' }, - { position: 'above', align: 'center', icon: 'arrow circle up', padding: '5px 5px 18px 5px' }, - { position: 'above', align: 'end', icon: 'arrow circle up', padding: '5px 5px 18px 42px' }, - { position: 'below', align: 'start', icon: 'arrow circle down', padding: '18px 42px 5px 5px' }, - { position: 'below', align: 'center', icon: 'arrow circle down', padding: '18px 5px 5px 5px' }, - { position: 'below', align: 'end', icon: 'arrow circle down', padding: '18px 5px 5px 42px' }, - { position: 'before', align: 'top', icon: 'arrow circle left', padding: '5px 42px 18px 5px' }, - { position: 'before', align: 'center', icon: 'arrow circle left', padding: '5px 42px 5px 5px' }, - { position: 'before', align: 'bottom', icon: 'arrow circle left', padding: '18px 42px 5px 5px' }, - { position: 'after', align: 'top', icon: 'arrow circle right', padding: '5px 5px 18px 42px' }, - { position: 'after', align: 'center', icon: 'arrow circle right', padding: '5px 5px 5px 42px' }, - { position: 'after', align: 'bottom', icon: 'arrow circle right', padding: '18px 5px 5px 42px' }, -] - -const PopupExamplePosition = () => ( - - {triggers.map(({ position, align, icon, padding }) => ( - ( + positionBeforeOrAfter + ? { + name: 'h-align shorthand', + initialValue: 'top', + values: ['top', 'center', 'bottom'], + } + : { + name: 'v-align shorthand', + initialValue: 'start', + values: ['start', 'center', 'end'], + }, + ) + + React.useEffect(() => setPositionBeforeOrAfter(isPositionBeforeOrAfter(position)), [position]) + + const buttonStyles = { padding: paddings[position][align], height: '38px', minWidth: '64px' } + + return ( + + } + content={{ + content: ( +

+ The popup is rendered {position} the trigger +
+ aligned to the {align}. +

+ ), + }} /> - ))} -
-) +
+ ) +} export default PopupExamplePosition + +const icons: { [key in Position]: string } = { + above: 'arrow circle up', + below: 'arrow circle down', + before: 'arrow circle left', + after: 'arrow circle right', +} + +const paddings: { [key in Position]: { [key in Alignment]?: React.CSSProperties['padding'] } } = { + above: { + start: '5px 42px 18px 5px', + center: '5px 5px 18px 5px', + end: '5px 5px 18px 42px', + }, + below: { + start: '18px 42px 5px 5px', + center: '18px 5px 5px 5px', + end: '18px 5px 5px 42px', + }, + before: { + top: '5px 42px 18px 5px', + center: '5px 42px 5px 5px', + bottom: '18px 42px 5px 5px', + }, + after: { + top: '5px 5px 18px 42px', + center: '5px 5px 5px 42px', + bottom: '18px 5px 5px 42px', + }, +} + +const isPositionBeforeOrAfter = (position: Position) => + position === 'before' || position === 'after' diff --git a/docs/src/examples/components/Popup/Variations/PopupExamplePosition.tsx b/docs/src/examples/components/Popup/Variations/PopupExamplePosition.tsx index bbce0f5ee8..cac546979f 100644 --- a/docs/src/examples/components/Popup/Variations/PopupExamplePosition.tsx +++ b/docs/src/examples/components/Popup/Variations/PopupExamplePosition.tsx @@ -1,57 +1,91 @@ import * as React from 'react' -import { Button, Grid, Popup } from '@stardust-ui/react' +import { Button, Grid, Popup, Alignment, Position } from '@stardust-ui/react' +import { useBooleanKnob, useSelectKnob } from '@stardust-ui/docs-components' -const PopupArrowExample = props => { - const { position, align, icon, padding } = props +const PopupExamplePosition = () => { + const [open] = useBooleanKnob({ name: 'open', initialValue: true }) - const buttonStyles = { padding, height: '38px', minWidth: '64px' } + const [position] = useSelectKnob({ + name: 'position', + initialValue: 'above', + values: ['above', 'below', 'before', 'after'], + }) - return ( - - The popup is rendered {position} the trigger -
- aligned to the {align}. -

- ), - }} - > -