271 lines
8.6 KiB
JavaScript
271 lines
8.6 KiB
JavaScript
// "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 (<React.Fragment>
|
|
{this.props.renderInput && this.props.renderInput(this.getInputProps())}
|
|
{!this.props.renderInput || this.state.focused
|
|
? (this.props.usePortal
|
|
? ReactDOM.createPortal(<div style={{
|
|
position: 'absolute',
|
|
height: this.state.height ? this.state.height+'px' : 'auto',
|
|
top: this.state.top+'px',
|
|
width: this.state.width ? this.state.width+'px' : 'auto',
|
|
left: this.state.left+'px',
|
|
zIndex: 100,
|
|
}} ref={this.setPicker}>
|
|
{this.renderPicker()}
|
|
</div>, document.body)
|
|
: <div style={{
|
|
position: 'fixed',
|
|
height: this.state.height ? this.state.height+'px' : 'auto',
|
|
top: this.state.top+'px',
|
|
width: this.state.width ? this.state.width+'px' : 'auto',
|
|
left: this.state.left+'px',
|
|
zIndex: 100,
|
|
}} ref={this.setPicker}>
|
|
{this.renderPicker()}
|
|
</div>)
|
|
: null}
|
|
</React.Fragment>);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|