Extract drop-down menu code into a separate class

master
Vitaliy Filippov 2020-04-27 15:59:26 +03:00
parent ca689129de
commit 49061e7f1b
5 changed files with 267 additions and 95 deletions

View File

@ -4,7 +4,7 @@
// ...Or maybe a button with a popup menu
// License: LGPLv3.0+
// (c) Vitaliy Filippov 2019+
// Version 2019-09-03
// Version 2020-04-27
import React from 'react';
import ReactDOM from 'react-dom';
@ -78,15 +78,25 @@ export default class Picker extends React.Component
this.picker = e;
}
render()
getInputProps()
{
return (<React.Fragment>
{this.props.renderInput({
return {
onFocus: this.focus,
onBlur: this.blur,
focused: this.state.focused,
ref: this.setInput,
})}
};
}
renderPicker()
{
return this.props.renderPicker();
}
render()
{
return (<React.Fragment>
{this.props.renderInput(this.getInputProps())}
{this.state.focused
? <div style={{
position: 'fixed',
@ -97,7 +107,7 @@ export default class Picker extends React.Component
left: this.state.left+'px',
zIndex: 100,
}} ref={this.setPicker}>
{this.props.renderPicker()}
{this.renderPicker()}
</div>
: null}
</React.Fragment>);

136
PickerMenu.js Normal file
View File

@ -0,0 +1,136 @@
// Menu-like Picker variant with keyboard control
// Version 2020-04-27
// License: LGPLv3.0+
// (c) Vitaliy Filippov 2020+
import React from 'react';
import PropTypes from 'prop-types';
import autocomplete_css from './autocomplete.css';
import Picker from './Picker.js';
export default class PickerMenu extends Picker
{
static propTypes = {
...Picker.propTypes,
// menu options
items: PropTypes.array,
// additional text/items to render before menu items
beforeItems: PropTypes.any,
// additional text/items to render after menu items
afterItems: PropTypes.any,
// menuitem callback
onSelectItem: PropTypes.func,
// menuitem name key - default empty (render the item itself)
labelKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
// change theme (CSS module) for this input
theme: PropTypes.object,
}
onKeyDown = (ev) =>
{
if ((ev.which == 40 || ev.which == 38) && this.props.items.length)
{
// up / down
this.setState({
active: this.state.active == null ? 0 : (
(this.state.active + (event.which === 40 ? 1 : this.props.items.length-1)) % this.props.items.length
),
});
if (!this.state.focused)
{
this.focus();
}
}
else if ((ev.which == 10 || ev.which == 13) && this.state.active != null &&
this.state.active < this.props.items.length)
{
// enter
this.onMouseDown();
}
}
onMouseDown = () =>
{
const sel = this.props.items[this.state.active];
const f = this.props.onSelectItem;
f && f(sel);
}
onMouseOver = (ev) =>
{
let e = ev.target;
while (e && e != ev.currentTarget && !e.id)
{
e = e.parentNode;
}
if (e && e.id)
{
this.setState({ active: e.id });
}
}
animatePicker = (e) =>
{
if (e)
{
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.maxHeight = '100%';
const end = () => { e.style.overflowY = 'auto'; e.removeEventListener('transitionend', end); };
e.addEventListener('transitionend', end);
});
});
}
}
renderPicker = () =>
{
const theme = this.props.theme || autocomplete_css;
return (<div ref={this.animatePicker}
className={theme.suggestions}
onMouseOver={this.onMouseOver}>
{this.props.beforeItems}
{this.props.items.map((e, i) => (<div key={i} id={i} onMouseDown={this.onMouseDown}
className={theme.suggestion+(this.state.active == i ? ' '+theme.active : '')}>
{!this.props.labelKey ? e : e[this.props.labelKey]}
</div>))}
{this.props.afterItems}
</div>);
}
getInputProps()
{
return {
...super.getInputProps(),
onKeyDown: this.onKeyDown,
};
}
componentDidUpdate()
{
super.componentDidUpdate();
if (this.input)
{
if (this.prevHeight && this.input.offsetHeight != this.prevHeight)
{
this.calculateDirection();
}
this.prevHeight = this.input.offsetHeight;
}
}
componentDidMount()
{
this.componentDidUpdate();
}
}
delete PickerMenu.propTypes.renderPicker;

