mirror of
https://github.com/flarum/core.git
synced 2025-10-11 15:04:25 +02:00
Blurring the input causes a redraw, which hides the results and invalidates the current index. So the routing wasn't working. Drawer is now hidden on IndexPage construction.
295 lines
7.9 KiB
JavaScript
295 lines
7.9 KiB
JavaScript
import Component from 'flarum/Component';
|
|
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
|
import ItemList from 'flarum/utils/ItemList';
|
|
import classList from 'flarum/utils/classList';
|
|
import icon from 'flarum/helpers/icon';
|
|
import DiscussionsSearchSource from 'flarum/components/DiscussionsSearchSource';
|
|
import UsersSearchSource from 'flarum/components/UsersSearchSource';
|
|
|
|
/**
|
|
* The `Search` component displays a menu of as-you-type results from a variety
|
|
* of sources.
|
|
*
|
|
* The search box will be 'activated' if the app's current controller implements
|
|
* a `searching` method that returns a truthy value. If this is the case, an 'x'
|
|
* button will be shown next to the search field, and clicking it will call the
|
|
* `clearSearch` method on the controller.
|
|
*/
|
|
export default class Search extends Component {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
/**
|
|
* The value of the search input.
|
|
*
|
|
* @type {Function}
|
|
*/
|
|
this.value = m.prop();
|
|
|
|
/**
|
|
* Whether or not the search input has focus.
|
|
*
|
|
* @type {Boolean}
|
|
*/
|
|
this.hasFocus = false;
|
|
|
|
/**
|
|
* An array of SearchSources.
|
|
*
|
|
* @type {SearchSource[]}
|
|
*/
|
|
this.sources = this.sourceItems().toArray();
|
|
|
|
/**
|
|
* The number of sources that are still loading results.
|
|
*
|
|
* @type {Integer}
|
|
*/
|
|
this.loadingSources = 0;
|
|
|
|
/**
|
|
* A list of queries that have been searched for.
|
|
*
|
|
* @type {Array}
|
|
*/
|
|
this.searched = [];
|
|
|
|
/**
|
|
* The index of the currently-selected <li> in the results list. This can be
|
|
* a unique string (to account for the fact that an item's position may jump
|
|
* around as new results load), but otherwise it will be numeric (the
|
|
* sequential position within the list).
|
|
*
|
|
* @type {String|Integer}
|
|
*/
|
|
this.index = 0;
|
|
}
|
|
|
|
view() {
|
|
const currentSearch = this.getCurrentSearch();
|
|
|
|
// Initialize search input value in the view rather than the constructor so
|
|
// that we have access to app.current.
|
|
if (typeof this.value() === 'undefined') {
|
|
this.value(currentSearch || '');
|
|
}
|
|
|
|
return (
|
|
<div className={'Search Dropdown ' + classList({
|
|
open: this.value() && this.hasFocus,
|
|
active: !!currentSearch,
|
|
loading: !!this.loadingSources
|
|
})}>
|
|
<div className="Search-input">
|
|
<input className="FormControl"
|
|
placeholder={app.trans('core.search_forum')}
|
|
value={this.value()}
|
|
oninput={m.withAttr('value', this.value)}
|
|
onfocus={() => this.hasFocus = true}
|
|
onblur={() => this.hasFocus = false}/>
|
|
{this.loadingSources
|
|
? LoadingIndicator.component({size: 'tiny', className: 'Button Button--icon Button--link'})
|
|
: currentSearch
|
|
? <button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>{icon('times-circle')}</button>
|
|
: ''}
|
|
</div>
|
|
{this.value() && this.hasFocus
|
|
? (
|
|
<ul className="Dropdown-menu Search-results">
|
|
{this.sources.map(source => source.view(this.value()))}
|
|
</ul>
|
|
)
|
|
: ''}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
config(isInitialized) {
|
|
// Highlight the item that is currently selected.
|
|
this.setIndex(this.getCurrentNumericIndex());
|
|
|
|
if (isInitialized) return;
|
|
|
|
const search = this;
|
|
|
|
this.$('.Search-results')
|
|
.on('mousedown', e => e.preventDefault())
|
|
.on('click', () => this.$('input').blur())
|
|
|
|
// Whenever the mouse is hovered over a search result, highlight it.
|
|
.on('mouseenter', '> li:not(.Dropdown-header)', function() {
|
|
search.setIndex(
|
|
search.selectableItems().index(this)
|
|
);
|
|
});
|
|
|
|
// Handle navigation key events on the search input.
|
|
this.$('input')
|
|
.on('keydown', e => {
|
|
switch (e.which) {
|
|
case 40: case 38: // Down/Up
|
|
this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true);
|
|
e.preventDefault();
|
|
break;
|
|
|
|
case 13: // Return
|
|
m.route(this.getItem(this.index).find('a').attr('href'));
|
|
this.$('input').blur();
|
|
break;
|
|
|
|
case 27: // Escape
|
|
this.clear();
|
|
break;
|
|
|
|
default:
|
|
// no default
|
|
}
|
|
})
|
|
|
|
// Handle input key events on the search input, triggering results to
|
|
// load.
|
|
.on('input focus', function() {
|
|
const query = this.value.toLowerCase();
|
|
|
|
if (!query) return;
|
|
|
|
clearTimeout(search.searchTimeout);
|
|
search.searchTimeout = setTimeout(() => {
|
|
if (search.searched.indexOf(query) !== -1) return;
|
|
|
|
if (query.length >= 3) {
|
|
search.sources.map(source => {
|
|
if (!source.search) return;
|
|
|
|
search.loadingSources++;
|
|
|
|
source.search(query).then(() => {
|
|
search.loadingSources--;
|
|
m.redraw();
|
|
});
|
|
});
|
|
}
|
|
|
|
search.searched.push(query);
|
|
m.redraw();
|
|
}, 250);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the active search in the app's current controller.
|
|
*
|
|
* @return {String}
|
|
*/
|
|
getCurrentSearch() {
|
|
return app.current && typeof app.current.searching === 'function' && app.current.searching();
|
|
}
|
|
|
|
/**
|
|
* Clear the search input and the current controller's active search.
|
|
*/
|
|
clear() {
|
|
this.value('');
|
|
|
|
if (this.getCurrentSearch()) {
|
|
app.current.clearSearch();
|
|
} else {
|
|
m.redraw();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build an item list of SearchSources.
|
|
*
|
|
* @return {ItemList}
|
|
*/
|
|
sourceItems() {
|
|
const items = new ItemList();
|
|
|
|
items.add('discussions', new DiscussionsSearchSource());
|
|
items.add('users', new UsersSearchSource());
|
|
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Get all of the search result items that are selectable.
|
|
*
|
|
* @return {jQuery}
|
|
*/
|
|
selectableItems() {
|
|
return this.$('.Search-results > li:not(.Dropdown-header)');
|
|
}
|
|
|
|
/**
|
|
* Get the position of the currently selected search result item.
|
|
*
|
|
* @return {Integer}
|
|
*/
|
|
getCurrentNumericIndex() {
|
|
return this.selectableItems().index(
|
|
this.getItem(this.index)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the <li> in the search results with the given index (numeric or named).
|
|
*
|
|
* @param {String} index
|
|
* @return {DOMElement}
|
|
*/
|
|
getItem(index) {
|
|
const $items = this.selectableItems();
|
|
let $item = $items.filter(`[data-index="${index}"]`);
|
|
|
|
if (!$item.length) {
|
|
$item = $items.eq(index);
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* Set the currently-selected search result item to the one with the given
|
|
* index.
|
|
*
|
|
* @param {Integer} index
|
|
* @param {Boolean} scrollToItem Whether or not to scroll the dropdown so that
|
|
* the item is in view.
|
|
*/
|
|
setIndex(index, scrollToItem) {
|
|
const $items = this.selectableItems();
|
|
const $dropdown = $items.parent();
|
|
|
|
let fixedIndex = index;
|
|
if (index < 0) {
|
|
fixedIndex = $items.length - 1;
|
|
} else if (index >= $items.length) {
|
|
fixedIndex = 0;
|
|
}
|
|
|
|
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
|
|
|
this.index = $item.attr('data-index') || fixedIndex;
|
|
|
|
if (scrollToItem) {
|
|
const dropdownScroll = $dropdown.scrollTop();
|
|
const dropdownTop = $dropdown.offset().top;
|
|
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
|
const itemTop = $item.offset().top;
|
|
const itemBottom = itemTop + $item.outerHeight();
|
|
|
|
let scrollTop;
|
|
if (itemTop < dropdownTop) {
|
|
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
|
} else if (itemBottom > dropdownBottom) {
|
|
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
|
}
|
|
|
|
if (typeof scrollTop !== 'undefined') {
|
|
$dropdown.stop(true).animate({scrollTop}, 100);
|
|
}
|
|
}
|
|
}
|
|
}
|