Unverified Commit 7bb5f105 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1174 from hypothesis/react-search-input

Convert `<search-input>` component to Preact
parents fbe1a173 06bab908
'use strict';
// @ngInject
function SearchInputController($element, store) {
const self = this;
const button = $element.find('button');
const input = $element.find('input')[0];
const form = $element.find('form')[0];
button.on('click', function() {
input.focus();
});
form.onsubmit = function(e) {
e.preventDefault();
self.onSearch({ $query: input.value });
};
const classnames = require('classnames');
const { createElement } = require('preact');
const { useRef, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const useStore = require('../store/use-store');
const Spinner = require('./spinner');
/**
* An input field in the top bar for entering a query that filters annotations
* (in the sidebar) or searches annotations (in the stream/single annotation
* view).
*
* This component also renders a loading spinner to indicate when the client
* is fetching for data from the API or in a "loading" state for any other
* reason.
*/
function SearchInput({ alwaysExpanded, query, onSearch }) {
const isLoading = useStore(store => store.isLoading());
const input = useRef();
// The active filter query from the previous render.
const [prevQuery, setPrevQuery] = useState(query);
this.isLoading = () => store.isLoading();
// The query that the user is currently typing, but may not yet have applied.
const [pendingQuery, setPendingQuery] = useState(query);
this.inputClasses = function() {
return { 'is-expanded': self.alwaysExpanded || self.query };
const onSubmit = e => {
e.preventDefault();
// TODO - When the parent components are converted to React, the signature
// of the callback can be simplified to `onSearch(query)` rather than
// `onSearch({ $query: query })`.
onSearch({ $query: input.current.value });
};
this.$onChanges = function(changes) {
if (changes.query) {
input.value = changes.query.currentValue;
// When the active query changes outside of this component, update the input
// field to match. This happens when clearing the current filter for example.
if (query !== prevQuery) {
setPendingQuery(query);
setPrevQuery(query);
}
};
return (
<form className="search-input__form" name="searchForm" onSubmit={onSubmit}>
<input
className={classnames('search-input__input', {
'is-expanded': alwaysExpanded || query,
})}
type="text"
name="query"
placeholder={(isLoading && 'Loading…') || 'Search…'}
disabled={isLoading}
ref={input}
value={pendingQuery}
onInput={e => setPendingQuery(e.target.value)}
/>
{!isLoading && (
<button
type="button"
className="search-input__icon top-bar__btn"
onClick={() => input.current.focus()}
>
<i className="h-icon-search"></i>
</button>
)}
{isLoading && <Spinner className="top-bar__btn" title="Loading…" />}
</form>
);
}
module.exports = {
controller: SearchInputController,
controllerAs: 'vm',
bindings: {
// Specifies whether the search input field should always be expanded,
// regardless of whether the it is focused or has an active query.
//
// If false, it is only expanded when focused or when 'query' is non-empty
alwaysExpanded: '<',
query: '<',
onSearch: '&',
},
template: require('../templates/search-input.html'),
SearchInput.propTypes = {
/**
* If true, the input field is always shown. If false, the input field is
* only shown if the query is non-empty.
*/
alwaysExpanded: propTypes.bool,
/**
* The currently active filter query.
*/
query: propTypes.string,
/**
* Callback to invoke when the current filter query changes.
*/
onSearch: propTypes.func,
};
module.exports = SearchInput;
'use strict';
const angular = require('angular');
const { createElement } = require('preact');
const { mount } = require('enzyme');
const util = require('../../directive/test/util');
const SearchInput = require('../search-input');
describe('searchInput', function() {
describe('SearchInput', () => {
let fakeStore;
before(function() {
angular
.module('app', [])
.component('searchInput', require('../search-input'));
});
const createSearchInput = (props = {}) =>
// `mount` rendering is used so we can get access to DOM nodes.
mount(<SearchInput {...props} />);
function typeQuery(wrapper, query) {
const input = wrapper.find('input');
input.getDOMNode().value = query;
input.simulate('input');
}
beforeEach(function() {
beforeEach(() => {
fakeStore = { isLoading: sinon.stub().returns(false) };
angular.mock.module('app', {
store: fakeStore,
});
});
it('displays the search query', function() {
const el = util.createDirective(document, 'searchInput', {
query: 'foo',
const FakeSpinner = () => null;
FakeSpinner.displayName = 'Spinner';
SearchInput.$imports.$mock({
'./spinner': FakeSpinner,
'../store/use-store': callback => callback(fakeStore),
});
const input = el.find('input')[0];
assert.equal(input.value, 'foo');
});
it('invokes #onSearch() when the query changes', function() {
const onSearch = sinon.stub();
const el = util.createDirective(document, 'searchInput', {
query: 'foo',
onSearch: {
args: ['$query'],
callback: onSearch,
},
afterEach(() => {
SearchInput.$imports.$restore();
});
const input = el.find('input')[0];
const form = el.find('form');
input.value = 'new-query';
form.submit();
assert.calledWith(onSearch, 'new-query');
it('displays the active query', () => {
const wrapper = createSearchInput({ query: 'foo' });
assert.equal(wrapper.find('input').prop('value'), 'foo');
});
describe('loading indicator', function() {
it('is hidden when there are no API requests in flight', function() {
const el = util.createDirective(document, 'search-input', {});
const spinner = el[0].querySelector('spinner');
it('resets input field value to active query when active query changes', () => {
const wrapper = createSearchInput({ query: 'foo' });
fakeStore.isLoading.returns(false);
el.scope.$digest();
// Simulate user editing the pending query, but not committing it.
typeQuery(wrapper, 'pending-query');
// Check that the pending query is displayed.
assert.equal(wrapper.find('input').prop('value'), 'pending-query');
// Simulate active query being reset.
wrapper.setProps({ query: '' });
assert.equal(util.isHidden(spinner), true);
assert.equal(wrapper.find('input').prop('value'), '');
});
it('is visible when there are API requests in flight', function() {
const el = util.createDirective(document, 'search-input', {});
const spinner = el[0].querySelector('spinner');
it('invokes `onSearch` with pending query when form is submitted', () => {
const onSearch = sinon.stub();
const wrapper = createSearchInput({ query: 'foo', onSearch });
typeQuery(wrapper, 'new-query');
wrapper.find('form').simulate('submit');
assert.calledWith(onSearch, { $query: 'new-query' });
});
it('renders loading indicator when app is in a "loading" state', () => {
fakeStore.isLoading.returns(true);
el.scope.$digest();
const wrapper = createSearchInput();
assert.isTrue(wrapper.exists('Spinner'));
});
assert.equal(util.isHidden(spinner), false);
it('doesn\'t render search button when app is in "loading" state', () => {
fakeStore.isLoading.returns(true);
const wrapper = createSearchInput();
assert.isFalse(wrapper.exists('button'));
});
it('doesn\'t render loading indicator when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchInput();
assert.isFalse(wrapper.exists('Spinner'));
});
it('renders search button when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchInput();
assert.isTrue(wrapper.exists('button'));
});
});
......@@ -17,7 +17,11 @@ describe('topBar', function() {
bindings: require('../login-control').bindings,
})
.component('searchInput', {
bindings: require('../search-input').bindings,
bindings: {
alwaysExpanded: '<',
query: '<',
onSearch: '&',
},
});
});
......
......@@ -177,7 +177,10 @@ function startAngularApp(config) {
'publishAnnotationBtn',
require('./components/publish-annotation-btn')
)
.component('searchInput', require('./components/search-input'))
.component(
'searchInput',
wrapReactComponent(require('./components/search-input'))
)
.component('searchStatusBar', require('./components/search-status-bar'))
.component('selectionTabs', require('./components/selection-tabs'))
.component('sidebarContent', require('./components/sidebar-content'))
......
<form class="simple-search-form"
name="searchForm"
ng-class="!vm.query && 'simple-search-inactive'">
<input class="simple-search-input"
type="text"
name="query"
placeholder="{{vm.isLoading() && 'Loading' || 'Search'}}…"
ng-disabled="vm.isLoading()"
ng-class="vm.inputClasses()"/>
<button type="button" class="simple-search-icon top-bar__btn" ng-hide="vm.isLoading()">
<i class="h-icon-search"></i>
</button>
<spinner class="top-bar__btn" ng-show="vm.isLoading()" title="Loading…"></spinner>
</form>
.search-input__form {
display: flex;
flex-flow: row nowrap;
position: relative;
color: $gray-dark;
}
.search-input__icon {
order: 0;
}
.search-input__input {
@include outline-on-keyboard-focus;
flex-grow: 1;
order: 1;
color: $text-color;
// Disable default browser styling for the input.
border: none;
padding: 0px;
width: 100%;
// The search box expands when focused, via a change in the
// `max-width` property. In Safari, the <input> will not accept
// focus if `max-width` is set to 0px so we set it to
// a near-zero positive value instead.
// See https://github.com/hypothesis/h/issues/2654
max-width: 0.1px;
transition: max-width .3s ease-out, padding-left .3s ease-out;
&:disabled {
background: none;
color: $gray-light;
}
// Expand the search input when focused (triggered by clicking
// on the search icon) or when `is-expanded` is applied.
&:focus,&.is-expanded {
max-width: 150px;
padding-left: 6px;
}
}
@import "../../base.scss";
@import "../../mixins/icons";
.simple-search-form {
display: flex;
flex-flow: row nowrap;
position: relative;
color: $gray-dark;
}
.simple-search-icon {
order: 0;
}
:not(:focus) ~ .simple-search-icon {
color: $gray-light;
}
@at-root {
$expanded-max-width: 150px;
.simple-search-input {
@include outline-on-keyboard-focus;
flex-grow: 1;
order: 1;
color: $text-color;
// disable the default browser styling for the input
border: none;
padding: 0px;
width: 100%;
// the search box expands when focused, via a change in the
// `max-width` property. In Safari, the <input> will not accept
// focus if `max-width` is set to 0px so we set it to
// a near-zero positive value instead.
// See GH #2654
max-width: 0.1px;
transition: max-width .3s ease-out, padding-left .3s ease-out;
&:disabled {
background: none;
color: $gray-light;
}
// expand the search input when focused (triggered by clicking
// on the search icon) or when `is-expanded` is applied
&:focus,&.is-expanded {
max-width: $expanded-max-width;
padding-left: 6px;
}
}
}
......@@ -41,8 +41,8 @@ $base-line-height: 20px;
@import './components/search-status-bar';
@import './components/selection-tabs';
@import './components/share-link';
@import './components/search-input';
@import './components/sidebar-tutorial';
@import './components/simple-search';
@import './components/svg-icon';
@import './components/spinner';
@import './components/tags-input';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment