mirror of
https://github.com/flarum/core.git
synced 2025-10-11 23:14:29 +02:00
Most of #137 done. - Use FastClick to make everything feel more responsive - Use transforms for animations to make them silky smooth - Style the drawer the same as the header to keep things simple - Revert to fixed composer, but allow it to be minimised - Add a separate notifications page for mobile so it’s easy to go back - Add indicator to the menu button when there are unread notifications - Close the drawer when navigating away - Make dropdowns/modals scrollable - Many other mobile tweaks and bug fixes Didn’t take much care to keep CSS clean, due to #103
230 lines
6.8 KiB
JavaScript
230 lines
6.8 KiB
JavaScript
import Component from 'flarum/component';
|
|
import DiscussionPage from 'flarum/components/discussion-page';
|
|
import IndexPage from 'flarum/components/index-page';
|
|
import ActionButton from 'flarum/components/action-button';
|
|
import LoadingIndicator from 'flarum/components/loading-indicator';
|
|
import ItemList from 'flarum/utils/item-list';
|
|
import classList from 'flarum/utils/class-list';
|
|
import listItems from 'flarum/helpers/list-items';
|
|
import icon from 'flarum/helpers/icon';
|
|
import DiscussionsSearchResults from 'flarum/components/discussions-search-results';
|
|
import UsersSearchResults from 'flarum/components/users-search-results';
|
|
|
|
/**
|
|
* A search box, which 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 SearchBox extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.value = m.prop();
|
|
this.hasFocus = m.prop(false);
|
|
|
|
this.sources = this.sourceItems().toArray();
|
|
this.loadingSources = 0;
|
|
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).
|
|
*/
|
|
this.index = m.prop(0);
|
|
}
|
|
|
|
getCurrentSearch() {
|
|
return app.current && typeof app.current.searching === 'function' && app.current.searching();
|
|
}
|
|
|
|
view() {
|
|
// Initialize value in the view rather than the constructor so that we have
|
|
// access to app.current.
|
|
if (typeof this.value() === 'undefined') {
|
|
this.value(this.getCurrentSearch() || '');
|
|
}
|
|
|
|
var currentSearch = this.getCurrentSearch();
|
|
|
|
return m('div.search-box.dropdown', {
|
|
config: this.onload.bind(this),
|
|
className: classList({
|
|
open: this.value() && this.hasFocus(),
|
|
active: !!currentSearch,
|
|
loading: !!this.loadingSources,
|
|
})
|
|
},
|
|
m('div.search-input',
|
|
m('input.form-control', {
|
|
placeholder: '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: 'btn btn-icon btn-link'})
|
|
: currentSearch
|
|
? m('button.clear.btn.btn-icon.btn-link', {onclick: this.clear.bind(this)}, icon('times-circle'))
|
|
: ''
|
|
),
|
|
m('ul.dropdown-menu.dropdown-menu-right.search-results', this.sources.map(source => source.view(this.value())))
|
|
);
|
|
}
|
|
|
|
onload(element, isInitialized, context) {
|
|
this.element(element);
|
|
|
|
// Highlight the item that is currently selected.
|
|
this.setIndex(this.getCurrentNumericIndex());
|
|
|
|
if (isInitialized) return;
|
|
|
|
var self = 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(e) {
|
|
self.setIndex(
|
|
self.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
|
|
this.$('input').blur();
|
|
m.route(this.getItem(this.index()).find('a').attr('href'));
|
|
app.drawer.hide();
|
|
break;
|
|
|
|
case 27: // Escape
|
|
this.clear();
|
|
break;
|
|
}
|
|
})
|
|
|
|
// Handle input key events on the search input, triggering results to
|
|
// load.
|
|
.on('input focus', function(e) {
|
|
var value = this.value.toLowerCase();
|
|
|
|
if (value) {
|
|
clearTimeout(self.searchTimeout);
|
|
self.searchTimeout = setTimeout(() => {
|
|
if (self.searched.indexOf(value) === -1) {
|
|
if (value.length >= 3) {
|
|
self.sources.map(source => {
|
|
if (source.search) {
|
|
self.loadingSources++;
|
|
source.search(value).then(() => {
|
|
self.loadingSources--;
|
|
m.redraw();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
self.searched.push(value);
|
|
m.redraw();
|
|
}
|
|
}, 500);
|
|
}
|
|
});
|
|
}
|
|
|
|
clear() {
|
|
this.value('');
|
|
if (this.getCurrentSearch()) {
|
|
app.current.clearSearch();
|
|
} else {
|
|
m.redraw();
|
|
}
|
|
}
|
|
|
|
sourceItems() {
|
|
var items = new ItemList();
|
|
|
|
items.add('discussions', new DiscussionsSearchResults());
|
|
items.add('users', new UsersSearchResults());
|
|
|
|
return items;
|
|
}
|
|
|
|
selectableItems() {
|
|
return this.$('.search-results > li:not(.dropdown-header)');
|
|
}
|
|
|
|
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) {
|
|
var $items = this.selectableItems();
|
|
var $item = $items.filter('[data-index='+index+']');
|
|
|
|
if (!$item.length) {
|
|
$item = $items.eq(index);
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
setIndex(index, scrollToItem) {
|
|
var $items = this.selectableItems();
|
|
var $dropdown = $items.parent();
|
|
|
|
if (index < 0) {
|
|
index = $items.length - 1;
|
|
} else if (index >= $items.length) {
|
|
index = 0;
|
|
}
|
|
|
|
var $item = $items.removeClass('active').eq(index).addClass('active');
|
|
|
|
this.index($item.attr('data-index') || index);
|
|
|
|
if (scrollToItem) {
|
|
var dropdownScroll = $dropdown.scrollTop();
|
|
var dropdownTop = $dropdown.offset().top;
|
|
var dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
|
var itemTop = $item.offset().top;
|
|
var itemBottom = itemTop + $item.outerHeight();
|
|
|
|
var scrollTop;
|
|
if (itemTop < dropdownTop) {
|
|
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'));
|
|
} else if (itemBottom > dropdownBottom) {
|
|
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'));
|
|
}
|
|
|
|
if (typeof scrollTop !== 'undefined') {
|
|
$dropdown.stop(true).animate({scrollTop}, 100);
|
|
}
|
|
}
|
|
}
|
|
}
|