// "Generic dropdown component" // Renders something and then when that "something" is focused renders a popup layer next to it // For example, a text input with a popup selection list // ...Or maybe a button with a popup menu // License: LGPLv3.0+ // (c) Vitaliy Filippov 2019+ // Version 2021-11-26 import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; export default class Picker extends React.Component { static propTypes = { direction: PropTypes.string, autoHide: PropTypes.bool, minWidth: PropTypes.number, className: PropTypes.string, renderInput: PropTypes.func, renderPicker: PropTypes.func.isRequired, popupX: PropTypes.number, popupY: PropTypes.number, onHide: PropTypes.func, usePortal: PropTypes.bool, } state = { focused: false, height: 0, width: 0, top: 0, left: 0, } focus = () => { if (!this.state.focused && this.props.renderInput) { this.setState({ focused: true, height: 0 }); } } blur = () => { if (this.state.focused || !this.props.renderInput) { if (this.props.renderInput) { this.setState({ focused: false }); } const f = this.props.onHide; f && f(); } } blurExt = (ev) => { let n = this.input ? ReactDOM.findDOMNode(this.input) : null; let e = ev.target||ev.srcElement; while (e) { // calendar-box is calendar.js's class if (e == this.picker || e == n || /\bcalendar-box\b/.exec(e.className||'')) { ev.stopPropagation(); ev.preventDefault(); return; } e = e.parentNode; } this.blur(); } setInput = (e) => { this.input = e; } setPicker = (e) => { this.picker = e; } getInputProps() { return { onFocus: this.focus, onBlur: this.blur, focused: this.state.focused, ref: this.setInput, }; } renderPicker() { return this.props.renderPicker({ blur: this.blur, }); } animatePicker = (e) => { if (e) { e = ReactDOM.findDOMNode(e); if (!this.props.renderInput) { e.focus(); } e.style.visibility = 'hidden'; e.style.overflowY = 'hidden'; const anim = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame; anim(() => { e.style.visibility = ''; e.style.maxHeight = '1px'; anim(() => { e.style.transitionTimingFunction = 'cubic-bezier(0.4, 0, 0.2, 1)'; e.style.transitionDuration = '0.2s'; e.style.transitionProperty = 'max-height'; e.style.maxHeight = '100%'; const end = () => { e.style.transitionProperty = ''; e.style.maxHeight = ''; e.style.overflowY = ''; e.removeEventListener('transitionend', end); }; e.addEventListener('transitionend', end); }); }); } } render() { return ( {this.props.renderInput && this.props.renderInput(this.getInputProps())} {!this.props.renderInput || this.state.focused ? (this.props.usePortal ? ReactDOM.createPortal(
{this.renderPicker()}
, document.body) :
{this.renderPicker()}
) : null}
); } componentDidMount() { this.componentDidUpdate(); } componentDidUpdate() { if (!this.props.renderInput || this.state.focused) { if (!this.state.height) { this.calculateDirection(); } if (this.props.autoHide && !this._blurSet) { this._blurSet = true; document.body.addEventListener('mousedown', this.blurExt); } } else if (this._blurSet) { this._blurSet = false; document.body.removeEventListener('mousedown', this.blurExt); } } componentWillUnmount() { if (this._blurSet) { this._blurSet = false; document.body.removeEventListener('mousedown', this.blurExt); } } calculateDirection() { if (!this.picker) { return; } let ph; if (this.state.height) { ph = this.picker.style.height; // Trigger reflow this.picker.style.height = 'auto'; } const inputRect = this.input ? ReactDOM.findDOMNode(this.input).getBoundingClientRect() : { left: this.props.popupX||0, top: this.props.popupY||0 }; const pos = Picker.calculatePopupPosition(inputRect, this.picker, this.props); if (ph && ph != 'auto') { this.picker.style.height = ph; } if (this.state.top !== pos.top || this.state.left !== pos.left || this.state.width !== pos.width || this.state.height !== pos.height) { this.setState(pos); } } static getScrollHeight(el) { let h = el.offsetHeight; for (const child of el.querySelectorAll('.scrollable')) { h += child.scrollHeight - child.offsetHeight; } return h; } static calculatePopupPosition(input_pos, popup, props) { const popup_node = ReactDOM.findDOMNode(popup); const popup_size = { width: popup_node.offsetWidth, height: Picker.getScrollHeight(popup_node) }; const screen_width = window.innerWidth || document.documentElement.offsetWidth; const screen_height = window.innerHeight || document.documentElement.offsetHeight; let direction = props && props.direction; let top = input_pos.top + (props.usePortal ? (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop) : 0) - (document.documentElement.clientTop || document.body.clientTop || 0); if (!direction || direction === 'auto') { const down = top + (input_pos.height||0) + popup_size.height < screen_height-32 || (top + (input_pos.height||0)) < screen_height/2; direction = down ? 'down' : 'up'; } const max_height = (direction == 'down' ? screen_height-top-(input_pos.height||0)-32 : top-32); const height = Math.ceil(popup_size.height < max_height ? popup_size.height : max_height); top = direction == 'down' ? (top + (input_pos.height||0)) : (top - height); let width = (input_pos.width||0) > popup_size.width ? input_pos.width : popup_size.width; width = Math.ceil(props && props.minWidth && width < props.minWidth ? props.minWidth : width); let left = (input_pos.left + (props.usePortal ? (window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft) : 0) - (document.documentElement.clientLeft || document.body.clientLeft || 0)); if (left + width > screen_width && width <= screen_width) { left = screen_width - width; } return { top, left, width, height }; } }