passbolt-styleguide
Version:
Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.
499 lines (459 loc) • 15.1 kB
JavaScript
/**
* 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 { Trans, withTranslation } from "react-i18next";
import CustomPropTypes from "../../../../shared/lib/PropTypes/CustomPropTypes";
import SearchSVG from "../../../../img/svg/search.svg";
import CaretDownSVG from "../../../../img/svg/caret_down.svg";
/**
* 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) || <> </>;
}
/**
* 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) || this.selectItemsRef.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>
<CaretDownSVG />
</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"
/>
<SearchSVG className="search" />
</>
)}
<ul ref={this.itemsRef} className="items">
{this.hasFilteredItems() &&
this.listItemsFiltered.map((item) => (
<li
tabIndex={item.disabled ? -1 : 0}
key={item.value}
title={item.title}
className={`option ${item.value}`}
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 (value !== null && 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, // The item list of the select field
value: CustomPropTypes.allPropTypes(
PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
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);