Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dropdowns): allow Select to search with keyboard interaction #787

Merged
merged 1 commit into from
Jun 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/dropdowns/.size-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@
}
},
"index.cjs.js": {
"bundled": 76777,
"minified": 48949,
"gzipped": 10574
"bundled": 82972,
"minified": 51254,
"gzipped": 11101
},
"index.esm.js": {
"bundled": 74148,
"minified": 46432,
"gzipped": 10399,
"bundled": 80164,
"minified": 48558,
"gzipped": 10927,
"treeshaked": {
"rollup": {
"code": 35742,
"code": 37693,
"import_statements": 807
},
"webpack": {
"code": 39593
"code": 41851
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/dropdowns/src/elements/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const Dropdown: React.FunctionComponent<IDropdownProps & ThemeProps<DefaultTheme
const previousIndexRef = useRef<number | undefined>(undefined);
const nextItemsHashRef = useRef<Record<string, unknown>>({});
const containsMultiselectRef = useRef(false);
const itemSearchRegistry = useRef([]);

// Ref used to determine ARIA attributes for menu dropdowns
const hasMenuRef = useRef(false);
Expand Down Expand Up @@ -214,7 +215,8 @@ const Dropdown: React.FunctionComponent<IDropdownProps & ThemeProps<DefaultTheme
popperReferenceElementRef,
selectedItems,
downshift: transformDownshift(downshift),
containsMultiselectRef
containsMultiselectRef,
itemSearchRegistry
}}
>
{children}
Expand Down
19 changes: 16 additions & 3 deletions packages/dropdowns/src/elements/Menu/Items/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import React, { useEffect, HTMLAttributes } from 'react';
import PropTypes from 'prop-types';
import { useCombinedRefs } from '@zendeskgarden/container-utilities';
import SelectedSvg from '@zendeskgarden/svg-icons/src/16/check-lg-stroke.svg';
import { StyledItem, StyledItemIcon } from '../../../styled';
import useDropdownContext from '../../../utils/useDropdownContext';
Expand All @@ -29,10 +30,11 @@ export interface IItemProps extends HTMLAttributes<HTMLLIElement> {
* Accepts all `<li>` props
*/
export const Item = React.forwardRef<HTMLLIElement, IItemProps>(
({ value, disabled, component = StyledItem, children, ...props }, ref) => {
({ value, disabled, component = StyledItem, children, ...props }, forwardRef) => {
const {
selectedItems,
hasMenuRef,
itemSearchRegistry,
downshift: {
isOpen,
selectedItem,
Expand All @@ -43,6 +45,7 @@ export const Item = React.forwardRef<HTMLLIElement, IItemProps>(
}
} = useDropdownContext();
const { itemIndexRef, isCompact } = useMenuContext();
const itemRef = useCombinedRefs(forwardRef);
const Component = component;

if ((value === undefined || value === null) && !disabled) {
Expand All @@ -53,6 +56,16 @@ export const Item = React.forwardRef<HTMLLIElement, IItemProps>(
const isFocused = highlightedIndex === currentIndex;
let isSelected: boolean;

useEffect(() => {
if (!disabled && itemRef.current) {
const itemTextValue = itemRef.current!.innerText;

if (itemTextValue) {
itemSearchRegistry.current[currentIndex] = itemTextValue;
}
}
});

// Calculate selection if provided value is an `object`
if (value) {
if (selectedItems) {
Expand All @@ -76,7 +89,7 @@ export const Item = React.forwardRef<HTMLLIElement, IItemProps>(
if (disabled) {
return (
<ItemContext.Provider value={{ isDisabled: disabled }}>
<Component ref={ref} disabled={disabled} isCompact={isCompact} {...props}>
<Component ref={itemRef} disabled={disabled} isCompact={isCompact} {...props}>
{isSelected && (
<StyledItemIcon isCompact={isCompact} isVisible={isSelected} isDisabled={disabled}>
<SelectedSvg />
Expand All @@ -99,7 +112,7 @@ export const Item = React.forwardRef<HTMLLIElement, IItemProps>(
{...getItemProps({
item: value,
isFocused,
ref,
ref: itemRef,
isCompact,
...(hasMenuRef.current && {
role: 'menuitem',
Expand Down
2 changes: 2 additions & 0 deletions packages/dropdowns/src/elements/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const Menu: React.FunctionComponent<IMenuProps & ThemeProps<DefaultTheme>> = pro
previousIndexRef,
nextItemsHashRef,
popperReferenceElementRef,
itemSearchRegistry,
downshift: { isOpen, getMenuProps }
} = useDropdownContext();
const scheduleUpdateRef = useRef<(() => void) | undefined>(undefined);
Expand Down Expand Up @@ -95,6 +96,7 @@ const Menu: React.FunctionComponent<IMenuProps & ThemeProps<DefaultTheme>> = pro
itemIndexRef.current = 0;
nextItemsHashRef.current = {};
previousIndexRef.current = undefined;
itemSearchRegistry.current = [];

const popperPlacement = isRtl(props)
? getRtlPopperPlacement(placement!)
Expand Down
130 changes: 126 additions & 4 deletions packages/dropdowns/src/elements/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import React, { useRef, useEffect, HTMLAttributes } from 'react';
import { useCombinedRefs } from '@zendeskgarden/container-utilities';
import React, { useRef, useState, useEffect, useCallback, HTMLAttributes } from 'react';
import { useCombinedRefs, KEY_CODES } from '@zendeskgarden/container-utilities';
import PropTypes from 'prop-types';
import { Reference } from 'react-popper';
import { StyledInput, SelectWrapper, StyledOverflowWrapper, StyledStartIcon } from '../../styled';
Expand Down Expand Up @@ -37,12 +37,24 @@ export const Select = React.forwardRef<HTMLDivElement, ISelectProps>(
({ children, start, ...props }, ref) => {
const {
popperReferenceElementRef,
downshift: { getToggleButtonProps, getInputProps, isOpen }
itemSearchRegistry,
downshift: {
getToggleButtonProps,
getInputProps,
isOpen,
highlightedIndex,
setHighlightedIndex,
selectItemAtIndex,
closeMenu
}
} = useDropdownContext();
const { isLabelHovered } = useFieldContext();
const hiddenInputRef = useRef<HTMLInputElement>(null);
const triggerRef = useCombinedRefs<HTMLDivElement>(ref, popperReferenceElementRef);
const previousIsOpenRef = useRef<boolean | undefined>(undefined);
const [searchString, setSearchString] = useState('');
const searchTimeoutRef = useRef<number>();
const currentSearchIndexRef = useRef<number>(0);

useEffect(() => {
// Focus internal input when Menu is opened
Expand All @@ -57,6 +69,115 @@ export const Select = React.forwardRef<HTMLDivElement, ISelectProps>(
previousIsOpenRef.current = isOpen;
}, [isOpen, triggerRef]);

/**
* Handle timeouts for clearing search text
*/
useEffect(() => {
// Cancel existing timeout if keys are pressed rapidly
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}

// Reset search string after delay
searchTimeoutRef.current = window.setTimeout(() => {
setSearchString('');
}, 500);

return () => {
clearTimeout(searchTimeoutRef.current);
};
}, [searchString]);

/**
* Search item value registry based around current highlight bounds
*/
const searchItems = useCallback(
(searchValue: string, startIndex: number, endIndex: number) => {
for (let index = startIndex; index < endIndex; index++) {
const itemTextValue = itemSearchRegistry.current[index];

if (
itemTextValue &&
itemTextValue.toUpperCase().indexOf(searchValue.toUpperCase()) === 0
) {
return index;
}
}

return undefined;
},
[itemSearchRegistry]
);

const onInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.keyCode === KEY_CODES.SPACE) {
// Prevent space from closing Menu only if used with existing search value
if (searchString) {
e.preventDefault();
e.stopPropagation();
} else if (highlightedIndex !== null && highlightedIndex !== undefined) {
e.preventDefault();
e.stopPropagation();

selectItemAtIndex(highlightedIndex);
closeMenu();
}
}

// Only search with alphanumeric keys
if (
(e.keyCode < 48 || e.keyCode > 57) &&
(e.keyCode < 65 || e.keyCode > 90) &&
e.keyCode !== KEY_CODES.SPACE
) {
return;
}

const character = String.fromCharCode(e.which || e.keyCode);

if (!character || character.length === 0) {
return;
}

// Reset starting search index after delay has removed previous values
if (!searchString) {
if (highlightedIndex === null || highlightedIndex === undefined) {
currentSearchIndexRef.current = -1;
} else {
currentSearchIndexRef.current = highlightedIndex;
}
}

const newSearchString = searchString + character;

setSearchString(newSearchString);

let matchingIndex = searchItems(
newSearchString,
currentSearchIndexRef.current + 1,
itemSearchRegistry.current.length
);

if (matchingIndex === undefined) {
matchingIndex = searchItems(newSearchString, 0, currentSearchIndexRef.current);
}

if (matchingIndex !== undefined) {
setHighlightedIndex(matchingIndex);
}
},
[
searchString,
searchItems,
itemSearchRegistry,
highlightedIndex,
selectItemAtIndex,
closeMenu,
setHighlightedIndex
]
);

/**
* Destructure type out of props so that `type="button"`
* is not spread onto the Select Dropdown `div`.
Expand Down Expand Up @@ -103,7 +224,8 @@ export const Select = React.forwardRef<HTMLDivElement, ISelectProps>(
isHidden: true,
tabIndex: -1,
ref: hiddenInputRef,
value: ''
value: '',
onKeyDown: onInputKeyDown
} as any)}
/>
</SelectWrapper>
Expand Down
Loading