UNPKG

passbolt-styleguide

Version:

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

477 lines (436 loc) 14.6 kB
/** * Passbolt ~ Open source password manager for teams * Copyright (c) 2022 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) 2022 Passbolt SA (https://www.passbolt.com) * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ import React, {Component} from "react"; import PropTypes from "prop-types"; import Icon from "../../../../shared/components/Icons/Icon"; import {Trans, withTranslation} from "react-i18next"; import CustomPropTypes from "../../../../shared/lib/PropTypes/CustomPropTypes"; /** * Display of the Select component */ class Select extends Component { /** * Default constructor * @param props Component props */ constructor(props) { super(props); this.state = this.getDefaultState(props); this.bindCallback(); this.createRefs(); } /** * Default state * @param props Component props */ getDefaultState(props) { return { selectedValue: props.value, // The selected value search: "", // The search value open: false, // The open select dropdown style: undefined // The style of the select field }; } /** * Get the list item filtered * @returns {*[]|*} */ get listItemsFiltered() { // Don't keep the selected item in the list const isNotSelectedItem = item => item.value !== this.state.selectedValue; const itemsFiltered = this.props.items.filter(isNotSelectedItem); if (this.props.search && this.state.search !== "") { return this.getItemsMatch(itemsFiltered, this.state.search); } return itemsFiltered; } /** * Get the selected item label * @returns {*|string} */ get selectedItemLabel() { const item = this.props.items && this.props.items.find(item => item.value === this.state.selectedValue); return item && item.label || <>&nbsp;</>; } /** * Get derived state from props * @param props * @param state * @returns {{selectedItem}|null} */ static getDerivedStateFromProps(props, state) { if (props.value !== undefined && props.value !== state.selectedValue) { return { selectedValue: props.value, }; } // Return null if the state hasn't changed return null; } /** * Bind class methods callback */ bindCallback() { this.handleDocumentClickEvent = this.handleDocumentClickEvent.bind(this); this.handleDocumentContextualMenuEvent = this.handleDocumentContextualMenuEvent.bind(this); this.handleDocumentDragStartEvent = this.handleDocumentDragStartEvent.bind(this); this.handleDocumentScrollEvent = this.handleDocumentScrollEvent.bind(this); this.handleSelectClick = this.handleSelectClick.bind(this); this.handleInputChange = this.handleInputChange.bind(this); this.handleItemClick = this.handleItemClick.bind(this); this.handleSelectKeyDown = this.handleSelectKeyDown.bind(this); this.handleItemKeyDown = this.handleItemKeyDown.bind(this); this.handleBlur = this.handleBlur.bind(this); } /** * Create DOM nodes or React elements references in order to be able to access them programmatically. */ createRefs() { this.selectedItemRef = React.createRef(); this.selectItemsRef = React.createRef(); this.itemsRef = React.createRef(); } /** * Component did mount */ componentDidMount() { document.addEventListener('click', this.handleDocumentClickEvent, {capture: true}); document.addEventListener('contextmenu', this.handleDocumentContextualMenuEvent, {capture: true}); document.addEventListener('dragstart', this.handleDocumentDragStartEvent, {capture: true}); document.addEventListener('scroll', this.handleDocumentScrollEvent, {capture: true}); } /** * componentWillUnmount * Invoked immediately before the component is removed from the tree * @return {void} */ componentWillUnmount() { document.removeEventListener('click', this.handleDocumentClickEvent, {capture: true}); document.removeEventListener('contextmenu', this.handleDocumentContextualMenuEvent, {capture: true}); document.removeEventListener('dragstart', this.handleDocumentDragStartEvent, {capture: true}); document.removeEventListener('scroll', this.handleDocumentScrollEvent, {capture: true}); } /** * Handle click events on document. Hide the component if the click occurred outside of the component. * @param {ReactEvent} event The event */ handleDocumentClickEvent(event) { // Prevent closing when the user click on an element of the select if (this.selectedItemRef.current.contains(event.target)) { return; } if (this.selectItemsRef.current.contains(event.target)) { return; } this.closeSelect(); } /** * Handle contextual menu events on document. Hide the component if the click occurred outside of the component. * @param {ReactEvent} event The event */ handleDocumentContextualMenuEvent(event) { // Prevent closing when the user right clicks on an element of the select component if (this.selectedItemRef.current.contains(event.target)) { return; } if (this.selectItemsRef.current.contains(event.target)) { return; } this.closeSelect(); } /** * Handle drag start event on document. Hide the component if any. */ handleDocumentDragStartEvent() { this.closeSelect(); } /** * Handle scroll event on document. Hide the component if any. */ handleDocumentScrollEvent(event) { if (this.itemsRef.current.contains(event.target)) { return; } this.closeSelect(); } /** * Toggle select */ handleSelectClick() { if (!this.props.disabled) { const open = !this.state.open; open ? this.forceVisibilitySelect() : this.resetStyleSelect(); this.setState({open}); } else { this.closeSelect(); } } /** * Get the first parent from this element that have a transfrom CSS property set. * It's useful for calculation of a correction of an offset for the dropdown */ getFirstParentWithTransform() { let currentElement = this.selectedItemRef.current.parentElement; while (currentElement !== null && currentElement.style.getPropertyValue("transform") === "") { currentElement = currentElement.parentElement; } return currentElement; } /** * Force the visibility of the select with fixed position */ forceVisibilitySelect() { const boundingRect = this.selectedItemRef.current.getBoundingClientRect(); const {width, height} = boundingRect; let {top, left} = boundingRect; const relativeParent = this.getFirstParentWithTransform(); if (relativeParent) { const relativeParentPosition = relativeParent.getBoundingClientRect(); top -= relativeParentPosition.top; left -= relativeParentPosition.left; } const style = { position: 'fixed', zIndex: 1, width, height, top, left }; this.setState({style}); } /** * Handle blur * @param event */ handleBlur(event) { if (!event.currentTarget.contains(event.relatedTarget)) { this.closeSelect(); } } /** * Close select */ closeSelect() { this.resetStyleSelect(); this.setState({open: false}); } /** * Reset the style of the select */ resetStyleSelect() { const style = undefined; this.setState({style}); } /** * Handle form input changes. * @params {ReactEvent} The react event * @returns {void} */ handleInputChange(event) { const target = event.target; const value = target.value; const name = target.name; this.setState({[name]: value}); } /** * Handle item click. * @params {event} The react event * @params {item} The item selected * @returns {void} */ handleItemClick(item) { this.setState({selectedValue: item.value, open: false}); if (typeof this.props.onChange == 'function') { const event = {target: {value: item.value, name: this.props.name}}; this.props.onChange(event); } this.closeSelect(); } /** * Get items who match the search * @param items * @param keyword * @returns {*} */ getItemsMatch(items, keyword) { const words = (keyword && keyword.split(/\s+/)) || ['']; // Test match of some escaped test words const escapeWord = word => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const wordToRegex = word => new RegExp(escapeWord(word), 'i'); const matchWord = (word, value) => wordToRegex(word).test(value); const matchText = item => words.every(word => matchWord(word, item.label)); return items.filter(matchText); } /** * Handle click on selected item * @param event The React event */ handleSelectKeyDown(event) { switch (event.keyCode) { // ENTER KEYBOARD case 13: event.stopPropagation(); // avoid side effect this.handleSelectClick(); return; // DOWN ARROW KEYBOARD case 40: event.preventDefault(); // avoid scrolling with keyboard event.stopPropagation(); // avoid side effect this.state.open ? this.focusItem(0) : this.handleSelectClick(); return; // UP ARROW KEYBOARD case 38: event.preventDefault(); // avoid scrolling with keyboard event.stopPropagation(); // avoid side effect this.state.open ? this.focusItem(this.listItemsFiltered.length - 1) : this.handleSelectClick(); return; // ESCAPE KEYBOARD case 27: event.stopPropagation(); // avoid side effect this.closeSelect(); return; default: return; } } /** * Focus the item at the index * @param index The index */ focusItem(index) { this.itemsRef.current.childNodes[index]?.focus(); } /** * Handle click on option item * @param event The React event * @param item The item */ handleItemKeyDown(event, item) { switch (event.keyCode) { // ENTER KEYBOARD case 13: // Prevent handle key down on parent element event.stopPropagation(); // avoid side effect this.handleItemClick(item); return; // DOWN ARROW KEYBOARD case 40: event.stopPropagation(); // avoid side effect event.preventDefault(); // avoid scrolling with keyboard event.target.nextSibling ? event.target.nextSibling.focus() : this.focusItem(0); return; // UP ARROW KEYBOARD case 38: event.stopPropagation(); // avoid side effect event.preventDefault(); // avoid scrolling with keyboard event.target.previousSibling ? event.target.previousSibling.focus() : this.focusItem(this.listItemsFiltered.length - 1); return; default: return; } } /** * Has filtered items * @returns {boolean} */ hasFilteredItems() { return this.listItemsFiltered.length > 0; } render() { return ( <div className={`select-container ${this.props.className}`} style={{width: this.state.style?.width, height: this.state.style?.height}}> <div onKeyDown={this.handleSelectKeyDown} onBlur={this.handleBlur} id={this.props.id} className={`select ${this.props.direction} ${this.state.open ? 'open' : ''}`} style={this.state.style}> <div ref={this.selectedItemRef} className={`selected-value ${this.props.disabled ? 'disabled' : ''}`} tabIndex={this.props.disabled ? -1 : 0} onClick={this.handleSelectClick}> <span className="value">{this.selectedItemLabel}</span> <Icon name="caret-down"/> </div> <div ref={this.selectItemsRef} className={`select-items ${this.state.open ? 'visible' : ''}`}> {this.props.search && <> <input className="search-input" name="search" value={this.state.search} onChange={this.handleInputChange} type="text"/> <Icon name="search"/> </> } <ul ref={this.itemsRef} className="items"> {this.hasFilteredItems() && this.listItemsFiltered.map(item => <li tabIndex={item.disabled ? -1 : 0} key={item.value} className="option" onKeyDown={event => this.handleItemKeyDown(event, item)} onClick={() => this.handleItemClick(item)}> {item.label} </li> ) } {!this.hasFilteredItems() && this.props.search && <li className="option no-results"> <Trans>No results match</Trans> <span>{this.state.search}</span> </li> } </ul> </div> </div> </div> ); } } Select.defaultProps = { id: "", name: "select", className: "", direction: 'bottom' }; /** * Is value props in items props * @param props * @param propName * @param componentName * @returns {Error} */ const isValueInItems = (props, propName, componentName) => { const value = props[propName]; // value must be in the items const items = props.items; if (items.length > 0 && items.every(item => item.value !== value)) { return new Error( `Invalid prop ${propName} passed to ${componentName}. Expected the value ${value} in items.` ); } }; /** * enumeration for select direction */ export const DirectionEnum = { top: 'top', bottom: 'bottom', left: 'left', right: 'right' }; Select.propTypes = { id: PropTypes.string, // The select field id name: PropTypes.string, // The select field name className: PropTypes.string, // The class name direction: PropTypes.oneOf(Object.values(DirectionEnum)), search: PropTypes.bool, // The search field property items: PropTypes.array.isRequired, // The item list of the select field value: CustomPropTypes.allPropTypes( PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired, isValueInItems ), // The item selected of the select field disabled: PropTypes.bool, // The current select field disabled property onChange: PropTypes.func, // The on change event callback }; export default withTranslation("common")(Select);