/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React, { Component } from 'react'; import classNames from 'classnames'; import { CommonProps } from '../../common'; import { OuiScreenReaderOnly } from '../../accessibility'; import { OuiSuperSelectControl, OuiSuperSelectControlProps, OuiSuperSelectOption, } from './super_select_control'; import { OuiInputPopover } from '../../popover'; import { OuiContextMenuItem, OuiContextMenuItemLayoutAlignment, } from '../../context_menu'; import { keys } from '../../../services'; import { OuiI18n } from '../../i18n'; enum ShiftDirection { BACK = 'back', FORWARD = 'forward', } export type OuiSuperSelectProps = CommonProps & Omit< OuiSuperSelectControlProps, 'onChange' | 'onClick' | 'options' | 'value' > & { /** * Pass an array of options that must at least include: * `value`: storing unique value of item, * `inputDisplay`: what shows inside the form input when selected * `dropdownDisplay` (optional): what shows for the item in the dropdown */ options: Array>; valueOfSelected?: T; /** * Classes for the context menu item */ itemClassName?: string; /** * You must pass an `onChange` function to handle the update of the value */ onChange?: (value: T) => void; /** * Change to `true` if you want horizontal lines between options. * This is best used when options are multi-line. */ hasDividers?: boolean; /** * Change `OuiContextMenuItem` layout position of icon */ itemLayoutAlign?: OuiContextMenuItemLayoutAlignment; /** * Applied to the outermost wrapper (popover) */ popoverClassName?: string; /** * Controls whether the options are shown. Default: false */ isOpen?: boolean; }; export class OuiSuperSelect extends Component< OuiSuperSelectProps > { static defaultProps = { hasDividers: false, fullWidth: false, compressed: false, isInvalid: false, isLoading: false, }; private itemNodes: Array = []; private _isMounted: boolean = false; state = { isPopoverOpen: this.props.isOpen || false, }; componentDidMount() { this._isMounted = true; if (this.props.isOpen) { this.openPopover(); } } componentWillUnmount() { this._isMounted = false; } setItemNode = (node: HTMLButtonElement | null, index: number) => { this.itemNodes[index] = node; }; openPopover = () => { this.setState({ isPopoverOpen: true, }); const focusSelected = () => { const indexOfSelected = this.props.options.reduce( (indexOfSelected, option, index) => { if (indexOfSelected != null) return indexOfSelected; if (option == null) return null; return option.value === this.props.valueOfSelected ? index : null; }, null ); requestAnimationFrame(() => { if (!this._isMounted) { return; } if (this.props.valueOfSelected != null) { if (indexOfSelected != null) { this.focusItemAt(indexOfSelected); } else { focusSelected(); } } }); }; requestAnimationFrame(focusSelected); }; closePopover = () => { this.setState({ isPopoverOpen: false, }); }; itemClicked = (value: T) => { this.setState({ isPopoverOpen: false, }); if (this.props.onChange) { this.props.onChange(value); } }; onSelectKeyDown = (event: React.KeyboardEvent) => { if (event.key === keys.ARROW_UP || event.key === keys.ARROW_DOWN) { event.preventDefault(); event.stopPropagation(); this.openPopover(); } }; onItemKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case keys.ESCAPE: // close the popover and prevent ancestors from handling event.preventDefault(); event.stopPropagation(); this.closePopover(); break; case keys.TAB: // no-op event.preventDefault(); event.stopPropagation(); break; case keys.ARROW_UP: event.preventDefault(); event.stopPropagation(); this.shiftFocus(ShiftDirection.BACK); break; case keys.ARROW_DOWN: event.preventDefault(); event.stopPropagation(); this.shiftFocus(ShiftDirection.FORWARD); break; } }; focusItemAt(index: number) { const targetElement = this.itemNodes[index]; if (targetElement != null) { targetElement.focus(); } } shiftFocus(direction: ShiftDirection) { const currentIndex = this.itemNodes.indexOf( document.activeElement as HTMLButtonElement ); let targetElementIndex: number; if (currentIndex === -1) { // somehow the select options has lost focus targetElementIndex = 0; } else { if (direction === ShiftDirection.BACK) { targetElementIndex = currentIndex === 0 ? this.itemNodes.length - 1 : currentIndex - 1; } else { targetElementIndex = currentIndex === this.itemNodes.length - 1 ? 0 : currentIndex + 1; } } this.focusItemAt(targetElementIndex); } render() { const { className, options, valueOfSelected, onChange, isOpen, isInvalid, hasDividers, itemClassName, itemLayoutAlign, fullWidth, popoverClassName, compressed, ...rest } = this.props; const popoverClasses = classNames('ouiSuperSelect', popoverClassName); const buttonClasses = classNames( { 'ouiSuperSelect--isOpen__button': this.state.isPopoverOpen, }, className ); const itemClasses = classNames( 'ouiSuperSelect__item', { 'ouiSuperSelect__item--hasDividers': hasDividers, }, itemClassName ); const button = ( ); const items = options.map((option, index) => { const { value, dropdownDisplay, inputDisplay, ...optionRest } = option; return ( this.itemClicked(value)} onKeyDown={this.onItemKeyDown} layoutAlign={itemLayoutAlign} buttonRef={(node) => this.setItemNode(node, index)} role="option" id={value} aria-selected={valueOfSelected === value} {...optionRest}> {dropdownDisplay || inputDisplay} ); }); return (

{items}
); } }