View File

@ -1,5 +1,5 @@
// Simple Dropdown/Autocomplete with single/multiple selection and easy customisation via CSS modules
// Version 2019-09-15
// Version 2020-04-27
// License: LGPLv3.0+
// (c) Vitaliy Filippov 2019+
@ -7,7 +7,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import autocomplete_css from './autocomplete.css';
import Picker from './Picker.js';
import PickerMenu from './PickerMenu.js';
export default class Selectbox extends React.PureComponent
{
@ -54,7 +54,6 @@ export default class Selectbox extends React.PureComponent
state = {
shown: false,
active: null,
query: null,
inputWidth: 20,
}
@ -74,29 +73,6 @@ export default class Selectbox extends React.PureComponent
}
}
onKeyDown = (ev) =>
{
if ((ev.which == 40 || ev.which == 38) && this.filtered_items.length)
{
// up / down
this.setState({
active: this.state.active == null ? 0 : (
(this.state.active + (event.which === 40 ? 1 : this.filtered_items.length-1)) % this.filtered_items.length
),
});
if (!this.picker.state.focused)
{
this.picker.focus();
}
}
else if ((ev.which == 10 || ev.which == 13) && this.state.active != null &&
this.state.active < this.filtered_items.length)
{
// enter
this.onMouseDown();
}
}
clear = () =>
{
this.setState({ query: null });
@ -116,11 +92,11 @@ export default class Selectbox extends React.PureComponent
}
}
onMouseDown = () =>
onSelectItem = (item) =>
{
this.setState({ query: null });
this.picker.blur();
const sel = this.filtered_items[this.state.active][this.props.valueKey||'id'];
const sel = item[this.props.valueKey||'id'];
let value = sel;
if (this.props.multiple)
{
@ -141,19 +117,6 @@ export default class Selectbox extends React.PureComponent
f && f(value);
}
onMouseOver = (ev) =>
{
let e = ev.target;
while (e && e != ev.currentTarget && !e.id)
{
e = e.parentNode;
}
if (e && e.id)
{
this.setState({ active: e.id });
}
}
focusInput = () =>
{
if (!this.props.disabled)
@ -168,14 +131,14 @@ export default class Selectbox extends React.PureComponent
{
return;
}
if (!this.props.multiple && this.state.active === null)
if (!this.props.multiple && this.picker.state.active === null)
{
const v = this.props.value, vk = this.props.valueKey||'id';
for (let i = 0; i < this.filtered_items.length; i++)
{
if (v == this.filtered_items[i][vk])
{
this.setState({ active: i });
this.picker.setState({ active: i });
break;
}
}
@ -213,7 +176,7 @@ export default class Selectbox extends React.PureComponent
onBlur={this.onBlur}
value={value}
onChange={this.setQuery}
onKeyDown={this.onKeyDown}
onKeyDown={p.onKeyDown}
/>;
return (<div ref={p.ref}
className={theme.input +
@ -247,41 +210,6 @@ export default class Selectbox extends React.PureComponent
</div>);
}
animateSuggestions = (e) =>
{
if (e)
{
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.maxHeight = '100%';
const end = () => { e.style.overflowY = 'auto'; e.removeEventListener('transitionend', end); };
e.addEventListener('transitionend', end);
});
});
}
}
renderSuggestions = () =>
{
const theme = this.props.theme || autocomplete_css;
return (<div ref={this.animateSuggestions}
className={theme.suggestions}
onMouseOver={this.onMouseOver}>
{this.filtered_items.map((e, i) => (<div key={i} id={i} onMouseDown={this.onMouseDown}
className={theme.suggestion+(this.state.active == i ? ' '+theme.active : '')}>
{e[this.props.labelKey||'name']}
</div>))}
{this.props.suggestionMsg}
</div>);
}
setSizer = (e) =>
{
this.sizer = e;
@ -334,12 +262,15 @@ export default class Selectbox extends React.PureComponent
}
this.prevProps = this.props;
this.prevState = this.state;
return (<Picker
return (<PickerMenu
ref={this.setPicker}
minWidth={this.props.minWidth}
clearOnClick={true}
renderInput={this.renderInput}
renderPicker={this.renderSuggestions}
items={this.filtered_items}
labelKey={this.props.labelKey||'name'}
afterItems={this.props.suggestionMsg}
onSelectItem={this.onSelectItem}
/>);
}
@ -348,11 +279,6 @@ export default class Selectbox extends React.PureComponent
if (this.sizer)
{
this.setState({ inputWidth: this.sizer.offsetWidth });
if (this.prevHeight && this.picker.input.offsetHeight != this.prevHeight)
{
this.picker.calculateDirection();
}
this.prevHeight = this.picker.input.offsetHeight;
}
}

