@patreon/studio
Version:
Patreon Studio Design System
318 lines (315 loc) • 12.9 kB
JSX
'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