passbolt-styleguide
Version:
Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.
449 lines (416 loc) • 12.6 kB
JavaScript
/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) 2020 Passbolt SA (https://www.passbolt.com)
*
* Licensed under GNU Affero General Public License version 3 of the or any later version.
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) 2020 Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 2.13.0
*/
import React, { Component } from "react";
import PropTypes from "prop-types";
import debounce from "debounce-promise";
import AutocompleteItem from "./AutocompleteItem";
import AutocompleteItemEmpty from "./AutocompleteItemEmpty";
import AutocompleteItemLoading from "./AutocompleteItemLoading";
const AUTOCOMPLETE_DISPLAY_LIMIT = 10;
class Autocomplete extends Component {
/**
* Constructor
* @param {Object} props
*/
constructor(props) {
super(props);
this.bindCallbacks();
this.createInputRefs();
this.state = this.getDefaultState();
this.cache = {};
this.cacheExpiry = 10000; // in ms (aka 10s)
}
/**
* getDefaultState
* @return {object}
*/
getDefaultState() {
return {
loading: true,
processing: false,
// autocomplete
selected: null,
autocompleteItems: null,
// Fields and errors
name: "loading...",
nameError: false,
};
}
/**
* ComponentDidMount
* Invoked immediately after component is inserted into the tree
* @return {void}
*/
componentDidMount() {
this.setState({ loading: false, name: "" }, () => {
this.inputRef.current.focus();
});
document.addEventListener("keydown", this.handleKeyDown, { capture: true });
}
/**
* componentDidUpdate
* Invoked immediately after props are updated
* @return {void}
*/
componentDidUpdate(prevProps) {
if (prevProps.disabled !== this.props.disabled) {
this.inputRef.current.focus();
}
}
/**
* componentWillUnmount
* Invoked immediately before the component is removed from the tree
* @return {void}
*/
componentWillUnmount() {
document.removeEventListener("keydown", this.handleKeyDown, { capture: true });
}
/**
* Create references
* @returns {void}
*/
createInputRefs() {
this.listRef = React.createRef();
this.inputRef = React.createRef();
}
/**
* Bind callbacks methods
* @return {void}
*/
bindCallbacks() {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.selectNext = this.selectNext.bind(this);
this.selectPrevious = this.selectPrevious.bind(this);
this.handleAutocompleteChangeDebounced = debounce(this.handleAutocompleteChange.bind(this), 300);
this.handleSelect = this.handleSelect.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
}
/**
* Retrieve the items to display based on a keyword
* @param {string} keyword The keyword to search for
* @returns {Promise<void>}
*/
async getItems(keyword) {
this.setState({ processing: true });
return await this.props.searchCallback(keyword);
}
/**
* Retrieve the items to display based on keywords.
* Check in the cache first, or plan a search call.
* @param {string} keyword The keyword to search for
* @returns {Promise<*>}
*/
async autocompleteSearch(keyword) {
if (!this.cache[keyword] || this.cache[keyword].cacheExpiry < new Date().getTime()) {
const results = await this.getItems(keyword);
this.cache[keyword] = results;
this.cache[keyword].cacheExpiry = new Date().getTime() + this.cacheExpiry;
}
return this.cache[keyword];
}
/**
* Close the autocomplete area
* @return {void}
*/
closeAutocomplete() {
this.cache = {};
this.setState({ processing: false, autocompleteItems: null, selected: null });
this.props.onClose();
}
/**
* Handle the user key down
* @param {ReactEvent} event The triggered event
* @return {void}
*/
handleKeyDown(event) {
if (this.state.disabled || this.state.processing || this.state.autocompleteItems === null) {
return;
}
if (event.keyCode === 40) {
// key down
event.preventDefault();
this.selectNext();
return;
}
if (event.keyCode === 38) {
// key up
event.preventDefault();
this.selectPrevious();
return;
}
if (event.keyCode === 13 || event.keyCode === 9) {
// enter key or tab
if (this.state.selected === null) {
return;
}
event.preventDefault();
this.handleSelect(this.state.selected);
}
}
/**
* Handle a suggested item selection
* @param {Object} selected The selected item
* @return {void}
*/
handleSelect(selected) {
const obj = this.state.autocompleteItems[selected];
this.cache = {};
this.setState({ name: "" });
this.props.onSelect(obj);
this.closeAutocomplete();
}
/**
* Handle the search input change
* @param {ReactEvent} event The triggered event
* @return {void}
*/
handleInputChange(event) {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
this.setState({
[name]: value,
});
if (name === "name") {
this.handleNameUpdate(value);
}
}
/**
* Handle name update
* @param {string} value
* @return {void}
*/
handleNameUpdate(value) {
if (value) {
if (!value.endsWith(" ")) {
this.handleAutocompleteChangeDebounced(value);
}
} else {
this.closeAutocomplete();
}
}
/**
* Handle autocomplete change
* @param {string} searchedName
* @returns {Promise<void>}
*/
async handleAutocompleteChange(keyword) {
if (!keyword) {
this.closeAutocomplete();
return;
}
try {
const autocompleteItems = await this.autocompleteSearch(keyword);
let selected = null;
if (autocompleteItems.length > 0) {
selected = 0;
}
this.props.onOpen();
// This verification avoid that a long promise erase the last call
if (keyword === this.state.name) {
return new Promise((resolve) => {
this.setState({ autocompleteItems, processing: false, selected: selected }, resolve());
});
}
} catch (error) {
console.error(error);
this.closeAutocomplete();
this.setState({ serviceError: error.message });
}
}
/**
* Select previous item in the list
* @return {void}
*/
selectPrevious() {
let selected = this.state.selected;
if (selected === 0 || selected === null) {
selected = this.state.autocompleteItems.length - 1;
} else {
selected = selected - 1;
}
this.scrollToSelectedItem(selected);
this.setState({ selected });
}
/**
* Select next item in the list
* @return {void}
*/
selectNext() {
let selected = this.state.selected;
if (selected === null || selected === this.state.autocompleteItems.length - 1) {
selected = 0;
} else {
selected = selected + 1;
}
this.scrollToSelectedItem(selected);
this.setState({ selected });
}
/**
* Check if an item is selected
* @param {int} key the item key to look for
* @returns {boolean}
*/
isItemSelected(key) {
if (this.state.selected === null) {
return false;
} else {
return key === this.state.selected;
}
}
/**
* Get the loading placeholder.
* @returns {string}
*/
getPlaceholder() {
if (this.props.disabled) {
return "please wait...";
} else {
return this.props.placeholder;
}
}
/**
* Check if the input search is disabled.
* @returns {Boolean}
*/
isInputDisabled() {
return this.state.loading || this.props.disabled;
}
/**
* Scroll to the selected item
* @param {number} selected
* @return {void}
*/
scrollToSelectedItem(selected) {
if (!this.state.autocompleteItems || this.state.autocompleteItems.length === 0) {
this.listRef.current.scrollTop = 0;
} else {
const totalHeight = this.listRef.current.scrollHeight;
const itemHeight = totalHeight / this.state.autocompleteItems.length;
const visibleHeight = this.listRef.current.clientHeight;
const howManyFits = Math.round(visibleHeight / itemHeight);
const fitOffset = visibleHeight - itemHeight * howManyFits;
const currentItemPosition = itemHeight * selected;
const currentScroll = this.listRef.current.scrollTop;
if (currentItemPosition === 0) {
this.listRef.current.scrollTop = 0;
} else if (currentItemPosition - fitOffset < currentScroll) {
this.listRef.current.scrollTop = this.listRef.current.scrollTop - visibleHeight;
} else if (currentItemPosition > currentScroll + visibleHeight) {
this.listRef.current.scrollTop = currentItemPosition;
}
}
}
/**
* Should show autocomplete content
* @returns {boolean}
*/
get shouldShowAutocompleteContent() {
return Boolean(this.state.name && (this.state.processing || this.state.autocompleteItems));
}
/**
* Returns the maximum count of element the autocomplete list should display.
* @returns {integer}
*/
static get DISPLAY_LIMIT() {
return AUTOCOMPLETE_DISPLAY_LIMIT;
}
/**
* Render
* @returns {JSX}
*/
render() {
return (
<div>
<div>
<div
className={`input text autocomplete ${this.isInputDisabled() ? "disabled" : ""} ${this.shouldShowAutocompleteContent ? "no-focus" : ""}`}
>
<label htmlFor={this.props.id}>{this.props.label}</label>
<input
id={this.props.id}
name={this.props.name}
ref={this.inputRef}
maxLength="64"
type="text"
placeholder={this.getPlaceholder()}
autoComplete="off"
value={this.state.name}
disabled={this.isInputDisabled()}
onChange={this.handleInputChange}
/>
</div>
{this.shouldShowAutocompleteContent && (
<div className="autocomplete-wrapper">
<div className="autocomplete-content scroll" ref={this.listRef}>
<ul>
{this.state.processing && this.state.name && <AutocompleteItemLoading />}
{!this.state.processing &&
(!this.state.autocompleteItems || !this.state.autocompleteItems.length) && (
<AutocompleteItemEmpty />
)}
{!this.state.processing &&
this.state.autocompleteItems &&
this.state.autocompleteItems.map((item, key) => {
if (item.username) {
return (
<AutocompleteItem
key={key}
id={key}
onClick={this.handleSelect}
baseUrl={this.props.baseUrl}
canShowUserAsSuspended={this.props.canShowUserAsSuspended}
user={item}
selected={this.isItemSelected(key)}
/>
);
} else {
return (
<AutocompleteItem
key={key}
id={key}
group={item}
selected={this.isItemSelected(key)}
onClick={this.handleSelect}
baseUrl={this.props.baseUrl}
/>
);
}
})}
</ul>
</div>
</div>
)}
</div>
</div>
);
}
}
Autocomplete.defaultProps = {
canShowUserAsSuspended: false,
};
Autocomplete.propTypes = {
baseUrl: PropTypes.string,
id: PropTypes.string,
searchCallback: PropTypes.func,
onSelect: PropTypes.func,
onOpen: PropTypes.func,
onClose: PropTypes.func,
label: PropTypes.string,
disabled: PropTypes.bool,
name: PropTypes.string,
placeholder: PropTypes.string,
canShowUserAsSuspended: PropTypes.bool.isRequired, // is the disableUser flag enabled?
};
export default Autocomplete;