From 6a0fcd432b1e67ce0a92abea462346e8b5631523 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Tue, 3 Sep 2019 10:58:44 -0700 Subject: [PATCH 1/5] feat(dropdowns): introduce Multiselect component --- .../{src => }/examples/autocomplete.md | 0 .../dropdowns/{src => }/examples/examples.md | 0 packages/dropdowns/{src => }/examples/menu.md | 0 packages/dropdowns/examples/multiselect.md | 138 ++++++++ .../dropdowns/{src => }/examples/select.md | 0 .../dropdowns/src/Dropdown/Dropdown.spec.tsx | 14 - packages/dropdowns/src/Dropdown/Dropdown.tsx | 44 ++- .../dropdowns/src/Multiselect/Multiselect.tsx | 303 ++++++++++++++++++ packages/dropdowns/src/index.ts | 1 + .../src/styled/field/StyledMultiselect.tsx | 66 ++++ packages/dropdowns/src/styled/index.ts | 1 + packages/dropdowns/styleguide.config.js | 15 +- 12 files changed, 548 insertions(+), 34 deletions(-) rename packages/dropdowns/{src => }/examples/autocomplete.md (100%) rename packages/dropdowns/{src => }/examples/examples.md (100%) rename packages/dropdowns/{src => }/examples/menu.md (100%) create mode 100644 packages/dropdowns/examples/multiselect.md rename packages/dropdowns/{src => }/examples/select.md (100%) create mode 100644 packages/dropdowns/src/Multiselect/Multiselect.tsx create mode 100644 packages/dropdowns/src/styled/field/StyledMultiselect.tsx diff --git a/packages/dropdowns/src/examples/autocomplete.md b/packages/dropdowns/examples/autocomplete.md similarity index 100% rename from packages/dropdowns/src/examples/autocomplete.md rename to packages/dropdowns/examples/autocomplete.md diff --git a/packages/dropdowns/src/examples/examples.md b/packages/dropdowns/examples/examples.md similarity index 100% rename from packages/dropdowns/src/examples/examples.md rename to packages/dropdowns/examples/examples.md diff --git a/packages/dropdowns/src/examples/menu.md b/packages/dropdowns/examples/menu.md similarity index 100% rename from packages/dropdowns/src/examples/menu.md rename to packages/dropdowns/examples/menu.md diff --git a/packages/dropdowns/examples/multiselect.md b/packages/dropdowns/examples/multiselect.md new file mode 100644 index 00000000000..4bb03a2bfe2 --- /dev/null +++ b/packages/dropdowns/examples/multiselect.md @@ -0,0 +1,138 @@ +The `Multiselect` component renders a customizable "tag" for each selected item. + +This can be any element, but the surrounding field is designed to work with +our standard `Tag` component. + +```jsx static + + + + ( + + {value} removeValue()} /> + + )} + /> + + {/** customizable items **/} + +``` + +```js +const debounce = require('lodash.debounce'); +const { Tag, Close } = require('@zendeskgarden/react-tags/src'); + +const options = [ + 'Aster', + "Bachelor's button", + 'Celosia', + 'Dusty miller', + 'Everlasting winged', + "Four o'clock", + 'Geranium', + 'Honesty', + 'Impatiens', + 'Johnny jump-up', + 'Kale', + 'Lobelia', + 'Marigold', + 'Nasturtium', + 'Ocimum (basil)', + 'Petunia', + 'Quaking grass', + 'Rose moss', + 'Salvia', + 'Torenia', + 'Ursinia', + 'Verbena', + 'Wax begonia', + 'Xeranthemum', + 'Yellow cosmos', + 'Zinnia' +]; + +function ExampleAutocomplete() { + const [selectedItems, setSelectedItems] = React.useState([ + options[0], + options[1], + options[2], + options[3], + options[4], + options[5] + ]); + const [inputValue, setInputValue] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + const [matchingOptions, setMatchingOptions] = React.useState(options); + + /** + * Debounce filtering + */ + const filterMatchingOptionsRef = React.useRef( + debounce(value => { + const matchingOptions = options.filter(option => { + return ( + option + .trim() + .toLowerCase() + .indexOf(value.trim().toLowerCase()) !== -1 + ); + }); + + setMatchingOptions(matchingOptions); + setIsLoading(false); + }, 300) + ); + + React.useEffect(() => { + setIsLoading(true); + filterMatchingOptionsRef.current(inputValue); + }, [inputValue]); + + const renderOptions = () => { + if (isLoading) { + return Loading items...; + } + + if (matchingOptions.length === 0) { + return No matches found; + } + + return matchingOptions.map(option => ( + + {option} + + )); + }; + + return ( + setSelectedItems(items)} + downshiftProps={{ defaultHighlightedIndex: 0 }} + onStateChange={changes => { + if (Object.prototype.hasOwnProperty.call(changes, 'inputValue')) { + setInputValue(changes.inputValue); + } + }} + > + + + This example include basic debounce logic + ( + + {value} removeValue()} /> + + )} + /> + + {renderOptions()} + + ); +} + +; +``` diff --git a/packages/dropdowns/src/examples/select.md b/packages/dropdowns/examples/select.md similarity index 100% rename from packages/dropdowns/src/examples/select.md rename to packages/dropdowns/examples/select.md diff --git a/packages/dropdowns/src/Dropdown/Dropdown.spec.tsx b/packages/dropdowns/src/Dropdown/Dropdown.spec.tsx index e76ef9ce917..7fe7b141577 100644 --- a/packages/dropdowns/src/Dropdown/Dropdown.spec.tsx +++ b/packages/dropdowns/src/Dropdown/Dropdown.spec.tsx @@ -37,20 +37,6 @@ const ExampleDropdown = (props: IDropdownProps) => ( describe('Dropdown', () => { describe('Custom keyboard nav', () => { - it('selects item on TAB key', () => { - const onSelectSpy = jest.fn(); - const { container, getByTestId } = render(); - - const trigger = getByTestId('trigger'); - const input = container.querySelector('input'); - - fireEvent.click(trigger); - fireEvent.keyDown(input!, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.keyDown(input!, { key: 'Tab', keyCode: 9 }); - - expect(onSelectSpy.mock.calls[0][0]).toBe('previous-item'); - }); - it('selects previous item on left arrow key in LTR mode', () => { const onSelectSpy = jest.fn(); const { container, getByTestId } = render(); diff --git a/packages/dropdowns/src/Dropdown/Dropdown.tsx b/packages/dropdowns/src/Dropdown/Dropdown.tsx index cf95a87f53f..a801ed5c7f8 100644 --- a/packages/dropdowns/src/Dropdown/Dropdown.tsx +++ b/packages/dropdowns/src/Dropdown/Dropdown.tsx @@ -12,6 +12,9 @@ import { Manager } from 'react-popper'; import { withTheme, isRtl } from '@zendeskgarden/react-theming'; import { KEY_CODES, composeEventHandlers } from '@zendeskgarden/container-utilities'; +export const REMOVE_ITEM_STATE_TYPE = 'REMOVE_ITEM'; +export const TAB_SELECT_ITEM_STATE_TYPE = 'TAB_ITEM'; + export interface IDropdownContext { itemIndexRef: React.MutableRefObject; previousItemRef: React.MutableRefObject; @@ -20,6 +23,7 @@ export interface IDropdownContext { popperReferenceElementRef: React.MutableRefObject; selectedItems?: any[]; downshift: ControllerStateAndHelpers; + containsMultiselectRef: React.MutableRefObject; } export const DropdownContext = React.createContext(undefined); @@ -64,6 +68,7 @@ const Dropdown: React.FunctionComponent = props => { const previousItemRef = useRef(undefined); const previousIndexRef = useRef(undefined); const nextItemsHashRef = useRef({}); + const containsMultiselectRef = useRef(false); // Used to inform Menu (Popper) that a full-width menu is needed const popperReferenceElementRef = useRef(null); @@ -71,25 +76,22 @@ const Dropdown: React.FunctionComponent = props => { /** * Add additional keyboard nav to the basics provided by Downshift **/ - const customGetInputProps = ({ onKeyDown, ...other }: any, downshift: any, rtl: any) => { + const customGetInputProps = ( + { onKeyDown, ...other }: any, + downshift: ControllerStateAndHelpers, + rtl: any + ) => { return { - onKeyDown: composeEventHandlers(onKeyDown, (e: any) => { + onKeyDown: composeEventHandlers(onKeyDown, (e: KeyboardEvent) => { const PREVIOUS_KEY = rtl ? KEY_CODES.RIGHT : KEY_CODES.LEFT; const NEXT_KEY = rtl ? KEY_CODES.LEFT : KEY_CODES.RIGHT; if (downshift.isOpen) { - // Select highlighted item on TAB - if (e.keyCode === KEY_CODES.TAB) { - e.preventDefault(); - e.stopPropagation(); - - downshift.selectHighlightedItem(); - } - // Select previous item if available if ( e.keyCode === PREVIOUS_KEY && previousIndexRef.current !== null && + previousIndexRef.current !== undefined && !downshift.inputValue ) { e.preventDefault(); @@ -106,7 +108,7 @@ const Dropdown: React.FunctionComponent = props => { e.preventDefault(); e.stopPropagation(); - downshift.selectItemAtIndex(downshift.highlightedIndex); + downshift.selectItemAtIndex(downshift.highlightedIndex!); } } } else if ( @@ -184,15 +186,26 @@ const Dropdown: React.FunctionComponent = props => { switch (changes.type) { case Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem: case Downshift.stateChangeTypes.mouseUp: - case Downshift.stateChangeTypes.keyDownEnter: - case Downshift.stateChangeTypes.clickItem: - case Downshift.stateChangeTypes.clickButton: case Downshift.stateChangeTypes.keyDownSpaceButton: case Downshift.stateChangeTypes.blurButton: return { ...changes, inputValue: '' }; + case Downshift.stateChangeTypes.keyDownEnter: + case Downshift.stateChangeTypes.clickItem: + case TAB_SELECT_ITEM_STATE_TYPE as any: { + const updatedState = { ...changes, inputValue: '' }; + + if (containsMultiselectRef.current) { + updatedState.isOpen = true; + updatedState.highlightedIndex = _state.highlightedIndex; + } + + return updatedState; + } + case REMOVE_ITEM_STATE_TYPE as any: + return { ...changes, isOpen: false }; default: return changes; } @@ -208,7 +221,8 @@ const Dropdown: React.FunctionComponent = props => { nextItemsHashRef, popperReferenceElementRef, selectedItems, - downshift: transformDownshift(downshift) + downshift: transformDownshift(downshift), + containsMultiselectRef }} > {children} diff --git a/packages/dropdowns/src/Multiselect/Multiselect.tsx b/packages/dropdowns/src/Multiselect/Multiselect.tsx new file mode 100644 index 00000000000..5ad8dd9817d --- /dev/null +++ b/packages/dropdowns/src/Multiselect/Multiselect.tsx @@ -0,0 +1,303 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { useRef, useEffect, useState, PropsWithChildren, useMemo, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { Reference } from 'react-popper'; +import { useSelection } from '@zendeskgarden/container-selection'; +import { + StyledMultiselectInput, + StyledSelect, + StyledItemWrapper, + StyledMoreAnchor, + VALIDATION +} from '../styled'; +import useDropdownContext from '../utils/useDropdownContext'; +import useFieldContext from '../utils/useFieldContext'; +import { KEY_CODES } from '@zendeskgarden/container-utilities'; +import { REMOVE_ITEM_STATE_TYPE } from '../Dropdown/Dropdown'; + +interface IMultiselectProps { + /** Applies flex layout to support MediaFigure components */ + mediaLayout?: boolean; + small?: boolean; + /** Removes all borders and styling */ + bare?: boolean; + disabled?: boolean; + focused?: boolean; + /** Applies inset `box-shadow` styling on focus */ + focusInset?: boolean; + hovered?: boolean; + /** Displays select open state */ + open?: boolean; + placeholder?: string; + validation?: VALIDATION; + /** Number of items to show in the collapsed state. Default of 4. */ + maxItems: number; + renderShowMore?: (index: number) => string; + renderItem: (options: { value: any; removeValue: () => void }) => React.ReactElement; +} + +/** + * Applies state and a11y attributes to its children. Must be nested within a `` component. + */ +const Multiselect = ({ + renderItem, + placeholder, + maxItems, + renderShowMore, + ...props +}: PropsWithChildren) => { + const { + popperReferenceElementRef, + selectedItems, + containsMultiselectRef, + downshift: { + getToggleButtonProps, + getInputProps, + isOpen, + closeMenu, + inputValue, + setState: setDownshiftState, + itemToString + } + } = useDropdownContext(); + const { isLabelHovered } = useFieldContext(); + const inputRef = useRef(null); + const triggerRef = useRef(null); + const previousIsOpenRef = useRef(undefined); + const [isFocused, setIsFocused] = useState(false); + const [focusedItem, setFocusedItem] = useState(undefined); + + const { getContainerProps, getItemProps } = useSelection({ + rtl: false, + focusedItem, + selectedItem: undefined, + onFocus: (item: any) => { + setFocusedItem(item); + } + }); + + useEffect(() => { + containsMultiselectRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Focus internal input when Menu is opened + if (isOpen && !previousIsOpenRef.current) { + inputRef.current && inputRef.current.focus(); + } + + // Focus trigger when Menu is closed + if (!isOpen && previousIsOpenRef.current) { + triggerRef.current && triggerRef.current.focus(); + } + previousIsOpenRef.current = isOpen; + }, [isOpen]); + + const selectProps = getToggleButtonProps({ + onKeyDown: e => { + if (isOpen) { + (e.nativeEvent as any).preventDownshiftDefault = true; + } else if (!inputValue && e.keyCode === KEY_CODES.HOME) { + setFocusedItem(selectedItems![0]); + e.preventDefault(); + } + }, + onFocus: () => { + setIsFocused(true); + }, + onBlur: (e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsFocused(false); + + e.preventDefault(); + (e.nativeEvent as any).preventDownshiftDefault = true; + } + }, + ...props + }); + + const renderSelectableItem = useCallback( + (item, index) => { + const removeValue = () => { + (setDownshiftState as any)({ + type: REMOVE_ITEM_STATE_TYPE, + selectedItem: item + }); + inputRef.current && inputRef.current.focus(); + }; + + const renderedItem = renderItem({ value: item, removeValue }); + const focusRef = React.createRef(); + + const clonedChild = React.cloneElement( + renderedItem, + getItemProps({ + item, + focusRef, + onKeyDown: (e: KeyboardEvent) => { + if (e.keyCode === KEY_CODES.DELETE || e.keyCode === KEY_CODES.BACKSPACE) { + e.preventDefault(); + removeValue(); + } + + if (e.keyCode === KEY_CODES.END && !inputValue) { + inputRef.current && inputRef.current.focus(); + e.preventDefault(); + } + + if (e.keyCode === KEY_CODES.LEFT && index === 0) { + e.preventDefault(); + } + + if (e.keyCode === KEY_CODES.RIGHT && index === selectedItems!.length - 1) { + e.preventDefault(); + inputRef.current && inputRef.current.focus(); + } + }, + onClick: (e: MouseEvent) => { + (e as any).nativeEvent.preventDownshiftDefault = true; + }, + tabIndex: -1 + }) + ); + + const key = `${itemToString(item)}-${index}`; + + return {clonedChild}; + }, + [getItemProps, inputValue, renderItem, setDownshiftState, itemToString, selectedItems] + ); + + const items = useMemo(() => { + if (!props.disabled) { + const itemValues = selectedItems || []; + const output = []; + + for (let x = 0; x < itemValues.length; x++) { + const item = itemValues[x]; + + if (x < maxItems) { + output.push(renderSelectableItem(item, x)); + } else if (!isFocused && !inputValue) { + output.push( + + {renderShowMore + ? renderShowMore(itemValues.length - x) + : `+ ${itemValues.length - x} more`} + + ); + break; + } else { + output.push(renderSelectableItem(item, x)); + } + } + + return output; + } + + return (selectedItems || []).map((item, index) => { + // eslint-disable-next-line no-empty-function + const renderedItem = renderItem({ value: item, removeValue: () => {} }); + + return {renderedItem}; + }); + }, [ + isFocused, + props.disabled, + renderSelectableItem, + selectedItems, + renderItem, + inputValue, + maxItems, + renderShowMore + ]); + + return ( + + {({ ref: popperReference }) => ( + { + // Pass ref to popperJS for positioning + popperReference(selectRef); + + // Store ref locally to return focus on close + (triggerRef as any).current = selectRef; + + // Apply Select ref to global Dropdown context + popperReferenceElementRef.current = selectRef; + } + })} + > + {items} + { + setFocusedItem(undefined); + }, + onKeyDown: (e: KeyboardEvent) => { + if (!inputValue) { + if (e.keyCode === KEY_CODES.LEFT && selectedItems!.length > 0) { + setFocusedItem(selectedItems![selectedItems!.length - 1]); + } else if (e.keyCode === KEY_CODES.BACKSPACE && selectedItems!.length > 0) { + (setDownshiftState as any)({ + type: REMOVE_ITEM_STATE_TYPE, + selectedItem: selectedItems![selectedItems!.length - 1] + }); + (e as any).nativeEvent.preventDownshiftDefault = true; + e.preventDefault(); + e.stopPropagation(); + } + } + }, + onBlur: () => { + closeMenu(); + }, + isVisible: isFocused || inputValue || selectedItems!.length === 0, + isSmall: props.small, + ref: inputRef, + placeholder: selectedItems!.length === 0 ? placeholder : undefined + }) as any)} + /> + + )} + + ); +}; + +Multiselect.propTypes = { + /** Applies flex layout to support MediaFigure components */ + mediaLayout: PropTypes.bool, + small: PropTypes.bool, + /** Removes all borders and styling */ + bare: PropTypes.bool, + disabled: PropTypes.bool, + focused: PropTypes.bool, + /** Applies inset `box-shadow` styling on focus */ + focusInset: PropTypes.bool, + hovered: PropTypes.bool, + /** Displays select open state */ + open: PropTypes.bool, + renderItem: PropTypes.func.isRequired, + maxItems: PropTypes.number, + validation: PropTypes.oneOf([VALIDATION.SUCCESS, VALIDATION.WARNING, VALIDATION.ERROR]) +}; + +Multiselect.defaultProps = { + maxItems: 4 +}; + +export default Multiselect; diff --git a/packages/dropdowns/src/index.ts b/packages/dropdowns/src/index.ts index 4a675dfb34a..d16fe6fcebc 100644 --- a/packages/dropdowns/src/index.ts +++ b/packages/dropdowns/src/index.ts @@ -8,6 +8,7 @@ export { default as Dropdown } from './Dropdown/Dropdown'; export { default as Trigger } from './Trigger/Trigger'; export { default as Autocomplete } from './Autocomplete/Autocomplete'; +export { default as Multiselect } from './Multiselect/Multiselect'; export { default as Select } from './Select/Select'; export { default as Field } from './Fields/Field'; export { default as Hint } from './Fields/Hint'; diff --git a/packages/dropdowns/src/styled/field/StyledMultiselect.tsx b/packages/dropdowns/src/styled/field/StyledMultiselect.tsx new file mode 100644 index 00000000000..4a0c161e253 --- /dev/null +++ b/packages/dropdowns/src/styled/field/StyledMultiselect.tsx @@ -0,0 +1,66 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import styled from 'styled-components'; +import { StyledInput } from '../field/StyledInput'; +import { zdColorBlue600 } from '@zendeskgarden/css-variables'; + +export const StyledItemWrapper = styled.div` + margin: 2px; +`; + +export const StyledMultiselectInput = styled(StyledInput)<{ + isSmall: boolean; + isVisible: boolean; +}>` + && { + flex-basis: 60px; + flex-grow: 1; + margin: 2px; + width: inherit; + min-width: 60px; + line-height: ${props => (props.isSmall ? 20 / 14 : 32 / 14)}; + } + + ${props => + !props.isVisible && + ` + &&& { + margin: 0; + opacity: 0; + height: 0; + min-height: 0; + width: 0; + min-width: 0; + } +`} +`; + +export const StyledMoreAnchor = styled.div` + display: flex; + align-items: center; + margin: 2px; + border: none; + border-radius: 0; + background-color: transparent; + cursor: pointer; + padding: 0; + min-width: 0; + min-height: 1em; + vertical-align: baseline; + text-decoration: none; + user-select: none; + line-height: 1em; + white-space: normal; + color: ${zdColorBlue600}; + font-size: inherit; + font-weight: inherit; + + :hover { + text-decoration: underline; + } +`; diff --git a/packages/dropdowns/src/styled/index.ts b/packages/dropdowns/src/styled/index.ts index 8c3b94ee7f2..8693d08453f 100644 --- a/packages/dropdowns/src/styled/index.ts +++ b/packages/dropdowns/src/styled/index.ts @@ -6,6 +6,7 @@ */ export * from './field/StyledInput'; +export * from './field/StyledMultiselect'; export * from './menu/StyledMenu'; export * from './menu/StyledSeparator'; export * from './items/StyledAddItem'; diff --git a/packages/dropdowns/styleguide.config.js b/packages/dropdowns/styleguide.config.js index 9c93bd29bca..6515c94e568 100644 --- a/packages/dropdowns/styleguide.config.js +++ b/packages/dropdowns/styleguide.config.js @@ -30,19 +30,23 @@ module.exports = { }, { name: 'Examples', - content: '../../packages/dropdowns/src/examples/examples.md', + content: '../../packages/dropdowns/examples/examples.md', sections: [ { name: 'Menu usage', - content: '../../packages/dropdowns/src/examples/menu.md' + content: '../../packages/dropdowns/examples/menu.md' }, { name: 'Select usage', - content: '../../packages/dropdowns/src/examples/select.md' + content: '../../packages/dropdowns/examples/select.md' }, { name: 'Autocomplete usage', - content: '../../packages/dropdowns/src/examples/autocomplete.md' + content: '../../packages/dropdowns/examples/autocomplete.md' + }, + { + name: 'Multiselect usage', + content: '../../packages/dropdowns/examples/multiselect.md' } ] }, @@ -52,7 +56,8 @@ module.exports = { '../../packages/dropdowns/src/Dropdown/Dropdown.tsx', '../../packages/dropdowns/src/Trigger/Trigger.tsx', '../../packages/dropdowns/src/Select/Select.tsx', - '../../packages/dropdowns/src/Autocomplete/Autocomplete.tsx' + '../../packages/dropdowns/src/Autocomplete/Autocomplete.tsx', + '../../packages/dropdowns/src/Multiselect/Multiselect.tsx' ], sections: [ { From 1190c4f4f4a6bc0828433094b7e0b831802c03a1 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Mon, 9 Sep 2019 08:15:15 -0700 Subject: [PATCH 2/5] Add RTL support --- .lintstagedrc | 18 +++++++ lint-staged.config.js | 23 --------- .../dropdowns/src/Multiselect/Multiselect.tsx | 49 +++++++++++++------ 3 files changed, 52 insertions(+), 38 deletions(-) create mode 100644 .lintstagedrc delete mode 100644 lint-staged.config.js diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 00000000000..48942185779 --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,18 @@ +{ + "*.{js,ts,tsx}": [ + "stylelint", + "eslint", + "jest --config=utils/test/jest.config.js --findRelatedTests", + "prettier --write", + "git add" + ], + "!(*CHANGELOG).md": [ + "markdownlint", + "prettier --write", + "git add" + ], + "**/package.json": [ + "prettier-package-json --write", + "git add" + ] +} diff --git a/lint-staged.config.js b/lint-staged.config.js deleted file mode 100644 index d8b6f6fca25..00000000000 --- a/lint-staged.config.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright Zendesk, Inc. - * - * Use of this source code is governed under the Apache License, Version 2.0 - * found at http://www.apache.org/licenses/LICENSE-2.0. - */ - -const micromatch = require('micromatch'); - -module.exports = { - '*.{js, ts, tsx}': [ - 'stylelint', - 'eslint', - 'jest --config=utils/test/jest.config.js --findRelatedTests', - 'prettier --write', - 'git add' - ], - '*.md': files => - micromatch - .not(files, '*CHANGELOG.md') - .map(file => `markdownlint ${file}; prettier --write ${file}; git add ${file};`), - '**/package.json': ['prettier-package-json --write', 'git add'] -}; diff --git a/packages/dropdowns/src/Multiselect/Multiselect.tsx b/packages/dropdowns/src/Multiselect/Multiselect.tsx index 5ad8dd9817d..0853df4225e 100644 --- a/packages/dropdowns/src/Multiselect/Multiselect.tsx +++ b/packages/dropdowns/src/Multiselect/Multiselect.tsx @@ -5,10 +5,11 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { useRef, useEffect, useState, PropsWithChildren, useMemo, useCallback } from 'react'; +import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'; import PropTypes from 'prop-types'; import { Reference } from 'react-popper'; import { useSelection } from '@zendeskgarden/container-selection'; +import { isRtl, withTheme } from '@zendeskgarden/react-theming'; import { StyledMultiselectInput, StyledSelect, @@ -37,7 +38,7 @@ interface IMultiselectProps { placeholder?: string; validation?: VALIDATION; /** Number of items to show in the collapsed state. Default of 4. */ - maxItems: number; + maxItems?: number; renderShowMore?: (index: number) => string; renderItem: (options: { value: any; removeValue: () => void }) => React.ReactElement; } @@ -45,13 +46,13 @@ interface IMultiselectProps { /** * Applies state and a11y attributes to its children. Must be nested within a `` component. */ -const Multiselect = ({ +const Multiselect: React.FunctionComponent = ({ renderItem, placeholder, maxItems, renderShowMore, ...props -}: PropsWithChildren) => { +}) => { const { popperReferenceElementRef, selectedItems, @@ -74,7 +75,7 @@ const Multiselect = ({ const [focusedItem, setFocusedItem] = useState(undefined); const { getContainerProps, getItemProps } = useSelection({ - rtl: false, + rtl: isRtl(props), focusedItem, selectedItem: undefined, onFocus: (item: any) => { @@ -152,13 +153,24 @@ const Multiselect = ({ e.preventDefault(); } - if (e.keyCode === KEY_CODES.LEFT && index === 0) { - e.preventDefault(); - } + if (isRtl(props)) { + if (e.keyCode === KEY_CODES.RIGHT && index === 0) { + e.preventDefault(); + } - if (e.keyCode === KEY_CODES.RIGHT && index === selectedItems!.length - 1) { - e.preventDefault(); - inputRef.current && inputRef.current.focus(); + if (e.keyCode === KEY_CODES.LEFT && index === selectedItems!.length - 1) { + e.preventDefault(); + inputRef.current && inputRef.current.focus(); + } + } else { + if (e.keyCode === KEY_CODES.LEFT && index === 0) { + e.preventDefault(); + } + + if (e.keyCode === KEY_CODES.RIGHT && index === selectedItems!.length - 1) { + e.preventDefault(); + inputRef.current && inputRef.current.focus(); + } } }, onClick: (e: MouseEvent) => { @@ -172,7 +184,7 @@ const Multiselect = ({ return {clonedChild}; }, - [getItemProps, inputValue, renderItem, setDownshiftState, itemToString, selectedItems] + [getItemProps, inputValue, renderItem, setDownshiftState, itemToString, selectedItems, props] ); const items = useMemo(() => { @@ -183,7 +195,7 @@ const Multiselect = ({ for (let x = 0; x < itemValues.length; x++) { const item = itemValues[x]; - if (x < maxItems) { + if (x < maxItems!) { output.push(renderSelectableItem(item, x)); } else if (!isFocused && !inputValue) { output.push( @@ -250,7 +262,13 @@ const Multiselect = ({ }, onKeyDown: (e: KeyboardEvent) => { if (!inputValue) { - if (e.keyCode === KEY_CODES.LEFT && selectedItems!.length > 0) { + if (isRtl(props) && e.keyCode === KEY_CODES.RIGHT && selectedItems!.length > 0) { + setFocusedItem(selectedItems![selectedItems!.length - 1]); + } else if ( + !isRtl(props) && + e.keyCode === KEY_CODES.LEFT && + selectedItems!.length > 0 + ) { setFocusedItem(selectedItems![selectedItems!.length - 1]); } else if (e.keyCode === KEY_CODES.BACKSPACE && selectedItems!.length > 0) { (setDownshiftState as any)({ @@ -300,4 +318,5 @@ Multiselect.defaultProps = { maxItems: 4 }; -export default Multiselect; +/* @component */ +export default withTheme(Multiselect) as React.FunctionComponent; From b2440e1bf2e5d9d2229b7d547e796287692f3d6c Mon Sep 17 00:00:00 2001 From: Austin Green Date: Tue, 10 Sep 2019 11:09:20 -0700 Subject: [PATCH 3/5] Add test coverage --- .../src/Multiselect/Multiselect.spec.tsx | 513 ++++++++++++++++++ .../dropdowns/src/Multiselect/Multiselect.tsx | 70 ++- 2 files changed, 547 insertions(+), 36 deletions(-) create mode 100644 packages/dropdowns/src/Multiselect/Multiselect.spec.tsx diff --git a/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx b/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx new file mode 100644 index 00000000000..204dac614bb --- /dev/null +++ b/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx @@ -0,0 +1,513 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React from 'react'; +import { render, fireEvent, renderRtl } from 'garden-test-utils'; +import { Dropdown, Multiselect, Field, Menu, Item, Label } from '..'; +import { IDropdownProps } from '../Dropdown/Dropdown'; +import { KEY_CODES } from '@zendeskgarden/container-utilities'; + +const ExampleWrapper: React.FC = ({ children, ...other }) => ( + + {children} + + + Celosia + + + Dusty miller + + + Everlasting winged + + + +); + +describe('Multiselect', () => { + it('focuses internal input when opened', () => { + const { getByTestId } = render( + + ( +
+ {value} + +
+ )} + /> +
+ ); + + fireEvent.click(getByTestId('multiselect')); + + expect(document.activeElement!.nodeName).toBe('INPUT'); + }); + + it('closes on input blur', () => { + const { getByTestId } = render( + + ( +
+ {value} + +
+ )} + /> +
+ ); + + const multiselect = getByTestId('multiselect'); + const input = multiselect.querySelector('input'); + + fireEvent.focus(input!); + expect(multiselect).toHaveClass('is-focused'); + fireEvent.blur(input!); + expect(multiselect).not.toHaveClass('is-focused'); + }); + + it('applies correct styling if open', () => { + const { getByTestId } = render( + + ( +
+ {value} + +
+ )} + /> +
+ ); + + const multiselect = getByTestId('multiselect'); + + fireEvent.click(multiselect); + + expect(multiselect).toHaveClass('is-focused'); + expect(multiselect).toHaveClass('is-open'); + }); + + it('applies correct styling if label is hovered', () => { + const { getByTestId } = render( + + + ( +
+ {value} + +
+ )} + /> +
+ ); + + fireEvent.mouseEnter(getByTestId('label')); + + expect(getByTestId('multiselect')).toHaveClass('is-hovered'); + }); + + describe('Interaction', () => { + it('opens on click', () => { + const { getByTestId } = render( + + ( +
+ {value} + +
+ )} + /> +
+ ); + const multiselect = getByTestId('multiselect'); + + fireEvent.click(multiselect); + + expect(multiselect).toHaveClass('is-open'); + }); + + it('opens on down key and highlights first item', () => { + const { getByTestId, getAllByTestId } = render( + + + ( +
+ {value} + +
+ )} + /> +
+ ); + const multiselect = getByTestId('multiselect'); + + fireEvent.keyDown(multiselect, { key: 'ArrowDown', keyCode: 40 }); + expect(multiselect).toHaveClass('is-open'); + + const items = getAllByTestId('item'); + + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + }); + + it('opens on up key and highlights last item', () => { + const { getByTestId, getAllByTestId } = render( + + + ( +
+ {value} + +
+ )} + /> +
+ ); + const multiselect = getByTestId('multiselect'); + + fireEvent.keyDown(multiselect, { key: 'ArrowUp', keyCode: 38 }); + expect(multiselect).toHaveClass('is-open'); + + const items = getAllByTestId('item'); + + expect(items[items.length - 1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('closes on escape key', () => { + const { getByTestId } = render( + + + ( +
+ {value} + +
+ )} + /> +
+ ); + const multiselect = getByTestId('multiselect'); + + fireEvent.click(multiselect); + expect(multiselect).toHaveClass('is-open'); + + fireEvent.keyDown(multiselect.querySelector('input')!, { key: 'Escape', keyCode: 27 }); + expect(multiselect).not.toHaveClass('is-open'); + }); + }); + + describe('Tags', () => { + const DefaultTagExample: React.FC = props => ( + + ( +
+ {value} + +
+ )} + /> +
+ ); + + it('limits number of visible tags when not focused', () => { + const items = []; + + for (let x = 0; x < 50; x++) { + items.push(`item-${x}`); + } + + const { getAllByTestId, getByTestId } = render(); + const tags = getAllByTestId('tag'); + const multiselect = getByTestId('multiselect'); + + expect(tags).toHaveLength(4); + expect(multiselect.textContent).toContain('+ 46 more'); + }); + + it('shows all tags when focused', () => { + const items = []; + + for (let x = 0; x < 50; x++) { + items.push(`item-${x}`); + } + + const { getAllByTestId, container } = render(); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + const tags = getAllByTestId('tag'); + + expect(tags).toHaveLength(50); + }); + + it('renders custom show more text when provided', () => { + const items = []; + + for (let x = 0; x < 50; x++) { + items.push(`item-${x}`); + } + + const { getAllByTestId, getByTestId } = render( + + `custom show more ${num}`} + data-test-id="multiselect" + renderItem={({ value, removeValue }) => ( +
+ {value} + +
+ )} + /> +
+ ); + const tags = getAllByTestId('tag'); + const multiselect = getByTestId('multiselect'); + + expect(tags).toHaveLength(6); + expect(multiselect.textContent).toContain('custom show more 44'); + }); + + it('limits number of visible tags when disabled', () => { + const items = []; + + for (let x = 0; x < 50; x++) { + items.push(`item-${x}`); + } + + const { getAllByTestId } = render( + + ( +
+ {value} + +
+ )} + /> +
+ ); + const tags = getAllByTestId('tag'); + + expect(tags).toHaveLength(4); + }); + + it('focuses tag on click', () => { + const { getAllByTestId, getByTestId } = render(); + const tags = getAllByTestId('tag'); + const multiselect = getByTestId('multiselect'); + + fireEvent.focus(tags[0]); + + expect(tags[0]).toHaveFocus(); + expect(multiselect).not.toHaveClass('is-open'); + }); + + it('removes tag on remove click ', () => { + const onSelectSpy = jest.fn(); + const { container, getAllByTestId } = render( + onSelectSpy(items)} /> + ); + + const input = container.querySelector('input'); + const removes = getAllByTestId('remove'); + + fireEvent.focus(input!); + fireEvent.click(removes[0]); + + expect(onSelectSpy).toHaveBeenCalledWith(['item-2', 'item-3']); + }); + + it('focuses last tag on left arrow keydown with no input value', () => { + const { getAllByTestId, container } = render(); + const tags = getAllByTestId('tag'); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'ArrowLeft', keyCode: KEY_CODES.LEFT }); + + expect(tags[tags.length - 1]).toHaveFocus(); + }); + + it('does not focus last tag on left arrow keydown with input value', () => { + const { getAllByTestId, container } = render(); + const tags = getAllByTestId('tag'); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'ArrowLeft', keyCode: KEY_CODES.LEFT }); + + expect(tags[tags.length - 1]).not.toHaveFocus(); + }); + + it('focuses first tag on home keydown with no input value', () => { + const { getAllByTestId, container } = render(); + const tags = getAllByTestId('tag'); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'Home', keyCode: KEY_CODES.HOME }); + + expect(tags[0]).toHaveFocus(); + }); + + it('does not focus first tag on home keydown with input value', () => { + const { getAllByTestId, container } = render(); + const tags = getAllByTestId('tag'); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'Home', keyCode: KEY_CODES.LEFT }); + + expect(tags[0]).not.toHaveFocus(); + }); + + it('removes last tag on backspace keydown is pressed with no input value', () => { + const onSelectSpy = jest.fn(); + const { container } = render( onSelectSpy(items)} />); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'Backspace', keyCode: KEY_CODES.BACKSPACE }); + + expect(onSelectSpy).toHaveBeenCalledWith(['item-1', 'item-2']); + }); + + it('deletes current tag on backspace keydown', () => { + const onSelectSpy = jest.fn(); + const { getAllByTestId } = render( + onSelectSpy(items)} /> + ); + const tags = getAllByTestId('tag'); + + fireEvent.keyDown(tags[1], { keyCode: KEY_CODES.BACKSPACE }); + expect(onSelectSpy).toHaveBeenCalledWith(['item-1', 'item-3']); + }); + + it('deletes current tag on delete keydown', () => { + const onSelectSpy = jest.fn(); + const { getAllByTestId } = render( + onSelectSpy(items)} /> + ); + const tags = getAllByTestId('tag'); + + fireEvent.keyDown(tags[1], { keyCode: KEY_CODES.DELETE }); + expect(onSelectSpy).toHaveBeenCalledWith(['item-1', 'item-3']); + }); + + it('focuses input on end keydown', () => { + const { getAllByTestId, container } = render(); + const input = container.querySelector('input'); + const tags = getAllByTestId('tag'); + + fireEvent.keyDown(tags[1], { keyCode: KEY_CODES.END }); + expect(input).toHaveFocus(); + }); + + it('remain on first tag on left keydown', () => { + const { getAllByTestId } = render(); + const tags = getAllByTestId('tag'); + + fireEvent.focus(tags[0]); + fireEvent.keyDown(tags[0], { keyCode: KEY_CODES.LEFT }); + expect(tags[0]).toHaveFocus(); + }); + + it('focus input on right keydown when last tag is focused', () => { + const { getAllByTestId, container } = render(); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + + const tags = getAllByTestId('tag'); + const lastTag = tags[tags.length - 1]; + + fireEvent.focus(lastTag); + fireEvent.keyDown(lastTag, { keyCode: KEY_CODES.RIGHT }); + + expect(input).toHaveFocus(); + }); + + describe('RTL Layout', () => { + it('focuses last tag on right arrow keydown with no input value', () => { + const { getAllByTestId, container } = renderRtl(); + const tags = getAllByTestId('tag'); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + fireEvent.keyDown(input!, { key: 'ArrowRight', keyCode: KEY_CODES.RIGHT }); + + expect(tags[tags.length - 1]).toHaveFocus(); + }); + + it('remain on first tag on right keydown', () => { + const { getAllByTestId } = renderRtl(); + const tags = getAllByTestId('tag'); + + fireEvent.focus(tags[0]); + fireEvent.keyDown(tags[0], { keyCode: KEY_CODES.RIGHT }); + expect(tags[0]).toHaveFocus(); + }); + + it('focus input on left keydown when last tag is focused', () => { + const { getAllByTestId, container } = renderRtl(); + const input = container.querySelector('input'); + + fireEvent.focus(input!); + + const tags = getAllByTestId('tag'); + const lastTag = tags[tags.length - 1]; + + fireEvent.focus(lastTag); + fireEvent.keyDown(lastTag, { keyCode: KEY_CODES.LEFT }); + + expect(input).toHaveFocus(); + }); + }); + }); +}); diff --git a/packages/dropdowns/src/Multiselect/Multiselect.tsx b/packages/dropdowns/src/Multiselect/Multiselect.tsx index 0853df4225e..183ddcf0c58 100644 --- a/packages/dropdowns/src/Multiselect/Multiselect.tsx +++ b/packages/dropdowns/src/Multiselect/Multiselect.tsx @@ -55,7 +55,7 @@ const Multiselect: React.FunctionComponent = ({ }) => { const { popperReferenceElementRef, - selectedItems, + selectedItems = [], containsMultiselectRef, downshift: { getToggleButtonProps, @@ -106,7 +106,7 @@ const Multiselect: React.FunctionComponent = ({ if (isOpen) { (e.nativeEvent as any).preventDownshiftDefault = true; } else if (!inputValue && e.keyCode === KEY_CODES.HOME) { - setFocusedItem(selectedItems![0]); + setFocusedItem(selectedItems[0]); e.preventDefault(); } }, @@ -158,7 +158,7 @@ const Multiselect: React.FunctionComponent = ({ e.preventDefault(); } - if (e.keyCode === KEY_CODES.LEFT && index === selectedItems!.length - 1) { + if (e.keyCode === KEY_CODES.LEFT && index === selectedItems.length - 1) { e.preventDefault(); inputRef.current && inputRef.current.focus(); } @@ -167,7 +167,7 @@ const Multiselect: React.FunctionComponent = ({ e.preventDefault(); } - if (e.keyCode === KEY_CODES.RIGHT && index === selectedItems!.length - 1) { + if (e.keyCode === KEY_CODES.RIGHT && index === selectedItems.length - 1) { e.preventDefault(); inputRef.current && inputRef.current.focus(); } @@ -188,38 +188,36 @@ const Multiselect: React.FunctionComponent = ({ ); const items = useMemo(() => { - if (!props.disabled) { - const itemValues = selectedItems || []; - const output = []; + const itemValues = selectedItems || []; + const output = []; - for (let x = 0; x < itemValues.length; x++) { - const item = itemValues[x]; + for (let x = 0; x < itemValues.length; x++) { + const item = itemValues[x]; - if (x < maxItems!) { - output.push(renderSelectableItem(item, x)); - } else if (!isFocused && !inputValue) { - output.push( - - {renderShowMore - ? renderShowMore(itemValues.length - x) - : `+ ${itemValues.length - x} more`} - - ); - break; + if (x < maxItems!) { + if (props.disabled) { + // eslint-disable-next-line no-empty-function + const renderedItem = renderItem({ value: item, removeValue: () => {} }); + + output.push({renderedItem}); } else { output.push(renderSelectableItem(item, x)); } + } else if ((!isFocused && !inputValue) || props.disabled) { + output.push( + + {renderShowMore + ? renderShowMore(itemValues.length - x) + : `+ ${itemValues.length - x} more`} + + ); + break; + } else { + output.push(renderSelectableItem(item, x)); } - - return output; } - return (selectedItems || []).map((item, index) => { - // eslint-disable-next-line no-empty-function - const renderedItem = renderItem({ value: item, removeValue: () => {} }); - - return {renderedItem}; - }); + return output; }, [ isFocused, props.disabled, @@ -262,18 +260,18 @@ const Multiselect: React.FunctionComponent = ({ }, onKeyDown: (e: KeyboardEvent) => { if (!inputValue) { - if (isRtl(props) && e.keyCode === KEY_CODES.RIGHT && selectedItems!.length > 0) { - setFocusedItem(selectedItems![selectedItems!.length - 1]); + if (isRtl(props) && e.keyCode === KEY_CODES.RIGHT && selectedItems.length > 0) { + setFocusedItem(selectedItems[selectedItems.length - 1]); } else if ( !isRtl(props) && e.keyCode === KEY_CODES.LEFT && - selectedItems!.length > 0 + selectedItems.length > 0 ) { - setFocusedItem(selectedItems![selectedItems!.length - 1]); - } else if (e.keyCode === KEY_CODES.BACKSPACE && selectedItems!.length > 0) { + setFocusedItem(selectedItems[selectedItems.length - 1]); + } else if (e.keyCode === KEY_CODES.BACKSPACE && selectedItems.length > 0) { (setDownshiftState as any)({ type: REMOVE_ITEM_STATE_TYPE, - selectedItem: selectedItems![selectedItems!.length - 1] + selectedItem: selectedItems[selectedItems.length - 1] }); (e as any).nativeEvent.preventDownshiftDefault = true; e.preventDefault(); @@ -284,10 +282,10 @@ const Multiselect: React.FunctionComponent = ({ onBlur: () => { closeMenu(); }, - isVisible: isFocused || inputValue || selectedItems!.length === 0, + isVisible: isFocused || inputValue || selectedItems.length === 0, isSmall: props.small, ref: inputRef, - placeholder: selectedItems!.length === 0 ? placeholder : undefined + placeholder: selectedItems.length === 0 ? placeholder : undefined }) as any)} /> From cd3b56bea08a2ab0e48eabb240b846602582ffa4 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Mon, 16 Sep 2019 11:25:40 -0700 Subject: [PATCH 4/5] Resolve JZ's comments --- packages/dropdowns/examples/multiselect.md | 4 +- packages/dropdowns/package.json | 1 + .../src/Multiselect/Multiselect.spec.tsx | 18 ++++++-- .../dropdowns/src/Multiselect/Multiselect.tsx | 41 ++++++++++++------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/dropdowns/examples/multiselect.md b/packages/dropdowns/examples/multiselect.md index 4bb03a2bfe2..cc24ac95908 100644 --- a/packages/dropdowns/examples/multiselect.md +++ b/packages/dropdowns/examples/multiselect.md @@ -119,7 +119,7 @@ function ExampleAutocomplete() { > - This example include basic debounce logic + This example includes basic debounce logic ( @@ -129,7 +129,7 @@ function ExampleAutocomplete() { )} /> - {renderOptions()} + {renderOptions()} ); } diff --git a/packages/dropdowns/package.json b/packages/dropdowns/package.json index 403904992a4..fb33c5c20f8 100644 --- a/packages/dropdowns/package.json +++ b/packages/dropdowns/package.json @@ -20,6 +20,7 @@ }, "types": "./dist/typings/index.d.ts", "dependencies": { + "@zendeskgarden/container-selection": "^1.1.5", "@zendeskgarden/container-utilities": "^0.1.2", "classnames": "^2.2.5", "downshift": "^3.2.7", diff --git a/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx b/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx index 204dac614bb..1707e3686fb 100644 --- a/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx +++ b/packages/dropdowns/src/Multiselect/Multiselect.spec.tsx @@ -6,11 +6,13 @@ */ import React from 'react'; -import { render, fireEvent, renderRtl } from 'garden-test-utils'; +import { render, fireEvent, renderRtl, act } from 'garden-test-utils'; import { Dropdown, Multiselect, Field, Menu, Item, Label } from '..'; import { IDropdownProps } from '../Dropdown/Dropdown'; import { KEY_CODES } from '@zendeskgarden/container-utilities'; +jest.useFakeTimers(); + const ExampleWrapper: React.FC = ({ children, ...other }) => ( {children} @@ -51,7 +53,7 @@ describe('Multiselect', () => { expect(document.activeElement!.nodeName).toBe('INPUT'); }); - it('closes on input blur', () => { + it('closes on multiselect blur', () => { const { getByTestId } = render( { const multiselect = getByTestId('multiselect'); const input = multiselect.querySelector('input'); - fireEvent.focus(input!); + act(() => { + fireEvent.focus(input!); + }); + expect(multiselect).toHaveClass('is-focused'); - fireEvent.blur(input!); + + act(() => { + fireEvent.blur(multiselect!); + jest.runOnlyPendingTimers(); + }); + expect(multiselect).not.toHaveClass('is-focused'); }); diff --git a/packages/dropdowns/src/Multiselect/Multiselect.tsx b/packages/dropdowns/src/Multiselect/Multiselect.tsx index 183ddcf0c58..69462efc2d7 100644 --- a/packages/dropdowns/src/Multiselect/Multiselect.tsx +++ b/packages/dropdowns/src/Multiselect/Multiselect.tsx @@ -70,6 +70,7 @@ const Multiselect: React.FunctionComponent = ({ const { isLabelHovered } = useFieldContext(); const inputRef = useRef(null); const triggerRef = useRef(null); + const blurTimeoutRef = useRef(); const previousIsOpenRef = useRef(undefined); const [isFocused, setIsFocused] = useState(false); const [focusedItem, setFocusedItem] = useState(undefined); @@ -85,6 +86,11 @@ const Multiselect: React.FunctionComponent = ({ useEffect(() => { containsMultiselectRef.current = true; + const tempRef = blurTimeoutRef; + + return () => { + clearTimeout(tempRef.current); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -93,15 +99,20 @@ const Multiselect: React.FunctionComponent = ({ if (isOpen && !previousIsOpenRef.current) { inputRef.current && inputRef.current.focus(); } - - // Focus trigger when Menu is closed - if (!isOpen && previousIsOpenRef.current) { - triggerRef.current && triggerRef.current.focus(); - } previousIsOpenRef.current = isOpen; }, [isOpen]); + /** + * Close menu when an item becomes focused + */ + useEffect(() => { + if (focusedItem !== undefined && isOpen) { + closeMenu(); + } + }, [focusedItem, isOpen, closeMenu]); + const selectProps = getToggleButtonProps({ + tabIndex: -1, onKeyDown: e => { if (isOpen) { (e.nativeEvent as any).preventDownshiftDefault = true; @@ -114,12 +125,13 @@ const Multiselect: React.FunctionComponent = ({ setIsFocused(true); }, onBlur: (e: React.FocusEvent) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setIsFocused(false); + const currentTarget = e.currentTarget; - e.preventDefault(); - (e.nativeEvent as any).preventDownshiftDefault = true; - } + blurTimeoutRef.current = (setTimeout(() => { + if (!currentTarget.contains(document.activeElement)) { + setIsFocused(false); + } + }, 0) as unknown) as number; }, ...props }); @@ -196,7 +208,6 @@ const Multiselect: React.FunctionComponent = ({ if (x < maxItems!) { if (props.disabled) { - // eslint-disable-next-line no-empty-function const renderedItem = renderItem({ value: item, removeValue: () => {} }); output.push({renderedItem}); @@ -258,6 +269,11 @@ const Multiselect: React.FunctionComponent = ({ onFocus: () => { setFocusedItem(undefined); }, + onClick: (e: MouseEvent) => { + if (inputValue && inputValue.length > 0 && isOpen) { + (e as any).nativeEvent.preventDownshiftDefault = true; + } + }, onKeyDown: (e: KeyboardEvent) => { if (!inputValue) { if (isRtl(props) && e.keyCode === KEY_CODES.RIGHT && selectedItems.length > 0) { @@ -279,9 +295,6 @@ const Multiselect: React.FunctionComponent = ({ } } }, - onBlur: () => { - closeMenu(); - }, isVisible: isFocused || inputValue || selectedItems.length === 0, isSmall: props.small, ref: inputRef, From 9c3bd1ec966081408c0a95a84c8a83e32a45d735 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Mon, 16 Sep 2019 11:30:23 -0700 Subject: [PATCH 5/5] Increase bundle-size threshold --- packages/dropdowns/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dropdowns/package.json b/packages/dropdowns/package.json index fb33c5c20f8..40e8d363162 100644 --- a/packages/dropdowns/package.json +++ b/packages/dropdowns/package.json @@ -52,6 +52,6 @@ "access": "public" }, "zendeskgarden:library": "GardenDropdowns", - "zendeskgarden:max_size": "31 kB", + "zendeskgarden:max_size": "35 kB", "zendeskgarden:src": "src/index.ts" }