UNPKG

@patreon/studio

Version:

Patreon Studio Design System

318 lines (315 loc) 12.9 kB
'use client'; import { autoUpdate, flip, offset, size, useFloating, shift, limitShift } from '@floating-ui/react-dom'; import React from 'react'; import { createPortal } from 'react-dom'; import { css } from 'styled-components'; import { tokens } from '../../tokens'; import { filterForAriaProps } from '../../utilities/accessibility'; import { hasResizeObserver } from '../../utilities/feature-detection'; import getDocument from '../../utilities/get-document'; import parentIncludesEventTarget from '../../utilities/parentIncludesEventTarget'; import { BodyText } from '../BodyText'; import { Box } from '../Box'; import { IconChevronDownAlt, IconChevronUpAlt } from '../Icon'; import { InlineError } from '../InlineError'; import { InlineHelpText } from '../InlineHelpText'; import { OverlayStackComponent } from '../OverlayStackProvider'; import { PortalPassthrough } from '../PortalPassthrough'; import { DropdownList } from './DropdownList'; import { keyCodes } from './lib/keycodes'; import { DropdownContainer, DropdownHandle, DropdownHandleDiv } from './styled-components'; const document = getDocument(); function DropdownPopper(props) { const { refs, floatingStyles } = useFloating({ placement: props.rightAlign ? 'bottom-end' : 'bottom-start', middleware: [ // Offset the dropdown UI by 8px offset(8), // Before resorting to vertical flips, shrink the UI size({ apply({ availableHeight, elements, rects }) { Object.assign(elements.floating.style, { // Clamp dropdown height to either: // * 56px - 40px plus 8px vertical padding // * availableHeight minus 8px bottom margin buffer maxHeight: `${Math.max(availableHeight - 8, 56)}px`, width: props.fluidDropdownList ? `${rects.reference.width}px` : undefined, }); }, }), // If the popover is overflowing the viewport, allow for // shifting its position to ensure it remains visible shift({ limiter: limitShift(), padding: 12 }), // Keep the dropdown in the viewport by moving it across // both axes flip(), ], // Re-calc position on scroll/resize whileElementsMounted(...args) { if (hasResizeObserver) { return autoUpdate(...args, { animationFrame: props.autoUpdate ?? false }); } return () => null; }, }); return props.children({ hostRef: refs.setReference, floatingRef: refs.setFloating, floatingStyles, }); } /** @deprecated use `OverlayTriggerMenu` instead. */ // TODO (legacied react-prefer-function-component/react-prefer-function-component) // This failure is legacied in and should be updated. DO NOT COPY. // eslint-disable-next-line react-prefer-function-component/react-prefer-function-component export class Dropdown extends React.Component { static defaultProps = { closeOnOutsideClick: true, placeholder: 'Select…', hideCaret: false, hasCustomButton: false, isDivDropdown: false, width: 'auto', maxHeight: 'none', renderMode: 'portal', hideOverflow: false, }; static List = DropdownList; state = { isMounted: false, closingTimeoutId: null, }; container; activator; get handleHitbox() { const handleTarget = this.activator; if (!handleTarget) { return null; } const h = handleTarget.getBoundingClientRect(); return { left: Math.floor(h.left), right: Math.floor(h.right), top: Math.floor(h.top), bottom: Math.floor(h.bottom), }; } get containerHitbox() { const containerTarget = this.container; if (!containerTarget) { return null; } const c = containerTarget.getBoundingClientRect(); return { left: Math.floor(c.left), right: Math.floor(c.right), top: Math.floor(c.top), bottom: Math.floor(c.bottom), }; } componentDidMount() { this.setState({ isMounted: true }); document.addEventListener('keydown', this.handleKeyDown); if (this.props.onClose) { document.addEventListener('mousedown', this.handleMouseClickOutside); } } componentWillUnmount() { document.removeEventListener('keydown', this.handleKeyDown); if (this.props.onClose) { document.removeEventListener('mousedown', this.handleMouseClickOutside); } } handleKeyDown = (e) => { const { closeOnOutsideClick, isOpen, onClose } = this.props; if (!closeOnOutsideClick) { return; } if (isOpen && e.key === keyCodes.ESC && onClose) { onClose(e); } }; handleMouseClickOutside = (e) => { const { closeOnOutsideClick, isOpen, onClose } = this.props; if (!closeOnOutsideClick || !isOpen || !onClose) { return; } // We also want to consider clicks on the toggle div as 'inside' clicks // so we don't preempt onClick's call of this.props.onClose if (parentIncludesEventTarget(e, this.container) || parentIncludesEventTarget(e, this.activator)) { return; } e.stopPropagation(); onClose(e); }; toggleMenuShowing = (e) => { const { onOpen, onClose, isOpen } = this.props; if (!isOpen && onOpen) { onOpen(e); } else if (isOpen && onClose) { onClose(e); } }; renderDisplayValue = () => { const { disabled, dropdownHandle } = this.props; const displayValue = this.props.displayValue || this.props.placeholder; return typeof displayValue === 'string' && !dropdownHandle ? (<BodyText color={disabled ? tokens.global.content.muted.default : tokens.global.content.regular.default}> {displayValue} </BodyText>) : (displayValue); }; renderHandleContent = () => { const { hasCustomButton, dropdownHandle, disabled, isOpen, ariaLabel } = this.props; const children = this.renderHandleContentChildren(); if (dropdownHandle) { const { component: DropdownHandleComponent, props } = dropdownHandle; if (hasCustomButton) { return (<DropdownHandleComponent {...props} onClick={!disabled ? this.toggleMenuShowing : undefined} aria-expanded={isOpen} aria-haspopup aria-label={ariaLabel} data-tag="menuToggleDiv"> {children} </DropdownHandleComponent>); } return <DropdownHandleComponent {...props}>{children}</DropdownHandleComponent>; } return children; }; renderHandleContentChildren = () => { const { hideCaret, isOpen } = this.props; const caretProps = { color: this.props.disabled ? tokens.global.content.muted.default : tokens.global.content.regular.default, size: '24px', }; const caret = isOpen ? <IconChevronUpAlt {...caretProps}/> : <IconChevronDownAlt {...caretProps}/>; return (<Box alignContent="center" alignItems="center" display="flex" flexDirection="row" flexWrap="nowrap" justifyContent="space-between" width="100%"> <Box mr={hideCaret ? 0 : 1.5} css={css ` overflow: hidden; white-space: nowrap; text-overflow: ellipsis; `}> {this.renderDisplayValue()} </Box> {!hideCaret && caret} </Box>); }; renderHeader = () => { const { header } = this.props; return (<Box pb={3}> <BodyText as="p" color={tokens.global.content.muted.default}> {header} </BodyText> </Box>); }; render() { const { ariaLabel, children, disabled, dropdownHandle: handleComponent, fluidWidth, fluidDropdownList, hasCustomButton, header, id, isDivDropdown, isOpen, maxHeight, minWidth, noHandleStyle, openOnHover, rightAlign, autoUpdatePosition, width, renderMode, hideOverflow, error, ...restProps } = this.props; const errorId = InlineError.getErrorId(id || ''); const dropdownHandleProps = { 'aria-expanded': isOpen, 'aria-haspopup': true, 'aria-label': ariaLabel, disabled, fluidWidth, hasCustomHandle: !!handleComponent || noHandleStyle, id, isOpen, minWidth, onMouseOver: openOnHover ? this.handleMouseOverHandle : undefined, openOnHover, error, ...filterForAriaProps(restProps), }; return (<Box alignSelf="center" fluidWidth={fluidWidth} onMouseLeave={this.handleMouseLeave} position="relative"> <OverlayStackComponent id={id ?? 'dropdown-fallback'} isOpen={isOpen ?? false}/> <DropdownPopper rightAlign={rightAlign} autoUpdate={autoUpdatePosition} fluidDropdownList={fluidDropdownList}> {({ floatingRef, hostRef, floatingStyles }) => { const container = (<PortalPassthrough> <DropdownContainer data-tag="dropdown-list" ref={(el) => { if (typeof floatingRef === 'function') { floatingRef(el); } this.containerRef(el); }} style={floatingStyles} maxHeight={maxHeight} width={width} hideOverflow={hideOverflow}> <Box p={2}> {header && this.renderHeader()} {children} </Box> </DropdownContainer> </PortalPassthrough>); return (<> {isDivDropdown || hasCustomButton ? (<DropdownHandleDiv {...dropdownHandleProps} data-tag={hasCustomButton ? undefined : 'menuToggleDiv'} ref={(el) => { if (hostRef instanceof Function) { hostRef(el); } this.activatorRef(el); }}> {this.renderHandleContent()} </DropdownHandleDiv>) : (<DropdownHandle {...dropdownHandleProps} data-tag="menuToggleDiv" ref={(el) => { if (hostRef instanceof Function) { hostRef(el); } this.activatorRef(el); }} onClick={!disabled ? this.toggleMenuShowing : undefined}> {this.renderHandleContent()} </DropdownHandle>)} {error && <InlineHelpText error={error} inputId={errorId}/>} {isOpen && renderMode === 'adjacent' && container} {isOpen && this.state.isMounted && renderMode === 'portal' && createPortal(container, window.document.body)} </>); }} </DropdownPopper> </Box>); } isMouseEventInHitbox(e) { if (!this.handleHitbox || !this.containerHitbox) { return false; } /** * If within handle hitbox (+ height of menu for gap b/w both) */ if (e.clientX > this.handleHitbox.left && e.clientX < this.handleHitbox.right && e.clientY > this.handleHitbox.top && e.clientY < this.containerHitbox.bottom) { return true; } /** * If within container hitbox */ if (e.clientX > this.containerHitbox.left && e.clientX < this.containerHitbox.right && e.clientY > this.containerHitbox.top && e.clientY < this.containerHitbox.bottom) { return true; } return false; } handleMouseOverHandle = (e) => { const { openOnHover, onOpen, isOpen } = this.props; if (openOnHover && onOpen && !isOpen) { onOpen(e); } }; handleMouseLeave = (e) => { const { openOnHover, isOpen, onClose } = this.props; if (!openOnHover || !isOpen || !onClose || this.isMouseEventInHitbox(e)) { return; } e.stopPropagation(); onClose(e); }; containerRef(el) { if (el != null) { this.container = el; } return this.container; } activatorRef(el) { if (el != null) { this.activator = el; } return this.activator; } } //# sourceMappingURL=index.jsx.map