Switch to Search component in tag selector and support X/Esc to clear
This moves debouncing into the search component and adds cancel behavior from the XUL search textbox. For now, this uses the X button from Firefox.
This commit is contained in:
parent
14d25d273a
commit
42667e7090
6 changed files with 138 additions and 21 deletions
93
chrome/content/zotero/components/search.jsx
Normal file
93
chrome/content/zotero/components/search.jsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const React = require('react');
|
||||||
|
const PropTypes = require('prop-types');
|
||||||
|
|
||||||
|
class Search extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.inputRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
immediateValue: this.props.value
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(props, state) {
|
||||||
|
var prevProps = state.prevProps || {};
|
||||||
|
return {
|
||||||
|
prevProps: props,
|
||||||
|
immediateValue: prevProps.value !== props.value
|
||||||
|
? props.value
|
||||||
|
: state.immediateValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput = (event) => {
|
||||||
|
var value = event.target.value;
|
||||||
|
// Update controlled value and cancel button immediately
|
||||||
|
this.setState({
|
||||||
|
immediateValue: value
|
||||||
|
});
|
||||||
|
// Debounce the search based on the timeout
|
||||||
|
if (this._timeout) {
|
||||||
|
clearTimeout(this._timeout);
|
||||||
|
}
|
||||||
|
this._timeout = this.props.timeout
|
||||||
|
&& setTimeout(() => this.props.onSearch(value), this.props.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear = () => {
|
||||||
|
if (this._timeout) {
|
||||||
|
clearTimeout(this._timeout);
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
immediateValue: ''
|
||||||
|
});
|
||||||
|
this.props.onSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown = (event) => {
|
||||||
|
if (event.key == 'Escape') {
|
||||||
|
this.handleClear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.inputRef.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
onInput={this.handleInput}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
ref={this.inputRef}
|
||||||
|
value={this.state.immediateValue}
|
||||||
|
/>
|
||||||
|
{this.state.immediateValue !== ''
|
||||||
|
? <div
|
||||||
|
className="search-cancel-button"
|
||||||
|
onClick={this.handleClear}/>
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
inputRef: PropTypes.object,
|
||||||
|
onSearch: PropTypes.func,
|
||||||
|
timeout: PropTypes.number,
|
||||||
|
value: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
onSearch: () => {},
|
||||||
|
timeout: 300,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Search;
|
|
@ -6,6 +6,7 @@ const TagList = require('./tag-selector/tag-list');
|
||||||
const Input = require('./form/input');
|
const Input = require('./form/input');
|
||||||
const { Button } = require('./button');
|
const { Button } = require('./button');
|
||||||
const { IconTagSelectorMenu } = require('./icons');
|
const { IconTagSelectorMenu } = require('./icons');
|
||||||
|
const Search = require('./search');
|
||||||
|
|
||||||
class TagSelector extends React.Component {
|
class TagSelector extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
|
@ -13,13 +14,11 @@ class TagSelector extends React.Component {
|
||||||
<div className="tag-selector">
|
<div className="tag-selector">
|
||||||
<TagList {...this.props} />
|
<TagList {...this.props} />
|
||||||
<div className="tag-selector-filter-container">
|
<div className="tag-selector-filter-container">
|
||||||
<Input
|
<Search
|
||||||
type="search"
|
|
||||||
ref={ref => this.focusTextbox = ref && ref.focus}
|
|
||||||
value={this.props.searchString}
|
value={this.props.searchString}
|
||||||
onChange={this.props.onSearch}
|
onSearch={this.props.onSearch}
|
||||||
|
inputRef={this.searchBoxRef}
|
||||||
className="tag-selector-filter"
|
className="tag-selector-filter"
|
||||||
size="1"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconTagSelectorMenu />}
|
icon={<IconTagSelectorMenu />}
|
||||||
|
@ -46,8 +45,8 @@ TagSelector.propTypes = {
|
||||||
onDragExit: PropTypes.func,
|
onDragExit: PropTypes.func,
|
||||||
onDrop: PropTypes.func
|
onDrop: PropTypes.func
|
||||||
}),
|
}),
|
||||||
|
searchBoxRef: PropTypes.object,
|
||||||
searchString: PropTypes.string,
|
searchString: PropTypes.string,
|
||||||
shouldFocus: PropTypes.bool,
|
|
||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
onTagContext: PropTypes.func,
|
onTagContext: PropTypes.func,
|
||||||
onSearch: PropTypes.func,
|
onSearch: PropTypes.func,
|
||||||
|
@ -58,7 +57,6 @@ TagSelector.propTypes = {
|
||||||
TagSelector.defaultProps = {
|
TagSelector.defaultProps = {
|
||||||
tags: [],
|
tags: [],
|
||||||
searchString: '',
|
searchString: '',
|
||||||
shouldFocus: false,
|
|
||||||
onSelect: () => Promise.resolve(),
|
onSelect: () => Promise.resolve(),
|
||||||
onTagContext: () => Promise.resolve(),
|
onTagContext: () => Promise.resolve(),
|
||||||
onSearch: () => Promise.resolve(),
|
onSearch: () => Promise.resolve(),
|
||||||
|
|
|
@ -29,6 +29,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
||||||
this.selectedTags = new Set();
|
this.selectedTags = new Set();
|
||||||
this.state = defaults;
|
this.state = defaults;
|
||||||
|
this.searchBoxRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusTextbox() {
|
||||||
|
this.searchBoxRef.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trigger #1 (triggered by ZoteroPane)
|
// Update trigger #1 (triggered by ZoteroPane)
|
||||||
|
@ -177,9 +182,8 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
});
|
});
|
||||||
return <TagSelector
|
return <TagSelector
|
||||||
tags={tags}
|
tags={tags}
|
||||||
ref={ref => this.focusTextbox = ref && ref.focusTextbox}
|
searchBoxRef={this.searchBoxRef}
|
||||||
searchString={this.state.searchString}
|
searchString={this.state.searchString}
|
||||||
shouldFocus={this.state.shouldFocus}
|
|
||||||
dragObserver={this.dragObserver}
|
dragObserver={this.dragObserver}
|
||||||
onSelect={this.state.viewOnly ? () => {} : this.handleTagSelected}
|
onSelect={this.state.viewOnly ? () => {} : this.handleTagSelected}
|
||||||
onTagContext={this.handleTagContext}
|
onTagContext={this.handleTagContext}
|
||||||
|
@ -226,9 +230,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSearch = Zotero.Utilities.debounce((searchString) => {
|
handleSearch = (searchString) => {
|
||||||
this.setState({searchString});
|
this.setState({searchString});
|
||||||
})
|
}
|
||||||
|
|
||||||
dragObserver = {
|
dragObserver = {
|
||||||
onDragOver: function(event) {
|
onDragOver: function(event) {
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
// Components
|
// Components
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
|
|
||||||
@import "components/tag-selector";
|
|
||||||
@import "components/button";
|
@import "components/button";
|
||||||
@import "components/icons";
|
@import "components/icons";
|
||||||
|
@import "components/search";
|
||||||
|
@import "components/tag-selector";
|
||||||
|
|
21
scss/components/_search.scss
Normal file
21
scss/components/_search.scss
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
-moz-appearance: searchfield;
|
||||||
|
flex: 1 0;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 24px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search .search-cancel-button {
|
||||||
|
background-image: url(chrome://global/skin/icons/searchfield-cancel.svg);
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
|
@ -24,6 +24,13 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-selector-list {
|
||||||
|
list-style: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-selector-filter-container {
|
.tag-selector-filter-container {
|
||||||
height: auto;
|
height: auto;
|
||||||
flex: 0 0 1em;
|
flex: 0 0 1em;
|
||||||
|
@ -32,16 +39,9 @@
|
||||||
padding: 0.125em 0 0.125em 0.5em;
|
padding: 0.125em 0 0.125em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-selector-list {
|
.tag-selector-filter-container .search {
|
||||||
list-style: none;
|
display: flex;
|
||||||
display: inline-block;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-selector-filter {
|
|
||||||
flex: 1 0;
|
flex: 1 0;
|
||||||
min-width: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-selector-actions {
|
.tag-selector-actions {
|
||||||
|
|
Loading…
Reference in a new issue