diff --git a/CHANGELOG.md b/CHANGELOG.md index 378c078651..771211d278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Features - Add `on` and `mouseLeaveDelay` props to `Popup` component @mnajdova ([#622](https://github.com/stardust-ui/react/pull/622)) +- Add Dropdown Single Selection variant @silviuavram ([#584](https://github.com/stardust-ui/react/pull/584)) ### Fixes - Fix unicode arrow characters to be RTL aware ([#690](https://github.com/stardust-ui/react/pull/690)) diff --git a/docs/src/examples/components/Dropdown/Types/DropdownExampleMultipleSearch.shorthand.tsx b/docs/src/examples/components/Dropdown/Types/DropdownExampleMultipleSearch.shorthand.tsx index 4dc983cd0c..6aba57a1d9 100644 --- a/docs/src/examples/components/Dropdown/Types/DropdownExampleMultipleSearch.shorthand.tsx +++ b/docs/src/examples/components/Dropdown/Types/DropdownExampleMultipleSearch.shorthand.tsx @@ -13,21 +13,17 @@ const inputItems = [ 'Selina Kyle', ] -class DropdownExample extends React.Component { - render() { - return ( - <Dropdown - multiple - search - getA11ySelectionMessage={getA11ySelectionMessage} - getA11yStatusMessage={getA11yStatusMessage} - noResultsMessage="We couldn't find any matches." - placeholder="Start typing a name" - items={inputItems} - /> - ) - } -} +const DropdownExample = () => ( + <Dropdown + multiple + search + getA11ySelectionMessage={getA11ySelectionMessage} + getA11yStatusMessage={getA11yStatusMessage} + noResultsMessage="We couldn't find any matches." + placeholder="Start typing a name" + items={inputItems} + /> +) const getA11ySelectionMessage = { onAdd: item => `${item} has been selected.`, diff --git a/docs/src/examples/components/Dropdown/Types/DropdownExampleSingleSelection.shorthand.tsx b/docs/src/examples/components/Dropdown/Types/DropdownExampleSingleSelection.shorthand.tsx new file mode 100644 index 0000000000..c4f046d982 --- /dev/null +++ b/docs/src/examples/components/Dropdown/Types/DropdownExampleSingleSelection.shorthand.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { Dropdown } from '@stardust-ui/react' + +const inputItems = [ + 'Bruce Wayne', + 'Natasha Romanoff', + 'Steven Strange', + 'Alfred Pennyworth', + `Scarlett O'Hara`, + 'Imperator Furiosa', + 'Bruce Banner', + 'Peter Parker', + 'Selina Kyle', +] + +const DropdownExample = () => ( + <Dropdown + getA11yStatusMessage={getA11yStatusMessage} + getA11ySelectionMessage={{ + onAdd: item => `${item} has been selected.`, + }} + placeholder="Select your hero" + items={inputItems} + /> +) + +const getA11yStatusMessage = ({ + isOpen, + itemToString, + previousResultCount, + resultCount, + selectedItem, +}) => { + if (!isOpen) { + return selectedItem ? itemToString(selectedItem) : '' + } + if (!resultCount) { + return 'No results are available.' + } + if (resultCount !== previousResultCount) { + return `${resultCount} result${ + resultCount === 1 ? ' is' : 's are' + } available, use up and down arrow keys to navigate. Press Enter key to select.` + } + return '' +} + +export default DropdownExample diff --git a/docs/src/examples/components/Dropdown/Types/index.tsx b/docs/src/examples/components/Dropdown/Types/index.tsx index 6a93a98f50..9b11169a48 100644 --- a/docs/src/examples/components/Dropdown/Types/index.tsx +++ b/docs/src/examples/components/Dropdown/Types/index.tsx @@ -4,10 +4,15 @@ import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection' const Types = () => ( <ExampleSection title="Types"> + <ComponentExample + title="Single Selection" + description="A dropdown with single selection." + examplePath="components/Dropdown/Types/DropdownExampleSingleSelection" + /> <ComponentExample title="Multiple Search" description="A dropdown with multiple selection and search." - examplePath="components/Dropdown/Types/DropdownExampleMultipleSearch.shorthand" + examplePath="components/Dropdown/Types/DropdownExampleMultipleSearch" /> </ExampleSection> ) diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchFluid.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchFluid.shorthand.tsx index 7dd60f245a..5d1de96419 100644 --- a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchFluid.shorthand.tsx +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchFluid.shorthand.tsx @@ -12,23 +12,18 @@ const inputItems = [ 'Peter Parker', 'Selina Kyle', ] - -class DropdownExample extends React.Component { - render() { - return ( - <Dropdown - multiple - getA11ySelectionMessage={getA11ySelectionMessage} - getA11yStatusMessage={getA11yStatusMessage} - noResultsMessage="We couldn't find any matches." - search - fluid - placeholder="Start typing a name" - items={inputItems} - /> - ) - } -} +const DropdownExample = () => ( + <Dropdown + multiple + getA11ySelectionMessage={getA11ySelectionMessage} + getA11yStatusMessage={getA11yStatusMessage} + noResultsMessage="We couldn't find any matches." + search + fluid + placeholder="Start typing a name" + items={inputItems} + /> +) const getA11ySelectionMessage = { onAdd: item => `${item} has been selected.`, diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent.shorthand.tsx index ed52d80f56..248055b67a 100644 --- a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent.shorthand.tsx +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent.shorthand.tsx @@ -49,21 +49,17 @@ const inputItems = [ }, ] -class DropdownExample extends React.Component { - render() { - return ( - <Dropdown - multiple - getA11yStatusMessage={getA11yStatusMessage} - search - getA11ySelectionMessage={getA11ySelectionMessage} - noResultsMessage="We couldn't find any matches." - placeholder="Start typing a name" - items={inputItems} - /> - ) - } -} +const DropdownExample = () => ( + <Dropdown + multiple + getA11yStatusMessage={getA11yStatusMessage} + search + getA11ySelectionMessage={getA11ySelectionMessage} + noResultsMessage="We couldn't find any matches." + placeholder="Start typing a name" + items={inputItems} + /> +) const getA11ySelectionMessage = { onAdd: item => `${item.header} has been selected.`, diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx index 1f85c9cc1b..3fb6470421 100644 --- a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx +++ b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx @@ -13,22 +13,18 @@ const inputItems = [ 'Selina Kyle', ] -class DropdownExample extends React.Component { - render() { - return ( - <Dropdown - multiple - getA11yStatusMessage={getA11yStatusMessage} - getA11ySelectionMessage={getA11ySelectionMessage} - noResultsMessage="We couldn't find any matches." - search - placeholder="Start typing a name" - toggleButton - items={inputItems} - /> - ) - } -} +const DropdownExample = () => ( + <Dropdown + multiple + getA11yStatusMessage={getA11yStatusMessage} + getA11ySelectionMessage={getA11ySelectionMessage} + noResultsMessage="We couldn't find any matches." + search + placeholder="Start typing a name" + toggleButton + items={inputItems} + /> +) const getA11ySelectionMessage = { onAdd: item => `${item} has been selected.`, diff --git a/docs/src/examples/components/Dropdown/Variations/index.tsx b/docs/src/examples/components/Dropdown/Variations/index.tsx index cd3ff955f8..ca3ecfd32f 100644 --- a/docs/src/examples/components/Dropdown/Variations/index.tsx +++ b/docs/src/examples/components/Dropdown/Variations/index.tsx @@ -7,17 +7,17 @@ const Variations = () => ( <ComponentExample title="Multiple Search with Image and Content" description="A multiple search dropdown which items have header, content and image." - examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent.shorthand" + examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchImageAndContent" /> <ComponentExample title="Multiple Search Fluid" description="A multiple search dropdown that fits the width of the container." - examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchFluid.shorthand" + examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchFluid" /> <ComponentExample title="Multiple Search with Toggle Button" description="A multiple search dropdown with toggle button that shows/hides the items list." - examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand" + examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton" /> </ExampleSection> ) diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index bb7aded10a..98b3183ffc 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -3,7 +3,11 @@ import * as PropTypes from 'prop-types' import * as _ from 'lodash' import { Extendable, ShorthandValue, ComponentEventHandler } from '../../../types/utils' -import { ComponentSlotStylesInput, ComponentVariablesInput } from '../../themes/types' +import { + ComponentSlotStylesInput, + ComponentVariablesInput, + ComponentSlotClasses, +} from '../../themes/types' import Downshift, { DownshiftState, StateChangeOptions, @@ -19,17 +23,17 @@ import { RenderResultConfig, customPropTypes, commonPropTypes, + handleRef, } from '../../lib' import keyboardKey from 'keyboard-key' import List from '../List/List' import Text from '../Text/Text' -import Icon from '../Icon/Icon' import Ref from '../Ref/Ref' import { UIComponentProps } from '../../lib/commonPropInterfaces' -import DropdownItem, { DropdownItemProps } from './DropdownItem' +import DropdownItem from './DropdownItem' import DropdownLabel, { DropdownLabelProps } from './DropdownLabel' -import DropdownSearchInput from './DropdownSearchInput' -import { DropdownSearchInputProps } from 'semantic-ui-react' +import DropdownSearchInput, { DropdownSearchInputProps } from './DropdownSearchInput' +import Button from '../Button/Button' // TODO: To be replaced when Downshift will add highlightedItem in their interface. export interface A11yStatusMessageOptions<Item> extends DownshiftA11yStatusMessageOptions<Item> { @@ -130,7 +134,9 @@ export default class Dropdown extends AutoControlledComponent< Extendable<DropdownProps>, DropdownState > { - private inputNode: HTMLElement + private buttonRef = React.createRef<HTMLElement>() + private inputRef = React.createRef<HTMLElement>() + private listRef = React.createRef<HTMLElement>() static displayName = 'Dropdown' @@ -210,12 +216,18 @@ export default class Dropdown extends AutoControlledComponent< <ElementType className={classes.root} {...rest}> <Downshift onChange={this.handleSelectedChange} - inputValue={searchQuery} + inputValue={search ? searchQuery : undefined} stateReducer={this.handleDownshiftStateChanges} itemToString={itemToString} - // Downshift does not support multiple selection. We will handle everything and pass it selected as null in this case. - selectedItem={multiple ? null : undefined} + // If it's single search, don't pass anything. Pass a null otherwise, as Downshift does + // not handle selection by default for single/multiple selection and multiple search. + selectedItem={search && !multiple ? undefined : null} getA11yStatusMessage={getA11yStatusMessage} + onStateChange={changes => { + if (changes.isOpen && !search) { + this.listRef.current.focus() + } + }} > {({ getInputProps, @@ -224,32 +236,44 @@ export default class Dropdown extends AutoControlledComponent< getRootProps, getToggleButtonProps, isOpen, + toggleMenu, highlightedIndex, selectItemAtIndex, }) => { + const { innerRef, ...accessibilityRootPropsRest } = getRootProps( + { refKey: 'innerRef' }, + { suppressRefError: true }, + ) return ( - <div - className={classes.containerDiv} - onClick={this.handleContainerClick.bind(this, isOpen)} - > - {multiple && this.renderSelectedItems(styles)} - {search && - this.renderSearchInput( - getRootProps, - getInputProps, + <Ref innerRef={innerRef}> + <div + className={classes.container} + onClick={multiple ? this.handleContainerClick.bind(this, isOpen) : undefined} + > + {multiple && this.renderSelectedItems(styles)} + {search + ? this.renderSearchInput( + accessibilityRootPropsRest, + getInputProps, + highlightedIndex, + selectItemAtIndex, + variables, + ) + : this.renderTriggerButton(styles, getToggleButtonProps)} + {toggleButton && this.renderToggleButton(getToggleButtonProps, classes, isOpen)} + {this.renderItemsList( + styles, + variables, + isOpen, highlightedIndex, + toggleMenu, selectItemAtIndex, + getMenuProps, + getItemProps, + getInputProps, )} - {toggleButton && this.renderToggleButton(getToggleButtonProps, styles, isOpen)} - {this.renderItemsList( - styles, - variables, - getMenuProps, - getItemProps, - isOpen, - highlightedIndex, - )} - </div> + </div> + </Ref> ) }} </Downshift> @@ -257,8 +281,36 @@ export default class Dropdown extends AutoControlledComponent< ) } + private renderTriggerButton( + styles: ComponentSlotStylesInput, + getToggleButtonProps: (options?: GetToggleButtonPropsOptions) => any, + ): JSX.Element { + const { placeholder, itemToString, multiple } = this.props + const { value } = this.state + const content = value && !multiple ? itemToString(value) : placeholder + + return ( + <Ref innerRef={this.buttonRef}> + <Button + content={content} + fluid + styles={styles.button} + {...getToggleButtonProps({ + onFocus: () => { + this.setState({ focused: true }) + }, + onBlur: () => { + this.setState({ focused: false }) + }, + 'aria-label': content, + })} + /> + </Ref> + ) + } + private renderSearchInput( - getRootProps: (options?: GetMenuPropsOptions, otherOptions?: GetPropsCommonOptions) => any, + accessibilityComboboxProps: Object, getInputProps: (options?: GetInputPropsOptions) => any, highlightedIndex: number, selectItemAtIndex: ( @@ -266,8 +318,9 @@ export default class Dropdown extends AutoControlledComponent< otherStateToSet?: Partial<StateChangeOptions<any>>, cb?: () => void, ) => void, + variables, ): JSX.Element { - const { searchInput, multiple, placeholder } = this.props + const { searchInput, multiple, placeholder, toggleButton } = this.props const { searchQuery, value } = this.state const noPlaceholder = @@ -276,16 +329,16 @@ export default class Dropdown extends AutoControlledComponent< return DropdownSearchInput.create(searchInput || {}, { defaultProps: { placeholder: noPlaceholder ? '' : placeholder, - inputRef: (inputNode: HTMLElement) => { - this.inputNode = inputNode - }, + hasToggleButton: !!toggleButton, + variables, + inputRef: this.inputRef, }, overrideProps: (predefinedProps: DropdownSearchInputProps) => this.handleSearchInputOverrides( predefinedProps, highlightedIndex, selectItemAtIndex, - getRootProps, + accessibilityComboboxProps, getInputProps, ), }) @@ -293,36 +346,57 @@ export default class Dropdown extends AutoControlledComponent< private renderToggleButton( getToggleButtonProps: (options?: GetToggleButtonPropsOptions) => any, - styles: ComponentSlotStylesInput, + classes: ComponentSlotClasses, isOpen: boolean, ) { + const { onClick } = getToggleButtonProps() return ( - <Icon - name={`chevron ${isOpen ? 'up' : 'down'}`} - as="button" - tabIndex="-1" - styles={styles.toggleButton} - {...getToggleButtonProps()} - /> + <span className={classes.toggleButton} onClick={onClick}> + {isOpen ? String.fromCharCode(9650) : String.fromCharCode(9660)} + </span> ) } private renderItemsList( styles: ComponentSlotStylesInput, variables: ComponentVariablesInput, - getMenuProps: (options?: GetMenuPropsOptions, otherOptions?: GetPropsCommonOptions) => any, - getItemProps: (options: GetItemPropsOptions<ShorthandValue>) => any, isOpen: boolean, highlightedIndex: number, + toggleMenu: () => void, + selectItemAtIndex: (index: number) => void, + getMenuProps: (options?: GetMenuPropsOptions, otherOptions?: GetPropsCommonOptions) => any, + getItemProps: (options: GetItemPropsOptions<ShorthandValue>) => any, + getInputProps: (options?: GetInputPropsOptions) => any, ) { - const accessibilityMenuProps = getMenuProps({ refKey: 'innerRef' }) + const accessibilityMenuProps = getMenuProps({ refKey: 'innerRef' }, { suppressRefError: true }) + const { search } = this.props + // If it's just a selection, some attributes and listeners from Downshift input need to go on the menu list. + if (!search) { + const accessibilityInputProps = getInputProps() + accessibilityMenuProps['aria-activedescendant'] = + accessibilityInputProps['aria-activedescendant'] + accessibilityMenuProps['onKeyDown'] = e => { + this.handleListKeyDown( + e, + highlightedIndex, + accessibilityInputProps['onKeyDown'], + toggleMenu, + selectItemAtIndex, + ) + } + } const { innerRef, ...accessibilityMenuPropsRest } = accessibilityMenuProps - return ( - <Ref innerRef={innerRef}> + <Ref + innerRef={(listElement: HTMLElement) => { + handleRef(this.listRef, listElement) + handleRef(innerRef, listElement) + }} + > <List {...accessibilityMenuPropsRest} styles={styles.list} + tabIndex={search ? undefined : -1} // needs to be focused when trigger button is activated. aria-hidden={!isOpen} items={isOpen ? this.renderItems(styles, variables, getItemProps, highlightedIndex) : []} /> @@ -336,18 +410,12 @@ export default class Dropdown extends AutoControlledComponent< getItemProps: (options: GetItemPropsOptions<ShorthandValue>) => any, highlightedIndex: number, ) { - const { items, noResultsMessage } = this.props - const filteredItems = this.getItemsFilteredBySearchQuery(items) + const { noResultsMessage } = this.props + const filteredItems = this.getItemsFilteredBySearchQuery() if (filteredItems.length > 0) { return filteredItems.map((item, index) => { - let itemAsListItem = item - if (typeof item === 'object') { - itemAsListItem = _.pickBy(item, (value, key) => - _.includes(['key', ...DropdownItem.handledProps], key), - ) - } - return DropdownItem.create(itemAsListItem, { + return DropdownItem.create(item, { defaultProps: { active: highlightedIndex === index, variables, @@ -356,8 +424,7 @@ export default class Dropdown extends AutoControlledComponent< key: (item as any).header, }), }, - overrideProps: (predefinedProps: DropdownItemProps) => - this.handleItemOverrides(item, index, getItemProps), + overrideProps: () => this.handleItemOverrides(item, index, getItemProps), }) }) } @@ -414,30 +481,38 @@ export default class Dropdown extends AutoControlledComponent< { ...this.props, searchQuery: changes.inputValue }, ) return changes + case Downshift.stateChangeTypes.blurButton: + // Downshift closes the list by default on trigger blur. It does not support the case when dropdown is + // single selection and focuses list on trigger click/up/down/space/enter. Treating that here. + if (state.isOpen && document.activeElement === this.listRef.current) { + return {} // won't change state in this case. + } default: return changes } } - private getItemsFilteredBySearchQuery = (items: ShorthandValue[]): ShorthandValue[] => { - const { itemToString, multiple, search } = this.props + private getItemsFilteredBySearchQuery = (): ShorthandValue[] => { + const { items, itemToString, multiple, search } = this.props const { searchQuery, value } = this.state + let filteredItems = items - const nonSelectedItems = items.filter(item => - multiple ? (value as ShorthandValue[]).indexOf(item) === -1 : true, - ) - - const itemsMatchSearchQuery = nonSelectedItems.filter(item => - search - ? _.isFunction(search) - ? search(itemsMatchSearchQuery, searchQuery) - : itemToString(item) - .toLowerCase() - .indexOf(searchQuery.toLowerCase()) !== -1 - : true, - ) + if (multiple) { + filteredItems = _.difference(filteredItems, value as ShorthandValue[]) + } + if (search) { + if (_.isFunction(search)) { + return search(filteredItems, searchQuery) + } + return filteredItems.filter( + item => + itemToString(item) + .toLowerCase() + .indexOf(searchQuery.toLowerCase()) !== -1, + ) + } - return itemsMatchSearchQuery + return filteredItems } private setA11yStatus = (statusMessage: string) => { @@ -496,7 +571,7 @@ export default class Dropdown extends AutoControlledComponent< otherStateToSet?: Partial<StateChangeOptions<any>>, cb?: () => void, ) => void, - getRootProps: (options?: GetMenuPropsOptions, otherOptions?: GetPropsCommonOptions) => any, + accessibilityComboboxProps: Object, getInputProps: (options?: GetInputPropsOptions) => any, ) => { const handleInputBlur = ( @@ -511,7 +586,7 @@ export default class Dropdown extends AutoControlledComponent< e: React.SyntheticEvent, searchInputProps: DropdownSearchInputProps, ) => { - if (keyboardKey.getCode(e) === keyboardKey.Tab && highlightedIndex !== undefined) { + if (keyboardKey.getCode(e) === keyboardKey.Tab && !_.isNil(highlightedIndex)) { selectItemAtIndex(highlightedIndex) } @@ -536,9 +611,7 @@ export default class Dropdown extends AutoControlledComponent< }), }, // same story as above for getRootProps. - accessibilityWrapperProps: { - ...getRootProps({ refKey: 'innerRef' }, { suppressRefError: true }), - }, + accessibilityComboboxProps, onFocus: (e: React.SyntheticEvent, searchInputProps: DropdownSearchInputProps) => { this.setState({ focused: true }) @@ -571,11 +644,36 @@ export default class Dropdown extends AutoControlledComponent< } private handleContainerClick = (isOpen: boolean) => { - !isOpen && this.inputNode.focus() + !isOpen && this.inputRef.current.focus() + } + + private handleListKeyDown = ( + e: React.SyntheticEvent, + highlightedIndex: number, + accessibilityInputPropsKeyDown: (e) => any, + toggleMenu: () => void, + selectItemAtIndex: (index: number) => void, + ) => { + switch (keyboardKey.getCode(e)) { + case keyboardKey.Tab: + if (_.isNil(highlightedIndex)) { + toggleMenu() + } else { + selectItemAtIndex(highlightedIndex) + } + return + case keyboardKey.Escape: + accessibilityInputPropsKeyDown(e) + this.buttonRef.current.focus() + return + default: + accessibilityInputPropsKeyDown(e) + return + } } private handleSelectedChange = (item: ShorthandValue) => { - const { multiple, getA11ySelectionMessage } = this.props + const { multiple, getA11ySelectionMessage, search } = this.props const newValue = multiple ? [...(this.state.value as ShorthandValue[]), item] : item this.trySetState({ @@ -585,6 +683,9 @@ export default class Dropdown extends AutoControlledComponent< if (getA11ySelectionMessage && getA11ySelectionMessage.onAdd) { this.setA11yStatus(getA11ySelectionMessage.onAdd(item)) } + if (!search) { + this.buttonRef.current.focus() + } // we don't have event for it, but want to keep the event handling interface, event is empty. _.invoke(this.props, 'onSelectedChange', {}, { ...this.props, value: newValue }) @@ -592,7 +693,7 @@ export default class Dropdown extends AutoControlledComponent< private handleSelectedItemRemove(e: React.SyntheticEvent, item: ShorthandValue) { this.removeItemFromValue(item) - this.inputNode.focus() + this.inputRef.current.focus() e.stopPropagation() } @@ -611,7 +712,7 @@ export default class Dropdown extends AutoControlledComponent< value, }) if (getA11ySelectionMessage && getA11ySelectionMessage.onRemove) { - this.setA11yStatus(getA11ySelectionMessage.onRemove(item)) + this.setA11yStatus(getA11ySelectionMessage.onRemove(poppedItem)) } // we don't have event for it, but want to keep the event handling interface, event is empty. diff --git a/src/components/Dropdown/DropdownSearchInput.tsx b/src/components/Dropdown/DropdownSearchInput.tsx index 63cec50f1d..5d967a245d 100644 --- a/src/components/Dropdown/DropdownSearchInput.tsx +++ b/src/components/Dropdown/DropdownSearchInput.tsx @@ -6,15 +6,13 @@ import { UIComponent, RenderResultConfig, createShorthandFactory, commonPropType import { ComponentEventHandler, ReactProps } from '../../../types/utils' import { UIComponentProps } from '../../lib/commonPropInterfaces' import Input from '../Input/Input' -import Ref from '../Ref/Ref' export interface DropdownSearchInputProps extends UIComponentProps<DropdownSearchInputProps> { - /** - * Ref callback with an input DOM node. - * - * @param {JSX.Element} node - input DOM node. - */ - inputRef?: (inputNode: HTMLElement) => void + /** Informs the search input about an existing toggle button. */ + hasToggleButton?: boolean + + /** Ref for input DOM node. */ + inputRef?: React.Ref<HTMLElement> /** * Called on input element focus. @@ -68,8 +66,9 @@ class DropdownSearchInput extends UIComponent<ReactProps<DropdownSearchInputProp content: false, }), accessibilityInputProps: PropTypes.object, - accessibilityWrapperProps: PropTypes.object, - inputRef: PropTypes.func, + accessibilityComboboxProps: PropTypes.object, + hasToggleButton: PropTypes.bool, + inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), onFocus: PropTypes.func, onInputBlur: PropTypes.func, onInputKeyDown: PropTypes.func, @@ -77,10 +76,6 @@ class DropdownSearchInput extends UIComponent<ReactProps<DropdownSearchInputProp placeholder: PropTypes.string, } - private handleInputRef = (inputNode: HTMLElement) => { - _.invoke(this.props, 'inputRef', inputNode) - } - private handleFocus = (e: React.SyntheticEvent) => { _.invoke(this.props, 'onFocus', e, this.props) } @@ -98,29 +93,31 @@ class DropdownSearchInput extends UIComponent<ReactProps<DropdownSearchInputProp } public renderComponent({ rest, styles }: RenderResultConfig<DropdownSearchInputProps>) { - const { accessibilityWrapperProps, accessibilityInputProps, placeholder } = this.props - const { innerRef, ...accessibilityWrapperPropsRest } = accessibilityWrapperProps + const { + accessibilityComboboxProps, + accessibilityInputProps, + inputRef, + placeholder, + } = this.props return ( - <Ref innerRef={innerRef}> - <Input - inputRef={this.handleInputRef} - onFocus={this.handleFocus} - onKeyUp={this.handleKeyUp} - wrapper={{ - styles: styles.wrapper, - ...accessibilityWrapperPropsRest, - }} - input={{ - type: 'text', - styles: styles.input, - placeholder, - onBlur: this.handleInputBlur, - onKeyDown: this.handleInputKeyDown, - ...accessibilityInputProps, - }} - {...rest} - /> - </Ref> + <Input + inputRef={inputRef} + onFocus={this.handleFocus} + onKeyUp={this.handleKeyUp} + wrapper={{ + styles: styles.combobox, + ...accessibilityComboboxProps, + }} + input={{ + type: 'text', + styles: styles.input, + placeholder, + onBlur: this.handleInputBlur, + onKeyDown: this.handleInputKeyDown, + ...accessibilityInputProps, + }} + {...rest} + /> ) } } diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 8eee55fa30..73da8e5f84 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -11,6 +11,7 @@ import { UIComponentProps, ChildrenComponentProps, commonPropTypes, + handleRef, } from '../../lib' import { ReactProps, ShorthandValue, ComponentEventHandler } from '../../../types/utils' import Icon from '../Icon/Icon' @@ -50,12 +51,8 @@ export interface InputProps extends UIComponentProps, ChildrenComponentProps { /** The HTML input type. */ type?: string - /** - * Ref callback with an input DOM node. - * - * @param {JSX.Element} node - input DOM node. - */ - inputRef?: (node: HTMLElement) => void + /** Ref for input DOM node. */ + inputRef?: React.Ref<HTMLElement> /** The value of the input. */ value?: React.ReactText @@ -77,7 +74,7 @@ export interface InputState { * - if input is search, then use "role='search'" */ class Input extends AutoControlledComponent<ReactProps<InputProps>, InputState> { - private inputDomElement: HTMLInputElement + private inputRef = React.createRef<HTMLElement>() static className = 'ui-input' @@ -93,7 +90,7 @@ class Input extends AutoControlledComponent<ReactProps<InputProps>, InputState> icon: customPropTypes.itemShorthand, iconPosition: PropTypes.oneOf(['start', 'end']), input: customPropTypes.itemShorthand, - inputRef: PropTypes.func, + inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), inline: PropTypes.bool, onChange: PropTypes.func, type: PropTypes.string, @@ -116,7 +113,7 @@ class Input extends AutoControlledComponent<ReactProps<InputProps>, InputState> styles, variables, }: RenderResultConfig<InputProps>) { - const { className, input, type, wrapper } = this.props + const { className, input, inputRef, type, wrapper } = this.props const { value = '' } = this.state const [htmlInputProps, rest] = partitionHTMLProps(restProps) @@ -125,7 +122,12 @@ class Input extends AutoControlledComponent<ReactProps<InputProps>, InputState> className: cx(Input.className, className), children: ( <> - <Ref innerRef={this.handleInputRef}> + <Ref + innerRef={(inputElement: HTMLElement) => { + handleRef(this.inputRef, inputElement) + handleRef(inputRef, inputElement) + }} + > {Slot.create(input || type, { defaultProps: { ...htmlInputProps, @@ -155,16 +157,10 @@ class Input extends AutoControlledComponent<ReactProps<InputProps>, InputState> }) } - private handleInputRef = (inputNode: HTMLElement) => { - this.inputDomElement = inputNode as HTMLInputElement - - _.invoke(this.props, 'inputRef', inputNode) - } - private handleIconOverrides = predefinedProps => ({ onClick: (e: React.SyntheticEvent) => { this.handleOnClear() - this.inputDomElement.focus() + this.inputRef.current.focus() _.invoke(predefinedProps, 'onClick', e, this.props) }, ...(predefinedProps.onClick && { tabIndex: '0' }), diff --git a/src/themes/teams/components/Dropdown/dropdownItemStyles.ts b/src/themes/teams/components/Dropdown/dropdownItemStyles.ts index bf5347c4cd..4e28dbe216 100644 --- a/src/themes/teams/components/Dropdown/dropdownItemStyles.ts +++ b/src/themes/teams/components/Dropdown/dropdownItemStyles.ts @@ -9,8 +9,8 @@ const dropdownItemStyles: ComponentSlotStylesInput<DropdownItemProps, DropdownVa ...(active && { [`&.${ListItem.className}`]: { - backgroundColor: v.listItemHighlightedBackgroundColor, - color: v.listItemHighlightedTextColor, + backgroundColor: v.listItemBackgroundColorActive, + color: v.listItemColorActive, }, }), }), diff --git a/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts b/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts index 193a05a58c..16c52581ff 100644 --- a/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts +++ b/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts @@ -6,18 +6,25 @@ const dropdownSearchInputStyles: ComponentSlotStylesInput< DropdownSearchInputProps, DropdownVariables > = { - input: ({ variables: { backgroundColor } }): ICSSInJSStyle => ({ + input: ({ variables: { backgroundColor, comboboxPaddingInput } }): ICSSInJSStyle => ({ width: '100%', backgroundColor, + padding: comboboxPaddingInput, ':focus': { borderBottomColor: 'transparent', }, }), - wrapper: ({ variables: { editTextFlexBasis } }): ICSSInJSStyle => ({ - flexBasis: editTextFlexBasis, + combobox: ({ + variables: { comboboxFlexBasis, toggleButtonSize }, + props: { hasToggleButton }, + }): ICSSInJSStyle => ({ + flexBasis: comboboxFlexBasis, flexGrow: 1, + ...(hasToggleButton && { + marginRight: toggleButtonSize, + }), }), } diff --git a/src/themes/teams/components/Dropdown/dropdownStyles.ts b/src/themes/teams/components/Dropdown/dropdownStyles.ts index 62436c7499..f06a82774f 100644 --- a/src/themes/teams/components/Dropdown/dropdownStyles.ts +++ b/src/themes/teams/components/Dropdown/dropdownStyles.ts @@ -1,19 +1,21 @@ import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types' import { DropdownProps } from '../../../../components/Dropdown/Dropdown' import { DropdownVariables } from './dropdownVariables' +import { pxToRem } from '../../utils' const dropdownStyles: ComponentSlotStylesInput<DropdownProps, DropdownVariables> = { - containerDiv: ({ - props: { focused, toggleButton, fluid }, + root: (): ICSSInJSStyle => ({}), + + container: ({ + props: { focused, fluid }, variables: { backgroundColor, - containerDivBorderBottom, - containerDivBorderRadius, - containerDivBorderColor, - containerDivFocusBorderColor, - containerDivFocusBorderRadius, - containerDivColor, - toggleButtonSize, + borderBottom, + borderRadius, + borderColor, + borderColorFocus, + borderRadiusFocus, + color, width, }, }): ICSSInJSStyle => ({ @@ -22,32 +24,57 @@ const dropdownStyles: ComponentSlotStylesInput<DropdownProps, DropdownVariables> outline: 0, border: 0, backgroundColor, - borderRadius: containerDivBorderRadius, - borderBottom: containerDivBorderBottom, - borderColor: containerDivBorderColor, - color: containerDivColor, + borderBottom, + borderColor, + borderRadius, + color, width: fluid ? '100%' : width, position: 'relative', - ...(toggleButton && { - paddingRight: toggleButtonSize, - }), ...(focused && { - borderColor: containerDivFocusBorderColor, - borderRadius: containerDivFocusBorderRadius, + borderColor: borderColorFocus, + borderRadius: borderRadiusFocus, }), }), + button: ({ variables: { comboboxPaddingButton } }): ICSSInJSStyle => { + const transparentColorStyle = { + backgroundColor: 'transparent', + borderColor: 'transparent', + } + return { + boxShadow: 'none', + margin: '0', + justifyContent: 'left', + padding: comboboxPaddingButton, + ...transparentColorStyle, + height: pxToRem(30), + ':hover': transparentColorStyle, + ':focus': { + ...transparentColorStyle, + ':after': { + borderColor: 'transparent', + }, + ':active': transparentColorStyle, + }, + ':active': transparentColorStyle, + } + }, + label: (): ICSSInJSStyle => ({ margin: '.4rem 0 0 .4rem', }), - list: ({ variables: { listMaxHeight, width }, props: { fluid } }): ICSSInJSStyle => ({ + list: ({ + variables: { listMaxHeight, width, listBackgroundColor }, + props: { fluid }, + }): ICSSInJSStyle => ({ position: 'absolute', zIndex: 1000, maxHeight: listMaxHeight, overflowY: 'auto', width: fluid ? '100%' : width, top: 'calc(100% + 2px)', // leave room for container + its border + background: listBackgroundColor, }), emptyListItem: ({ variables: { listItemBackgroundColor } }) => ({ @@ -58,9 +85,13 @@ const dropdownStyles: ComponentSlotStylesInput<DropdownProps, DropdownVariables> position: 'absolute', height: toggleButtonSize, width: toggleButtonSize, - border: 0, + cursor: 'pointer', backgroundColor: 'transparent', margin: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + userSelect: 'none', ...(fluid ? { right: 0 } : { left: `calc(${width} - ${toggleButtonSize})` }), }), } diff --git a/src/themes/teams/components/Dropdown/dropdownVariables.ts b/src/themes/teams/components/Dropdown/dropdownVariables.ts index 32c59c7fd2..489bbccc4a 100644 --- a/src/themes/teams/components/Dropdown/dropdownVariables.ts +++ b/src/themes/teams/components/Dropdown/dropdownVariables.ts @@ -1,39 +1,42 @@ import { pxToRem } from '../../utils' export interface DropdownVariables { backgroundColor: string - containerDivBorderRadius: string - containerDivBorderBottom: string - containerDivBorderColor: string - containerDivColor: string - containerDivFocusBorderColor: string - containerDivFocusBorderRadius: string - editTextFlexBasis: string + borderBottom: string + borderColor: string + borderColorFocus: string + borderRadius: string + borderRadiusFocus: string + color: string + comboboxPaddingButton: string + comboboxPaddingInput: string + comboboxFlexBasis: string + listBackgroundColor: string listItemBackgroundColor: string - listItemHighlightedBackgroundColor: string - listItemHighlightedTextColor: string + listItemBackgroundColorActive: string + listItemColorActive: string listMaxHeight: string toggleButtonSize: string width: string } -const [_2px_asRem, _3px_asRem] = [2, 3].map(v => pxToRem(v)) +const [_2px_asRem, _3px_asRem, _6px_asRem, _12px_asRem] = [2, 3, 6, 12].map(v => pxToRem(v)) export default (siteVars): DropdownVariables => ({ backgroundColor: siteVars.gray10, - - containerDivBorderRadius: _3px_asRem, - containerDivBorderBottom: `${_2px_asRem} solid transparent`, - containerDivBorderColor: 'transparent', - containerDivColor: siteVars.bodyColor, - containerDivFocusBorderColor: siteVars.brand, - containerDivFocusBorderRadius: `${_3px_asRem} ${_3px_asRem} ${_2px_asRem} ${_2px_asRem}`, - editTextFlexBasis: '100px', - + borderRadius: _3px_asRem, + borderBottom: `${_2px_asRem} solid transparent`, + borderColor: 'transparent', + borderColorFocus: siteVars.brand, + borderRadiusFocus: `${_3px_asRem} ${_3px_asRem} ${_2px_asRem} ${_2px_asRem}`, + color: siteVars.bodyColor, + comboboxPaddingButton: `0 ${_12px_asRem}`, + comboboxPaddingInput: `${_6px_asRem} ${_12px_asRem}`, + comboboxFlexBasis: '50px', + listBackgroundColor: siteVars.white, listItemBackgroundColor: siteVars.white, - listItemHighlightedBackgroundColor: siteVars.brand, - listItemHighlightedTextColor: siteVars.white, + listItemBackgroundColorActive: siteVars.brand, + listItemColorActive: siteVars.white, listMaxHeight: '20rem', - - toggleButtonSize: pxToRem(30), + toggleButtonSize: pxToRem(32), width: pxToRem(356), })