UNPKG

passbolt-styleguide

Version:

Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.

449 lines (416 loc) 12.6 kB
/** * 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;