UNPKG

passbolt-styleguide

Version:

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

405 lines (375 loc) 11.5 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), 150); 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()) { this.cache[keyword] = await this.getItems(keyword); 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.handleAfterNameUpdate(); } }); } /** * Handle after name update * @param {ReactEvent} event The triggered event * @return {void} */ handleAfterNameUpdate() { if (this.state.name) { if (!this.state.name.endsWith(' ')) { this.handleAutocompleteChangeDebounced(); } } else { this.closeAutocomplete(); } } /** * Handle autocomplete change * @returns {Promise<void>} */ async handleAutocompleteChange() { const keyword = this.state.name; 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() { if (this.state.selected === 0 || this.state.selected === null) { this.setState({selected: (this.state.autocompleteItems.length - 1)}); } else { this.setState({selected: (this.state.selected - 1)}); } this.scrollToSelectedItem(); } /** * Select next item in the list * @return {void} */ selectNext() { if (this.state.selected === null || (this.state.selected === this.state.autocompleteItems.length - 1)) { this.setState({selected: 0}); } else { this.setState({selected: (this.state.selected + 1)}); } this.scrollToSelectedItem(); } /** * 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 * @return {void} */ scrollToSelectedItem() { 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 * this.state.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; } } } /** * 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' : ''}`}> <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.state.processing || this.state.autocompleteItems) && <div className="autocomplete-wrapper"> <div className="autocomplete-content scroll" ref={this.listRef}> <ul> {this.state.processing && <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} user={item} selected={this.isItemSelected(key)} onClick={this.handleSelect} baseUrl={this.props.baseUrl}/>; } 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.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, }; export default Autocomplete;