90
button.css Normal file
View File

@ -0,0 +1,90 @@
/* extjs-like <button> */
.button
{
cursor: pointer;
vertical-align: middle;
border: 1px solid #d8d8d8;
outline: none;
position: relative;
background: #f5f5f5;
border-radius: 3px;
font-size: inherit;
line-height: 2em;
padding: 0 .6em;
color: #606060;
}
.button::-moz-focus-inner
{
border: 0;
}
.button:focus:after
{
content: " ";
display: block;
border: 1px solid #4196d4;
border-radius: 2px;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.button:not([disabled]):hover
{
border: 1px solid #cfcfcf;
background: #ebebeb;
}
.button:not([disabled]):active, .button.pressed
{
border: 1px solid #c6c6c6;
background: #e1e1e1;
}
.button[disabled]
{
color: #c0c0c0;
cursor: default;
}
.button :global(span.material-icons)
{
font-size: inherit;
line-height: 100%;
vertical-align: middle;
}
.button.blue
{
background: #3892d3;
border-color: #157fcc;
color: white;
}
.button.blue:focus:after
{
border: 1px solid white;
}
.button.blue:not([disabled]):hover
{
border: 1px solid #1374bb;
background: #3386c2;
}
.button.blue:not([disabled]):active, .button.blue.pressed
{
border: 1px solid #0f5f99;
background: #2a6d9e;
}
.button.blue[disabled]
{
color: white;
opacity: 0.5;
cursor: default;
}

12
main.js
View File

@ -2,6 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import Selectbox from './Selectbox.js';
import PickerMenu from './PickerMenu.js';
import button_css from './button.css';
const OPTIONS = {
day: 'День',
@ -10,6 +12,8 @@ const OPTIONS = {
kak: 'Полный период детализации',
};
const NAMES = Object.values(OPTIONS);
class Test extends React.PureComponent
{
state = {
@ -29,7 +33,7 @@ class Test extends React.PureComponent
render()
{
return <div style={{padding: '20px', width: '300px', background: '#e0e8ff'}}>
return <div style={{padding: '20px', width: '300px', background: '#e0e8ff', fontSize: '13px'}}>
<Selectbox
source={OPTIONS}
allowClear={true}
@ -66,8 +70,14 @@ class Test extends React.PureComponent
multiple={false}
suggestionMatch={true}
value={this.state.value2}
style={{marginBottom: '20px'}}
onChange={this.onChange2}
/>
<PickerMenu
clearOnClick={true}
renderInput={p => <button {...p} className={button_css.button}>Меню</button>}
items={NAMES}
/>
</div>;
}
}