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 // ...Or maybe a button with a popup menu
// License: LGPLv3.0+ // License: LGPLv3.0+
// (c) Vitaliy Filippov 2019+ // (c) Vitaliy Filippov 2019+
// Version 2019-09-03 // Version 2020-04-27
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@ -78,15 +78,25 @@ export default class Picker extends React.Component
this.picker = e; this.picker = e;
} }
getInputProps()
{
return {
onFocus: this.focus,
onBlur: this.blur,
focused: this.state.focused,
ref: this.setInput,
};
}
renderPicker()
{
return this.props.renderPicker();
}
render() render()
{ {
return (<React.Fragment> return (<React.Fragment>
{this.props.renderInput({ {this.props.renderInput(this.getInputProps())}
onFocus: this.focus,
onBlur: this.blur,
focused: this.state.focused,
ref: this.setInput,
})}
{this.state.focused {this.state.focused
? <div style={{ ? <div style={{
position: 'fixed', position: 'fixed',
@ -97,7 +107,7 @@ export default class Picker extends React.Component
left: this.state.left+'px', left: this.state.left+'px',
zIndex: 100, zIndex: 100,
}} ref={this.setPicker}> }} ref={this.setPicker}>
{this.props.renderPicker()} {this.renderPicker()}
</div> </div>
: null} : null}
</React.Fragment>); </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 // Simple Dropdown/Autocomplete with single/multiple selection and easy customisation via CSS modules
// Version 2019-09-15 // Version 2020-04-27
// License: LGPLv3.0+ // License: LGPLv3.0+
// (c) Vitaliy Filippov 2019+ // (c) Vitaliy Filippov 2019+
@ -7,7 +7,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import autocomplete_css from './autocomplete.css'; import autocomplete_css from './autocomplete.css';
import Picker from './Picker.js'; import PickerMenu from './PickerMenu.js';
export default class Selectbox extends React.PureComponent export default class Selectbox extends React.PureComponent
{ {
@ -54,7 +54,6 @@ export default class Selectbox extends React.PureComponent
state = { state = {
shown: false, shown: false,
active: null,
query: null, query: null,
inputWidth: 20, 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 = () => clear = () =>
{ {
this.setState({ query: null }); this.setState({ query: null });
@ -116,11 +92,11 @@ export default class Selectbox extends React.PureComponent
} }
} }
onMouseDown = () => onSelectItem = (item) =>
{ {
this.setState({ query: null }); this.setState({ query: null });
this.picker.blur(); 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; let value = sel;
if (this.props.multiple) if (this.props.multiple)
{ {
@ -141,19 +117,6 @@ export default class Selectbox extends React.PureComponent
f && f(value); 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 = () => focusInput = () =>
{ {
if (!this.props.disabled) if (!this.props.disabled)
@ -168,14 +131,14 @@ export default class Selectbox extends React.PureComponent
{ {
return; 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'; const v = this.props.value, vk = this.props.valueKey||'id';
for (let i = 0; i < this.filtered_items.length; i++) for (let i = 0; i < this.filtered_items.length; i++)
{ {
if (v == this.filtered_items[i][vk]) if (v == this.filtered_items[i][vk])
{ {
this.setState({ active: i }); this.picker.setState({ active: i });
break; break;
} }
} }
@ -213,7 +176,7 @@ export default class Selectbox extends React.PureComponent
onBlur={this.onBlur} onBlur={this.onBlur}
value={value} value={value}
onChange={this.setQuery} onChange={this.setQuery}
onKeyDown={this.onKeyDown} onKeyDown={p.onKeyDown}
/>; />;
return (<div ref={p.ref} return (<div ref={p.ref}
className={theme.input + className={theme.input +
@ -247,41 +210,6 @@ export default class Selectbox extends React.PureComponent
</div>); </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) => setSizer = (e) =>
{ {
this.sizer = e; this.sizer = e;
@ -334,12 +262,15 @@ export default class Selectbox extends React.PureComponent
} }
this.prevProps = this.props; this.prevProps = this.props;
this.prevState = this.state; this.prevState = this.state;
return (<Picker return (<PickerMenu
ref={this.setPicker} ref={this.setPicker}
minWidth={this.props.minWidth} minWidth={this.props.minWidth}
clearOnClick={true} clearOnClick={true}
renderInput={this.renderInput} 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) if (this.sizer)
{ {
this.setState({ inputWidth: this.sizer.offsetWidth }); 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 ReactDOM from 'react-dom';
import Selectbox from './Selectbox.js'; import Selectbox from './Selectbox.js';
import PickerMenu from './PickerMenu.js';
import button_css from './button.css';
const OPTIONS = { const OPTIONS = {
day: 'День', day: 'День',
@ -10,6 +12,8 @@ const OPTIONS = {
kak: 'Полный период детализации', kak: 'Полный период детализации',
}; };
const NAMES = Object.values(OPTIONS);
class Test extends React.PureComponent class Test extends React.PureComponent
{ {
state = { state = {
@ -29,7 +33,7 @@ class Test extends React.PureComponent
render() render()
{ {
return <div style={{padding: '20px', width: '300px', background: '#e0e8ff'}}> return <div style={{padding: '20px', width: '300px', background: '#e0e8ff', fontSize: '13px'}}>
<Selectbox <Selectbox
source={OPTIONS} source={OPTIONS}
allowClear={true} allowClear={true}
@ -66,8 +70,14 @@ class Test extends React.PureComponent
multiple={false} multiple={false}
suggestionMatch={true} suggestionMatch={true}
value={this.state.value2} value={this.state.value2}
style={{marginBottom: '20px'}}
onChange={this.onChange2} onChange={this.onChange2}
/> />
<PickerMenu
clearOnClick={true}
renderInput={p => <button {...p} className={button_css.button}>Меню</button>}
items={NAMES}
/>
</div>; </div>;
} }
} }