diff --git a/packages/components/forma-36-react-timepicker/package.json b/packages/components/forma-36-react-timepicker/package.json index 9c183f443d..df7fee8302 100644 --- a/packages/components/forma-36-react-timepicker/package.json +++ b/packages/components/forma-36-react-timepicker/package.json @@ -40,8 +40,8 @@ "@types/react": "^16.9.11", "@types/react-dom": "^16.9.3", "husky": "^3.0.9", - "react": "16.10.2", - "react-dom": "16.10.2", + "react": "^16.8.3", + "react-dom": "^16.8.3", "tsdx": "^0.11.0", "tslib": "^1.10.0", "typescript": "^3.6.4" diff --git a/packages/forma-36-react-components/src/components/Card/AssetCard/__snapshots__/AssetCard.test.tsx.snap b/packages/forma-36-react-components/src/components/Card/AssetCard/__snapshots__/AssetCard.test.tsx.snap index 4122d728fc..68ccc6841c 100644 --- a/packages/forma-36-react-components/src/components/Card/AssetCard/__snapshots__/AssetCard.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Card/AssetCard/__snapshots__/AssetCard.test.tsx.snap @@ -169,15 +169,15 @@ exports[`renders the component with actions 1`] = ` - Actions - - + Edit - + diff --git a/packages/forma-36-react-components/src/components/Card/CardActions/CardActions.tsx b/packages/forma-36-react-components/src/components/Card/CardActions/CardActions.tsx index b96363556b..45d48853b9 100644 --- a/packages/forma-36-react-components/src/components/Card/CardActions/CardActions.tsx +++ b/packages/forma-36-react-components/src/components/Card/CardActions/CardActions.tsx @@ -20,8 +20,8 @@ export interface CardActionsPropTypes { * The DropdownList elements used to render an actions dropdown for the component */ children: - | React.ReactElement - | React.ReactElement[]; + | React.ReactElement + | React.ReactElement[]; /** * An ID used for testing purposes applied as a data attribute (data-test-id) */ @@ -73,7 +73,6 @@ export class CardActions extends Component< }); }} position="bottom-right" - isAutoalignmentEnabled={false} className={className} isOpen={this.state.isDropdownOpen} testId={testId} diff --git a/packages/forma-36-react-components/src/components/Card/CardActions/__snapshots__/CardActions.test.tsx.snap b/packages/forma-36-react-components/src/components/Card/CardActions/__snapshots__/CardActions.test.tsx.snap index 6ed1cb705e..58b18ffd79 100644 --- a/packages/forma-36-react-components/src/components/Card/CardActions/__snapshots__/CardActions.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Card/CardActions/__snapshots__/CardActions.test.tsx.snap @@ -3,7 +3,6 @@ exports[`renders the component as disabled 1`] = ` - Edit - + `; @@ -45,7 +44,6 @@ exports[`renders the component as disabled 1`] = ` exports[`renders the component using a multiple dropdown lists 1`] = ` - Edit - - + Download - - + Remove - + - Edit - - + Download - - + Remove - + `; @@ -138,7 +136,6 @@ exports[`renders the component using a multiple dropdown lists 1`] = ` exports[`renders the component using a single dropdown list 1`] = ` - Edit - - + Download - - + Remove - + `; @@ -199,7 +196,6 @@ exports[`renders the component with an additional class name 1`] = ` - Edit - - + Download - - + Remove - + `; @@ -259,7 +255,6 @@ exports[`renders the component with an additional class name 1`] = ` exports[`renders the component with published status 1`] = ` - Edit - - + Download - - + Remove - + `; diff --git a/packages/forma-36-react-components/src/components/Card/EntryCard/__snapshots__/EntryCard.test.tsx.snap b/packages/forma-36-react-components/src/components/Card/EntryCard/__snapshots__/EntryCard.test.tsx.snap index 406cb1c588..8854ff6b90 100644 --- a/packages/forma-36-react-components/src/components/Card/EntryCard/__snapshots__/EntryCard.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Card/EntryCard/__snapshots__/EntryCard.test.tsx.snap @@ -353,15 +353,15 @@ exports[`renders the component with dropdownListElements 1`] = ` - Actions - - + Edit - - + Download - - + Remove - + diff --git a/packages/forma-36-react-components/src/components/Card/InlineEntryCard/__snapshots__/InlineEntryCard.test.tsx.snap b/packages/forma-36-react-components/src/components/Card/InlineEntryCard/__snapshots__/InlineEntryCard.test.tsx.snap index 66cac00361..08d0764728 100644 --- a/packages/forma-36-react-components/src/components/Card/InlineEntryCard/__snapshots__/InlineEntryCard.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Card/InlineEntryCard/__snapshots__/InlineEntryCard.test.tsx.snap @@ -86,7 +86,7 @@ exports[`renders the component with a dropdown 1`] = ` - Edit - - + Remove - + diff --git a/packages/forma-36-react-components/src/components/Dropdown/Dropdown.stories.tsx b/packages/forma-36-react-components/src/components/Dropdown/Dropdown.stories.tsx index dccc60bb88..1b8344091a 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/Dropdown.stories.tsx +++ b/packages/forma-36-react-components/src/components/Dropdown/Dropdown.stories.tsx @@ -18,7 +18,6 @@ function DefaultStory() { onClose={() => setOpen(false)} isFullWidth={boolean('isFullWidth', false)} key={Date.now()} // Force Reinit - isAutoalignmentEnabled={boolean('isAutoalignmentEnabled', true)} position={select( 'position', { @@ -140,7 +139,6 @@ function DynamicContentStory() { = { + name: 'sameWidth', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: ({ state }: { state: PopperState }) => { + state.styles.popper.width = `${state.rects.reference.width}px`; + }, + effect: ({ state }: { state: PopperState }) => { + // Not sure why we need to cast this 🤔 + const reference = state.elements.reference as HTMLElement; + state.elements.popper.style.width = `${reference.offsetWidth}px`; + + return () => {}; + }, +}; + export type positionType = | 'top' | 'right' @@ -14,231 +36,215 @@ export type positionType = | 'top-right' | 'top-left'; -export interface DropdownProps { - toggleElement?: React.ReactElement; - submenuToggleLabel?: string; - position: positionType; - isOpen: boolean; - onClose?: Function; - testId?: string; - dropdownContainerClassName?: string; - getContainerRef?: (ref: HTMLElement | null) => void; - className?: string; - children: React.ReactNode; - isFullWidth?: boolean; - isAutoalignmentEnabled?: boolean; -} +/** + * Helper method to map our current Dropdown position props to Popper.js + * placements. + * + * @todo: Maybe we can use the Popper placements in the next breaking change? + */ +const mapPositionTypeToPlacement = (position: positionType): Placement => { + switch (position) { + case 'bottom-left': + return 'bottom-start'; -export interface AnchorDimensionsAndPositonType { - top: number; - left: number; - width: number; - height: number; -} + case 'bottom-right': + return 'bottom-end'; -export interface DropdownState { - isOpen: boolean; - position: positionType; - anchorDimensionsAndPositon?: AnchorDimensionsAndPositonType; -} + case 'right': + return 'right-start'; -const defaultProps: Partial = { - testId: 'cf-ui-dropdown', - position: 'bottom-left', - isOpen: false, - isAutoalignmentEnabled: true, - getContainerRef: () => {}, -}; + case 'left': + return 'left-start'; -export class Dropdown extends Component { - static defaultProps = defaultProps; - toggleElementWrapper: HTMLSpanElement | null = null; - - state = { - isOpen: this.props.isOpen, - position: this.props.position, - anchorDimensionsAndPositon: { - top: 0, - left: 0, - height: 0, - width: 0, - }, - }; + case 'top-left': + return 'top-start'; - dropdownAnchor: HTMLDivElement | null = null; + case 'top-right': + return 'top-end'; - componentDidMount() { - if (!isBrowser) { - return; - } - this.setAnchorDimensions(); - this.bindEventListeners(); + default: + return position; } +}; - setAnchorDimensions = () => { - if (this.dropdownAnchor) { - const dropdownAnchorRect = this.dropdownAnchor.getBoundingClientRect(); - this.setState({ - anchorDimensionsAndPositon: { - top: dropdownAnchorRect.top, - left: dropdownAnchorRect.left, - width: dropdownAnchorRect.width, - height: dropdownAnchorRect.height, - }, - }); - } - }; - - UNSAFE_componentWillReceiveProps(newProps: DropdownProps) { - this.setState({ - isOpen: newProps.isOpen, - }); - } +export interface DropdownProps { + /** + * Child nodes to be rendered in the component + */ + children: React.ReactNode; + className?: string; + dropdownContainerClassName?: string; + getContainerRef?: (ref: HTMLElement | null) => void; - componentDidUpdate(prevProps: DropdownProps) { - if (!isBrowser) { - return; - } + isAutoalignmentEnabled?: boolean; + /** + * Boolean to determine if the Dropdown should take the full width of + * the container + */ + isFullWidth?: boolean; + isOpen: boolean; + onClose?: Function; + /** + * Determines the preferred position of the Dropdown. This position is not + * guaranteed, as the Dropdown might be moved to fit the viewport + */ + position: positionType; + /** + * A text label to use as the toggle element for the submenu + */ + submenuToggleLabel?: string; + /** + * An ID used for testing purposes applied as a data attribute (data-test-id) + */ + testId?: string; + toggleElement?: React.ReactElement; +} - if (prevProps.isOpen !== this.props.isOpen) { - this.setAnchorDimensions(); - } +export function Dropdown({ + children, + className, + dropdownContainerClassName, + getContainerRef, + isAutoalignmentEnabled, + isFullWidth, + isOpen: isOpenProp, + onClose, + position, + submenuToggleLabel, + testId, + toggleElement, + ...otherProps +}: DropdownProps) { + const [referenceElement, setReferenceElement] = useState( + null, + ); + const [popperElement, setPopperElement] = useState(null); + const [isOpen, setIsOpen] = useState(isOpenProp); + const placement = mapPositionTypeToPlacement(position); + const { attributes, forceUpdate, styles: popperStyles } = usePopper( + referenceElement, + popperElement, + { + placement, + modifiers: isFullWidth ? [sameWidth] : undefined, + }, + ); + const classNames = cn(styles['Dropdown'], className); + const containerTestId = testId ? `${testId}-container` : testId; - this.bindEventListeners(); - } + useEffect(() => { + setIsOpen(isOpenProp); + }, [isOpenProp]); - bindEventListeners = () => { - if (this.state.isOpen) { - document.addEventListener('keydown', this.handleEscapeKey, true); - window.addEventListener('resize', this.setAnchorDimensions, true); - document.addEventListener('scroll', this.setAnchorDimensions, true); - } else { - document.removeEventListener('keydown', this.handleEscapeKey, true); - window.removeEventListener('resize', this.setAnchorDimensions, true); - document.removeEventListener('scroll', this.setAnchorDimensions, true); + useEffect(() => { + if (forceUpdate) { + forceUpdate(); } - }; + }, [children]); - componentWillUnmount() { - if (!isBrowser) { - return; + const openSubmenu = (isOpen: boolean) => { + if (submenuToggleLabel) { + setIsOpen(isOpen); } - - document.removeEventListener('keydown', this.handleEscapeKey, true); - window.removeEventListener('resize', this.setAnchorDimensions, true); - document.removeEventListener('scroll', this.setAnchorDimensions, true); - } - - openMenu = (isOpen: boolean) => { - this.setState({ isOpen }); }; - handleEscapeKey = (event: KeyboardEvent) => { - const ESCAPE_KEYCODE = 27; - - if (event.keyCode === ESCAPE_KEYCODE) { - event.stopPropagation(); + const close = useCallback(() => { + setIsOpen(false); - this.setState({ - isOpen: false, - }); - - if (this.props.onClose) { - this.props.onClose(); - } + if (onClose) { + onClose(); } - }; + }, [onClose, setIsOpen]); - openSubmenu = (isOpen: boolean) => { - if (this.props.submenuToggleLabel) { - this.openMenu(isOpen); - } - }; + const handleEscapeKey = useCallback( + (event: KeyboardEvent) => { + if (event.code === 'Escape') { + event.stopPropagation(); - render() { - const { - className, - toggleElement, - testId, - submenuToggleLabel, - getContainerRef, - dropdownContainerClassName, - children, - isOpen, - isAutoalignmentEnabled, - isFullWidth, - ...otherProps - } = this.props; - - const classNames = cn(styles['Dropdown'], className); - const width = isFullWidth && this.state.anchorDimensionsAndPositon.width; - const containerTestId = testId ? `${testId}-container` : testId; - - return submenuToggleLabel ? ( - this.openMenu(true)} - onLeave={() => this.openMenu(false)} - {...otherProps} - > - {toggleElement && - React.cloneElement(toggleElement, { - 'aria-haspopup': 'menu', - 'aria-expanded': this.state.isOpen, - })} - {this.state.isOpen && ( - - {this.props.children} - - )} - - ) : ( -
{ - if (!submenuToggleLabel) { - this.dropdownAnchor = ref; - } - }} - {...otherProps} - > - {toggleElement && - React.cloneElement(toggleElement, { - 'aria-haspopup': 'menu', - 'aria-expanded': this.state.isOpen, - })} - {this.state.isOpen && ( - - {this.props.children} - - )} -
- ); - } + close(); + } + }, + [close], + ); + + useEffect(() => { + document.addEventListener('keydown', handleEscapeKey, true); + + return () => { + document.removeEventListener('keydown', handleEscapeKey, true); + }; + }, []); + + return submenuToggleLabel ? ( + setIsOpen(true)} + onLeave={() => setIsOpen(false)} + ref={setReferenceElement} + {...otherProps} + > + {toggleElement && + React.cloneElement(toggleElement, { + 'aria-haspopup': 'menu', + 'aria-expanded': isOpen, + })} + {isOpen && ( + + {children} + + )} + + ) : ( +
+ {toggleElement && + React.cloneElement(toggleElement, { + 'aria-haspopup': 'menu', + 'aria-expanded': isOpen, + })} + + {isOpen && ( + + {children} + + )} +
+ ); } +Dropdown.defaultProps = { + testId: 'cf-ui-dropdown', + position: 'bottom-left', + isOpen: false, + getContainerRef: () => {}, +}; + export default Dropdown; diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.css b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.css index c7d158924b..c27a2e977d 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.css +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.css @@ -13,26 +13,10 @@ list-style: none; padding: 0; margin: 0; - position: fixed; + z-index: var(--z-index-dropdown); } .DropdownContainer > div { width: 100%; } - -.DropdownContainer__submenu { - position: absolute; -} - -.DropdownContainer__container-position--right { - margin-top: 0; - top: 0; - left: 100%; -} - -.DropdownContainer__container-position--left { - margin-top: 0; - top: 0; - right: 100%; -} diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.test.tsx b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.test.tsx index 7e7267d875..ec1958a0a1 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.test.tsx +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.test.tsx @@ -4,21 +4,14 @@ import DropdownContainer from './DropdownContainer'; it('renders the component', () => { const output = shallow( - - DropdownContainer - , + DropdownContainer, ); expect(output).toMatchSnapshot(); }); it('renders the component with an additional class name', () => { const output = shallow( - + DropdownContainer , ); @@ -27,11 +20,7 @@ it('renders the component with an additional class name', () => { it('renders the component as a submenu', () => { const output = shallow( - + DropdownContainer , ); @@ -40,11 +29,7 @@ it('renders the component as a submenu', () => { it('has no a11y issues', async () => { const output = shallow( - - DropdownContainer - , + DropdownContainer, ); expect(output).toMatchSnapshot(); }); diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.tsx b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.tsx index ce375f96a9..ba73ff0848 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.tsx +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/DropdownContainer.tsx @@ -1,252 +1,130 @@ -import React, { Component } from 'react'; +import React, { forwardRef, useCallback, useEffect, useRef } from 'react'; import cn from 'classnames'; import ReactDOM from 'react-dom'; -import InViewport from '../../InViewport'; -import { positionType, AnchorDimensionsAndPositonType } from '../Dropdown'; +import { positionType } from '../Dropdown'; import styles from './DropdownContainer.css'; -export interface DropdownContainerProps { - onClose?: Function; - dropdownAnchor?: HTMLElement | null; - className?: string; +export interface DropdownContainerProps + extends React.HTMLAttributes { children?: React.ReactNode; - testId?: string; - openSubmenu?: (value: boolean) => void; - anchorDimensionsAndPositon?: AnchorDimensionsAndPositonType; - position: positionType; + className?: string; getRef?: (ref: HTMLElement | null) => void; + isOpen: boolean; + onClose?: Function; + openSubmenu?: (value: boolean) => void; + position?: positionType; submenu?: boolean; - width?: number | false; - isAutoalignmentEnabled?: boolean; -} - -export interface DropdownState { - dropdownDimensions: { - width: number; - height: number; - }; - position: positionType; + testId?: string; } -const defaultProps: Partial = { - testId: 'cf-ui-dropdown-portal', - position: 'bottom-left', - submenu: false, - isAutoalignmentEnabled: true, -}; - -class DropdownContainer extends Component< - DropdownContainerProps, - DropdownState -> { - static defaultProps = defaultProps; - - portalTarget = document.createElement('div'); - dropdown: HTMLElement | null = null; - lastOverflowAt: string | null = null; - - state = { - position: this.props.position, - dropdownDimensions: { - width: 0, - height: 0, +export const DropdownContainer = forwardRef< + HTMLElement, + DropdownContainerProps +>( + ( + { + children, + className, + getRef, + isOpen, + onClose, + openSubmenu, + position, + style, + submenu, + testId, + ...props }, - }; - - componentDidMount() { - document.body.appendChild(this.portalTarget); - if (this.dropdown) { - const dropdownRect = this.dropdown.getBoundingClientRect(); - this.setState({ - dropdownDimensions: { - width: dropdownRect.width, - height: dropdownRect.height, - }, - }); - } - document.addEventListener('mousedown', this.trackOutsideClick, true); - this.props.getRef?.(this.dropdown); - } - - componentDidUpdate( - prevProps: DropdownContainerProps, - prevState: DropdownState, - ) { - if (!this.dropdown) { - return; - } - - const dropdownRect = this.dropdown.getBoundingClientRect(); - - if ( - dropdownRect.width !== prevState.dropdownDimensions.width || - dropdownRect.height !== prevState.dropdownDimensions.height - ) { - this.setState({ - dropdownDimensions: { - width: dropdownRect.width, - height: dropdownRect.height, - }, - }); - } - } - - componentWillUnmount() { - document.body.removeChild(this.portalTarget); - document.removeEventListener('mousedown', this.trackOutsideClick, true); - } - - trackOutsideClick = (e: MouseEvent) => { - if ( - this.dropdown && - !this.dropdown.contains(e.target as Node) && - this.props.dropdownAnchor && - !this.props.dropdownAnchor.contains(e.target as Node) - ) { - if (this.props.onClose) { - this.props.onClose(); - } - } - }; - - handleOverflow = (overflowAt: string) => { - if (!this.props.isAutoalignmentEnabled) { - return; - } - if (overflowAt === this.lastOverflowAt) { - return; - } - - const resolutions = { - right: { - 'bottom-left': 'bottom-right', - 'top-left': 'top-right', - right: 'left', - }, - left: { - 'bottom-right': 'bottom-left', - 'top-right': 'top-left', - left: 'right', - }, - top: { - 'top-left': 'bottom-left', - 'top-right': 'bottom-right', + refCallback, + ) => { + // We're not dealing with React RefObjects but with useState (because we + // want to re-render on all changes) + const setReference = refCallback as React.Dispatch< + React.SetStateAction + >; + const dropdown = useRef(null); + const portalTarget = useRef(document.createElement('div')); + const classNames = cn(className, styles['DropdownContainer']); + + const trackOutsideClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + + if ( + onClose && + dropdown.current && + !dropdown.current.contains(event.target as Node) + ) { + onClose(); + } }, - bottom: { - 'bottom-left': 'top-left', - 'bottom-right': 'top-right', - }, - }; - const currentPosition = this.state.position; - const resolution = resolutions[overflowAt][currentPosition]; - if (resolution) { - this.setState( - { - position: resolution, - }, - () => { - this.lastOverflowAt = overflowAt; - }, - ); - } - }; - - calculatePosition = () => { - const { anchorDimensionsAndPositon } = this.props; - const { dropdownDimensions, position } = this.state; - - if (!anchorDimensionsAndPositon || !dropdownDimensions) { - return false; - } - - switch (position) { - case 'bottom-left': - return { - top: - anchorDimensionsAndPositon.top + anchorDimensionsAndPositon.height, - left: anchorDimensionsAndPositon.left, - }; - case 'top-left': - return { - bottom: window.innerHeight - anchorDimensionsAndPositon.top, - left: anchorDimensionsAndPositon.left, - }; - case 'bottom-right': - return { - top: - anchorDimensionsAndPositon.top + anchorDimensionsAndPositon.height, - left: - anchorDimensionsAndPositon.left - - (dropdownDimensions.width - anchorDimensionsAndPositon.width), - }; - case 'top-right': - return { - bottom: window.innerHeight - anchorDimensionsAndPositon.top, - left: - anchorDimensionsAndPositon.left - - (dropdownDimensions.width - anchorDimensionsAndPositon.width), - }; - } - }; - - getSubmenuClassNames = () => - cn( - styles['DropdownContainer__submenu'], - styles[`DropdownContainer__container-position--${this.state.position}`], + [onClose, dropdown.current], ); - render() { - const { submenu, className, width, testId } = this.props; + useEffect(() => { + if (isOpen) { + document.body.appendChild(portalTarget.current); + document.addEventListener('mousedown', trackOutsideClick, true); + } else { + document.body.removeChild(portalTarget.current); + document.removeEventListener('mousedown', trackOutsideClick, true); + } + + return () => { + document.body.removeChild(portalTarget.current); + document.removeEventListener('mousedown', trackOutsideClick, true); + }; + }, [isOpen]); - const classNames = cn( - className, - styles['DropdownContainer'], - submenu ? this.getSubmenuClassNames() : '', - ); + useEffect(() => { + if (getRef && dropdown.current) { + getRef(dropdown.current); + } + }, [dropdown.current]); - const dropdown = ( + const dropdownComponent = (
{ - this.dropdown = ref; - }} - data-test-id={testId} - style={{ - ...(width ? { width: `${width}px` } : {}), - ...(!submenu && this.calculatePosition()), - }} + {...props} className={classNames} + data-test-id={testId} onMouseEnter={() => { - if (this.props.openSubmenu) { - this.props.openSubmenu(true); + if (openSubmenu) { + openSubmenu(true); } }} onFocus={() => { - if (this.props.openSubmenu) { - this.props.openSubmenu(true); + if (openSubmenu) { + openSubmenu(true); } }} onMouseLeave={() => { - if (this.props.openSubmenu) { - this.props.openSubmenu(false); + if (openSubmenu) { + openSubmenu(false); } }} + ref={(node) => { + setReference(node); + dropdown.current = node; + }} + style={style} > - this.handleOverflow('left')} - onOverflowRight={() => this.handleOverflow('right')} - onOverflowTop={() => this.handleOverflow('top')} - onOverflowBottom={() => this.handleOverflow('bottom')} - > - {this.props.children} - + {children}
); return submenu - ? dropdown - : ReactDOM.createPortal(dropdown, this.portalTarget); - } -} + ? dropdownComponent + : ReactDOM.createPortal(dropdownComponent, portalTarget.current); + }, +); + +DropdownContainer.displayName = 'DropdownContainer'; + +DropdownContainer.defaultProps = { + testId: 'cf-ui-dropdown-portal', + position: 'bottom-left' as positionType, + submenu: false, +}; export default DropdownContainer; diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/__snapshots__/DropdownContainer.test.tsx.snap b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/__snapshots__/DropdownContainer.test.tsx.snap index a2fd56bc72..b7c042d83f 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/__snapshots__/DropdownContainer.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownContainer/__snapshots__/DropdownContainer.test.tsx.snap @@ -10,23 +10,8 @@ exports[`has no a11y issues 1`] = ` onFocus={[Function]} onMouseEnter={[Function]} onMouseLeave={[Function]} - style={ - Object { - "left": 0, - "top": 0, - } - } > - - DropdownContainer - + DropdownContainer `; @@ -41,46 +26,21 @@ exports[`renders the component 1`] = ` onFocus={[Function]} onMouseEnter={[Function]} onMouseLeave={[Function]} - style={ - Object { - "left": 0, - "top": 0, - } - } > - - DropdownContainer - + DropdownContainer `; exports[`renders the component as a submenu 1`] = `
- - DropdownContainer - + DropdownContainer
`; @@ -94,23 +54,8 @@ exports[`renders the component with an additional class name 1`] = ` onFocus={[Function]} onMouseEnter={[Function]} onMouseLeave={[Function]} - style={ - Object { - "left": 0, - "top": 0, - } - } > - - DropdownContainer - + DropdownContainer `; diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownList/DropdownList.tsx b/packages/forma-36-react-components/src/components/Dropdown/DropdownList/DropdownList.tsx index 25ad7bee4a..3207b7cd90 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownList/DropdownList.tsx +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownList/DropdownList.tsx @@ -1,8 +1,9 @@ -import React, { Component } from 'react'; +import React from 'react'; import cn from 'classnames'; -import cssStyles from './DropdownList.css'; import * as CSS from 'csstype'; +import cssStyles from './DropdownList.css'; + export interface DropdownListProps { children: React.ReactNode; listRef?: React.RefObject; @@ -13,46 +14,40 @@ export interface DropdownListProps { styles?: object; } -const defaultProps: Partial = { - testId: 'cf-ui-dropdown-list', -}; - -export class DropdownList extends Component { - static defaultProps = defaultProps; +export const DropdownList = ({ + className, + border, + maxHeight, + testId, + children, + listRef, + styles, + ...otherProps +}: DropdownListProps) => { + const classNames = cn(cssStyles['DropdownList'], className, { + [cssStyles[`DropdownList--border-${border}`]]: border, + }); - render() { - const { - className, - border, - maxHeight, - testId, - children, - listRef, - styles, - ...otherProps - } = this.props; - - const classNames = cn(cssStyles['DropdownList'], className, { - [cssStyles[`DropdownList--border-${border}`]]: border, - }); + return ( +
    + {children} +
+ ); +}; - return ( -
    - {children} -
- ); - } -} +DropdownList.defaultProps = { + testId: 'cf-ui-dropdown-list', +}; export default DropdownList; diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownList/__snapshots__/DropdownList.test.tsx.snap b/packages/forma-36-react-components/src/components/Dropdown/DropdownList/__snapshots__/DropdownList.test.tsx.snap index 46140e49a4..f9f3e222a6 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownList/__snapshots__/DropdownList.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownList/__snapshots__/DropdownList.test.tsx.snap @@ -12,14 +12,14 @@ exports[`renders the component 1`] = ` } } > - List Item - + `; @@ -35,14 +35,14 @@ exports[`renders the component with a border attribute 1`] = ` } } > - List Item - + `; @@ -58,13 +58,13 @@ exports[`renders the component with an additional class name 1`] = ` } } > - List Item - + `; diff --git a/packages/forma-36-react-components/src/components/Dropdown/DropdownListItem/DropdownListItem.tsx b/packages/forma-36-react-components/src/components/Dropdown/DropdownListItem/DropdownListItem.tsx index 6470eb29b1..59175967b8 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/DropdownListItem/DropdownListItem.tsx +++ b/packages/forma-36-react-components/src/components/Dropdown/DropdownListItem/DropdownListItem.tsx @@ -1,16 +1,19 @@ import React, { - Component, + forwardRef, MouseEventHandler, FocusEventHandler, MouseEvent as ReactMouseEvent, + useCallback, } from 'react'; import cn from 'classnames'; + import TabFocusTrap from '../../TabFocusTrap/TabFocusTrap'; import styles from './DropdownListItem.css'; -export interface DropdownListItemProps { +export interface DropdownListItemProps + extends React.HTMLAttributes { isDisabled?: boolean; - listItemRef?: React.RefObject; + listItemRef?: React.MutableRefObject; isActive?: boolean; isTitle?: boolean; children: React.ReactNode; @@ -25,126 +28,30 @@ export interface DropdownListItemProps { testId?: string; } -const defaultProps: Partial = { - testId: 'cf-ui-dropdown-list-item', - isDisabled: false, - isActive: false, - isTitle: false, -}; - -export class DropdownListItem extends Component { - static defaultProps = defaultProps; - - renderSubmenuToggle = () => { - const { - onClick, - onEnter, - onLeave, - onFocus, +export const DropdownListItem = forwardRef( + ( + { children, - submenuToggleLabel, - testId, - isDisabled, isActive, - isTitle, - ...otherProps - } = this.props; - - return ( - - - {children} - - ); - }; - - renderListItem = () => { - const { - onClick, - onMouseDown, - href, isDisabled, - children, isTitle, - isActive, - testId, - listItemRef, - ...otherProps - } = this.props; - - const isClickable = onClick || onMouseDown || href; - - if (isClickable) { - const Element = href ? 'a' : 'button'; - - const buttonProps = { - disabled: isDisabled, - 'aria-disabled': isDisabled, - }; - - const linkProps = { - href, - }; - - return ( - { - if (!isDisabled && onClick) { - onClick(e); - } - }} - onMouseDown={(e: ReactMouseEvent) => { - if (!isDisabled && onMouseDown) { - onMouseDown(e); - } - }} - {...(href ? linkProps : buttonProps)} - {...otherProps} - data-test-id="cf-ui-dropdown-list-item-button" - className={styles['DropdownListItem__button']} - > - - {children} - - - ); - } - - return {children}; - }; - - render() { - const { - className, - isDisabled, - testId, - listItemRef, - isActive, onClick, - onMouseDown, - href, + onEnter, + onFocus, + onLeave, + style, submenuToggleLabel, - isTitle, - } = this.props; - + testId, + ...props + }, + refCallback, + ) => { + const { className, href, listItemRef, onMouseDown, ...otherProps } = props; + // We're not dealing with React RefObjects but with useState (because we + // want to re-render on all changes) + const setReference = refCallback as React.Dispatch< + React.SetStateAction + >; const classNames = cn(styles['DropdownListItem'], className, { [styles['DropdownListItem__submenu-toggle']]: submenuToggleLabel || onClick || onMouseDown || href, @@ -153,19 +60,102 @@ export class DropdownListItem extends Component { [styles['DropdownListItem--title']]: isTitle, }); + const renderListItem = useCallback(() => { + const { onMouseDown, href, listItemRef, ...otherProps } = props; + + const isClickable = onClick || onMouseDown || href; + + if (isClickable) { + const Element = href ? 'a' : 'button'; + + const buttonProps = { + disabled: isDisabled, + 'aria-disabled': isDisabled, + }; + + const linkProps = { + href, + }; + + return ( + { + if (!isDisabled && onClick) { + onClick(e); + } + }} + onMouseDown={(e: ReactMouseEvent) => { + if (!isDisabled && onMouseDown) { + onMouseDown(e); + } + }} + type="button" + {...(href ? linkProps : buttonProps)} + {...otherProps} + > + + {children} + + + ); + } + + return {children}; + }, [children, isActive, isDisabled, isTitle, onClick, props]); + return (
  • { + if (setReference) { + setReference(node); + } + + if (listItemRef) { + listItemRef.current = node; + } + }} + style={style} > - {submenuToggleLabel - ? this.renderSubmenuToggle() - : this.renderListItem()} + {submenuToggleLabel ? ( + + + {children} + + ) : ( + renderListItem() + )}
  • ); - } -} + }, +); + +DropdownListItem.defaultProps = { + testId: 'cf-ui-dropdown-list-item', + isDisabled: false, + isActive: false, + isTitle: false, +}; export default DropdownListItem; diff --git a/packages/forma-36-react-components/src/components/Dropdown/README.mdx b/packages/forma-36-react-components/src/components/Dropdown/README.mdx new file mode 100644 index 0000000000..358a5cb3b1 --- /dev/null +++ b/packages/forma-36-react-components/src/components/Dropdown/README.mdx @@ -0,0 +1,243 @@ +Dropdowns allow users to access a list of multiple actions. A common use-case for Dropdowns is to build context menus. + +A Dropdown should contain at least one DropdownList, and multiple DropdownListItem components to represent each action. + +## Examples of usage + +```jsx +import { + Button, + Dropdown, + DropdownList, + DropdownListItem, +} from '@contentful/forma-36-react-components'; + +function DropdownExample() { + const [isOpen, setOpen] = React.useState(false); + return ( + setOpen(false)} + toggleElement={ + + } + > + + + Dropdown list item 1 + + + Dropdown list item 2 + + + + ); +} +``` + +Dropdown with a title – Use a title to clarify the purpose of a dropdown. + +```jsx +import { + Button, + Dropdown, + DropdownList, + DropdownListItem, +} from '@contentful/forma-36-react-components'; + +function DropdownExample() { + const [isOpen, setOpen] = React.useState(false); + return ( + setOpen(false)} + toggleElement={ + + } + > + + Dropdown title + + Dropdown list item 1 + + + Dropdown list item 2 + + + + ); +} +``` + +Dropdown with multiple DropdownLists – Use multiple DropdownLists to group actions together. A DropdownList can contain a border to visually separating these lists. + +```jsx +import { + Button, + Dropdown, + DropdownList, + DropdownListItem, +} from '@contentful/forma-36-react-components'; + +function DropdownExample() { + const [isOpen, setOpen] = React.useState(false); + return ( + setOpen(false)} + toggleElement={ + + } + > + + + Dropdown list item 1 + + + Dropdown list item 2 + + + + + Dropdown list item 1 + + + Dropdown list item 2 + + + + ); +} +``` + +Dropdown with links – DropdownListItems can also contain TextLinks. These are often used to highlight related actions to the content of the DropdownList. + +```jsx +import { + Button, + Dropdown, + DropdownList, + DropdownListItem, +} from '@contentful/forma-36-react-components'; + +function DropdownExample() { + const [isOpen, setOpen] = React.useState(false); + return ( + setOpen(false)} + key={Date.now()} // Force Reinit + toggleElement={ + + } + > + + Kitten + Puppy + Piglet + + + + Add a cute animal + + + + ); +} +``` + +Dropdown with max height – DropdownLists can have a max height to limit the height of the dropdown and introduce scrolling. This should be used when dropdowns need to contain a lot of data. + +```jsx +import { + Button, + Dropdown, + DropdownList, + DropdownListItem, +} from '@contentful/forma-36-react-components'; + +function DropdownExample() { + const [isOpen, setOpen] = React.useState(false); + return ( + setOpen(false)} + key={Date.now()} // Force Reinit + toggleElement={ + + } + > + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + Item 7 + Item 8 + Item 9 + Item 10 + Item 11 + Item 12 + Item 13 + Item 14 + Item 15 + Item 16 + Item 17 + Item 18 + + + ); +} +``` + +## Best practices + +- If there is a single action to display, use TextLink or Button instead +- Use lists and titles to group similar actions +- Use a single TextLink at the bottom of the list for an action that is considerably different than the rest + +## Content recommendations: + +- To make Dropdown action-oriented, use a verb. For example, "Add field", not "New field" +- Use clear and succinct copy + +## Accessibility + +- Missing diff --git a/packages/forma-36-react-components/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap b/packages/forma-36-react-components/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap index 2bd8faadf8..26253e2454 100644 --- a/packages/forma-36-react-components/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap +++ b/packages/forma-36-react-components/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap @@ -4,7 +4,6 @@ exports[`renders the component 1`] = `
    @@ -80,7 +75,6 @@ exports[`renders the component with a submenu 1`] = `