Skip to content

Commit 483259b

Browse files
committed
fix: Issue in Chrome browser where detection of touch-enabled devices was not working properly; feature: new property: pageSize: number (default 5) - # of options to jump in menu when page{up|down} keys are used - this new property is part of the implementation to handle 'PageUp' and 'PageDown' key-based navigation in menu
1 parent 3c93639 commit 483259b

File tree

7 files changed

+117
-76
lines changed

7 files changed

+117
-76
lines changed

src/Select.tsx

Lines changed: 84 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
FilterMatchEnum,
1919
OptionIndexEnum,
2020
MenuPositionEnum,
21-
FUNCTION_DEFAULTS,
21+
FUNCTIONS,
2222
EMPTY_ARRAY,
2323
DEFAULT_THEME,
2424
SELECT_WRAPPER_ATTRS,
@@ -51,11 +51,12 @@ import type { FixedSizeList } from 'react-window';
5151
import styled, { css, ThemeProvider, type DefaultTheme } from 'styled-components';
5252
import { Menu, Value, AriaLiveRegion, AutosizeInput, IndicatorIcons } from './components';
5353
import { useDebounce, useLatestRef, useCallbackRef, useMenuOptions, useMountEffect, useUpdateEffect, useMenuPositioner } from './hooks';
54-
import { isBoolean, isFunction, isPlainObject, mergeDeep, suppressEvent, normalizeValue, IS_TOUCH_DEVICE, isArrayWithLength } from './utils';
54+
import { isBoolean, isFunction, isPlainObject, mergeDeep, suppressEvent, normalizeValue, isTouchDevice, isArrayWithLength } from './utils';
5555

5656
type SelectProps = Readonly<{
5757
async?: boolean;
5858
inputId?: string;
59+
pageSize: number;
5960
selectId?: string;
6061
isMulti?: boolean;
6162
ariaLabel?: string;
@@ -217,6 +218,7 @@ const Select = forwardRef<SelectRef, SelectProps>((
217218
hideSelectedOptions,
218219
getIsOptionDisabled,
219220
getFilterOptionString,
221+
pageSize = 5,
220222
isSearchable = true,
221223
memoOptions = false,
222224
lazyLoadMenu = false,
@@ -258,8 +260,8 @@ const Select = forwardRef<SelectRef, SelectProps>((
258260
}, [themeConfig]);
259261

260262
// Memoized callback functions referencing optional function properties on Select.tsx
261-
const getOptionLabelFn = useMemo<OptionLabelCallback>(() => getOptionLabel || FUNCTION_DEFAULTS.optionLabel, [getOptionLabel]);
262-
const getOptionValueFn = useMemo<OptionValueCallback>(() => getOptionValue || FUNCTION_DEFAULTS.optionValue, [getOptionValue]);
263+
const getOptionLabelFn = useMemo<OptionLabelCallback>(() => getOptionLabel || FUNCTIONS.optionLabel, [getOptionLabel]);
264+
const getOptionValueFn = useMemo<OptionValueCallback>(() => getOptionValue || FUNCTIONS.optionValue, [getOptionValue]);
263265
const renderOptionLabelFn = useMemo<RenderLabelCallback>(() => renderOptionLabel || getOptionLabelFn, [renderOptionLabel, getOptionLabelFn]);
264266

265267
// Custom hook abstraction that debounces search input value (opt-in)
@@ -319,10 +321,7 @@ const Select = forwardRef<SelectRef, SelectProps>((
319321
const blurInput = (): void => inputRef.current?.blur();
320322
const focusInput = (): void => inputRef.current?.focus();
321323
const scrollToItemIndex = (idx: number): void => listRef.current?.scrollToItem(idx);
322-
323-
// Local boolean flags based on component props
324324
const hasSelectedOptions = isArrayWithLength(selectedOption);
325-
const blurInputOnSelectOrDefault = isBoolean(blurInputOnSelect) ? blurInputOnSelect : IS_TOUCH_DEVICE;
326325

327326
const openMenuAndFocusOption = useCallback((position: OptionIndexEnum): void => {
328327
if (!isArrayWithLength(menuOptions)) {
@@ -360,13 +359,14 @@ const Select = forwardRef<SelectRef, SelectProps>((
360359
setSelectedOption((prev) => !isMulti ? [selectedOpt] : [...prev, selectedOpt]);
361360
}
362361

363-
if (blurInputOnSelectOrDefault) {
362+
const blurOrDefault = isBoolean(blurInputOnSelect) ? blurInputOnSelect : isTouchDevice();
363+
if (blurOrDefault) {
364364
blurInput();
365365
} else if (closeMenuOnSelect) {
366366
setInputValue('');
367367
setMenuOpen(false);
368368
}
369-
}, [isMulti, closeMenuOnSelect, removeSelectedOption, blurInputOnSelectOrDefault]);
369+
}, [isMulti, closeMenuOnSelect, blurInputOnSelect, removeSelectedOption]);
370370

371371
/**
372372
* useImperativeHandle.
@@ -508,15 +508,30 @@ const Select = forwardRef<SelectRef, SelectProps>((
508508
const focusOptionOnArrowKey = (direction: OptionIndexEnum): void => {
509509
if (!isArrayWithLength(menuOptions)) return;
510510

511-
const index =
512-
direction === OptionIndexEnum.DOWN
513-
? (focusedOption.index + 1) % menuOptions.length
514-
: focusedOption.index > 0
515-
? focusedOption.index - 1
516-
: menuOptions.length - 1;
511+
let index = focusedOption.index;
512+
switch (direction) {
513+
case OptionIndexEnum.UP: {
514+
index = (focusedOption.index > 0) ? focusedOption.index - 1 : menuOptions.length - 1;
515+
break;
516+
}
517+
case OptionIndexEnum.DOWN: {
518+
index = (focusedOption.index + 1) % menuOptions.length;
519+
break;
520+
}
521+
case OptionIndexEnum.PAGEUP: {
522+
const pageIndex = focusedOption.index - pageSize;
523+
index = (pageIndex < 0) ? 0 : pageIndex;
524+
break;
525+
}
526+
case OptionIndexEnum.PAGEDOWN: {
527+
const pageIndex = focusedOption.index + pageSize;
528+
index = (pageIndex > menuOptions.length - 1) ? menuOptions.length - 1 : pageIndex;
529+
break;
530+
}
531+
}
517532

518533
scrollToItemIndex(index);
519-
setFocusedMultiValue(null);
534+
focusedMultiValue && setFocusedMultiValue(null);
520535
setFocusedOption({ index, ...menuOptions[index] });
521536
};
522537

@@ -531,25 +546,29 @@ const Select = forwardRef<SelectRef, SelectProps>((
531546

532547
switch (key) {
533548
case 'ArrowDown': {
534-
menuOpen
535-
? focusOptionOnArrowKey(OptionIndexEnum.DOWN)
536-
: openMenuAndFocusOption(OptionIndexEnum.FIRST);
549+
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.DOWN) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
537550
break;
538551
}
539552
case 'ArrowUp': {
540-
menuOpen
541-
? focusOptionOnArrowKey(OptionIndexEnum.UP)
542-
: openMenuAndFocusOption(OptionIndexEnum.LAST);
553+
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.UP) : openMenuAndFocusOption(OptionIndexEnum.LAST);
543554
break;
544555
}
545556
case 'ArrowLeft':
546557
case 'ArrowRight': {
547-
if (!isMulti || inputValue || renderMultiOptions) {
548-
return;
549-
}
558+
if (!isMulti || inputValue || renderMultiOptions) return;
550559
focusValueOnArrowKey(key);
551560
break;
552561
}
562+
case 'PageUp': {
563+
if (!menuOpen) return;
564+
focusOptionOnArrowKey(OptionIndexEnum.PAGEUP);
565+
break;
566+
}
567+
case 'PageDown': {
568+
if (!menuOpen) return;
569+
focusOptionOnArrowKey(OptionIndexEnum.PAGEDOWN);
570+
break;
571+
}
553572
// Handle spacebar keydown events
554573
case ' ': {
555574
if (inputValue) return;
@@ -565,9 +584,8 @@ const Select = forwardRef<SelectRef, SelectProps>((
565584
break;
566585
}
567586
case 'Enter': {
568-
if (menuOpen) {
569-
selectOptionFromFocused();
570-
}
587+
if (!menuOpen) return;
588+
selectOptionFromFocused();
571589
break;
572590
}
573591
case 'Escape': {
@@ -578,9 +596,7 @@ const Select = forwardRef<SelectRef, SelectProps>((
578596
break;
579597
}
580598
case 'Tab': {
581-
if (shiftKey || !menuOpen || !tabSelectsOption || !focusedOption.data) {
582-
return;
583-
}
599+
if (shiftKey || !menuOpen || !tabSelectsOption || !focusedOption.data) return;
584600
selectOptionFromFocused();
585601
break;
586602
}
@@ -618,11 +634,6 @@ const Select = forwardRef<SelectRef, SelectProps>((
618634
e.preventDefault();
619635
};
620636

621-
const handleOnMouseDownEvent = (e: SyntheticEvent<Element>): void => {
622-
suppressEvent(e);
623-
focusInput();
624-
};
625-
626637
const handleOnControlMouseDown = (e: MouseOrTouchEvent<HTMLElement>): void => {
627638
if (isDisabled) return;
628639
if (!isFocused) focusInput();
@@ -657,20 +668,25 @@ const Select = forwardRef<SelectRef, SelectProps>((
657668
setMenuOpen(true);
658669
}, [onInputChange]);
659670

660-
const handleOnCaretMouseDown = useCallback((e: MouseOrTouchEvent<HTMLElement>): void => {
661-
handleOnMouseDownEvent(e);
662-
menuOpenRef.current ? setMenuOpen(false) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
663-
}, [openMenuAndFocusOption]);
671+
const handleOnMouseDown = (e: SyntheticEvent<Element>): void => {
672+
suppressEvent(e);
673+
focusInput();
674+
};
664675

665676
const handleOnClearMouseDown = useCallback((e: MouseOrTouchEvent<HTMLElement>): void => {
666-
handleOnMouseDownEvent(e);
677+
handleOnMouseDown(e);
667678
setSelectedOption(EMPTY_ARRAY);
668679
}, []);
669680

670-
const renderMenu = !lazyLoadMenu || (lazyLoadMenu && menuOpen);
671-
const showClear = !!(isClearable && !isDisabled && hasSelectedOptions);
681+
const handleOnCaretMouseDown = useCallback((e: MouseOrTouchEvent<HTMLElement>): void => {
682+
if (!isDisabled && !openMenuOnClick) {
683+
handleOnMouseDown(e);
684+
menuOpenRef.current ? setMenuOpen(false) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
685+
}
686+
}, [isDisabled, openMenuOnClick, openMenuAndFocusOption]);
687+
688+
const showClear = !!isClearable && !isDisabled && hasSelectedOptions;
672689
const inputReadOnly = isDisabled || !isSearchable || !!focusedMultiValue;
673-
const handleOnCaretMouseDownOrNoop = (!isDisabled && !openMenuOnClick) ? handleOnCaretMouseDown : undefined;
674690

675691
return (
676692
<ThemeProvider theme={theme}>
@@ -725,33 +741,32 @@ const Select = forwardRef<SelectRef, SelectProps>((
725741
isDisabled={isDisabled}
726742
loadingNode={loadingNode}
727743
onClearMouseDown={handleOnClearMouseDown}
728-
onCaretMouseDown={handleOnCaretMouseDownOrNoop}
744+
onCaretMouseDown={handleOnCaretMouseDown}
729745
/>
730746
</ControlWrapper>
731-
{renderMenu && (
732-
<Menu
733-
menuRef={menuRef}
734-
menuOpen={menuOpen}
735-
isLoading={isLoading}
736-
menuTop={menuStyleTop}
737-
height={menuHeightCalc}
738-
itemSize={menuItemSize}
739-
loadingMsg={loadingMsg}
740-
menuOptions={menuOptions}
741-
memoOptions={memoOptions}
742-
fixedSizeListRef={listRef}
743-
noOptionsMsg={noOptionsMsg}
744-
selectOption={selectOption}
745-
direction={menuItemDirection}
746-
itemKeySelector={itemKeySelector}
747-
overscanCount={menuOverscanCount}
748-
menuPortalTarget={menuPortalTarget}
749-
width={menuWidth || theme.menu.width}
750-
renderOptionLabel={renderOptionLabelFn}
751-
focusedOptionIndex={focusedOption.index}
752-
onMenuMouseDown={handleOnMouseDownEvent}
753-
/>
754-
)}
747+
<Menu
748+
menuRef={menuRef}
749+
menuOpen={menuOpen}
750+
isLoading={isLoading}
751+
menuTop={menuStyleTop}
752+
height={menuHeightCalc}
753+
itemSize={menuItemSize}
754+
loadingMsg={loadingMsg}
755+
menuOptions={menuOptions}
756+
memoOptions={memoOptions}
757+
fixedSizeListRef={listRef}
758+
lazyLoadMenu={lazyLoadMenu}
759+
noOptionsMsg={noOptionsMsg}
760+
selectOption={selectOption}
761+
direction={menuItemDirection}
762+
itemKeySelector={itemKeySelector}
763+
overscanCount={menuOverscanCount}
764+
menuPortalTarget={menuPortalTarget}
765+
onMenuMouseDown={handleOnMouseDown}
766+
width={menuWidth || theme.menu.width}
767+
renderOptionLabel={renderOptionLabelFn}
768+
focusedOptionIndex={focusedOption.index}
769+
/>
755770
{isAriaLiveEnabled && (
756771
<AriaLiveRegion
757772
ariaLive={ariaLive}

src/components/IndicatorIcons/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type IndicatorIconsProps = Readonly<{
1616
clearIcon?: IconRenderer;
1717
caretIcon?: IconRenderer;
1818
onClearMouseDown: MouseOrTouchEventHandler;
19-
onCaretMouseDown?: MouseOrTouchEventHandler;
19+
onCaretMouseDown: MouseOrTouchEventHandler;
2020
}>;
2121

2222
type CaretProps = Pick<IndicatorIconsProps, 'menuOpen' | 'isInvalid'>;

src/components/Menu/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
interface MenuProps extends MenuListProps {
1717
menuTop?: string;
1818
menuOpen: boolean;
19+
lazyLoadMenu: boolean;
1920
menuPortalTarget?: Element;
2021
menuRef: MutableRefObject<HTMLDivElement | null>;
2122
onMenuMouseDown: (e: MouseOrTouchEvent<HTMLDivElement>) => void;
@@ -75,10 +76,15 @@ const Menu: FunctionComponent<MenuProps> = ({
7576
menuRef,
7677
menuTop,
7778
menuOpen,
79+
lazyLoadMenu,
7880
onMenuMouseDown,
7981
menuPortalTarget,
8082
...menuListProps
8183
}) => {
84+
if (lazyLoadMenu && !menuOpen) {
85+
return null;
86+
}
87+
8288
const { menuOptions, noOptionsMsg } = menuListProps;
8389
const hideNoOptionsMsg = menuOpen && !noOptionsMsg && !isArrayWithLength(menuOptions);
8490

src/constants/defaults.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const FOCUSED_OPTION_DEFAULT: FocusedOption = { index: -1 };
1616
// Default for options and selectedOption props
1717
export const EMPTY_ARRAY: any[] = [];
1818

19-
export const FUNCTION_DEFAULTS = {
19+
export const FUNCTIONS = {
2020
optionLabel: ((x) => x.label) as OptionLabelCallback,
2121
optionValue: ((x) => x.value) as OptionValueCallback,
2222
isOptionDisabled: ((x) => !!x.isDisabled) as OptionDisabledCallback,

src/constants/enums.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export const OptionIndexEnum = {
2828
UP: 0,
2929
DOWN: 1,
3030
LAST: 2,
31-
FIRST: 3
31+
FIRST: 3,
32+
PAGEUP: 4,
33+
PAGEDOWN: 5
3234
} as const;
3335

3436
export type OptionIndexEnum = typeof OptionIndexEnum[keyof typeof OptionIndexEnum];

src/hooks/useMenuOptions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMemo } from 'react';
22
import useCallbackRef from './useCallbackRef';
3+
import { FilterMatchEnum, FUNCTIONS } from '../constants';
34
import { isBoolean, trimAndFormatFilterStr } from '../utils';
4-
import { FilterMatchEnum, FUNCTION_DEFAULTS } from '../constants';
55
import type {
66
MenuOption,
77
OptionData,
@@ -30,8 +30,8 @@ const useMenuOptions = (
3030
async: boolean = false,
3131
hideSelectedOptions?: boolean
3232
): MenuOption[] => {
33-
const getFilterOptionStringRef = useCallbackRef(getFilterOptionString || FUNCTION_DEFAULTS.optionFilter);
34-
const getIsOptionDisabledRef = useCallbackRef(getIsOptionDisabled || FUNCTION_DEFAULTS.isOptionDisabled);
33+
const getFilterOptionStringRef = useCallbackRef(getFilterOptionString || FUNCTIONS.optionFilter);
34+
const getIsOptionDisabledRef = useCallbackRef(getIsOptionDisabled || FUNCTIONS.isOptionDisabled);
3535

3636
const searchValue = !async ? debouncedInputValue : ''; // Prevent recomputing/filtering on input mutations in async mode
3737
const isFilterMatchAny = filterMatchFrom === FilterMatchEnum.ANY;

src/utils/device.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1+
import { isBoolean } from './common';
2+
3+
let _isTouchDevice: boolean | undefined;
4+
15
/**
26
* Determines if the current device is touch-enabled.
7+
* Global, lazy evaluation.
38
*/
4-
export const IS_TOUCH_DEVICE = !!window?.ontouchstart || !!navigator?.maxTouchPoints;
9+
export const isTouchDevice = (): boolean => {
10+
if (isBoolean(_isTouchDevice)) {
11+
return _isTouchDevice;
12+
}
13+
14+
return (_isTouchDevice = (() => {
15+
try {
16+
document.createEvent('TouchEvent');
17+
return true;
18+
} catch (e) {
19+
return false;
20+
}
21+
})());
22+
};

0 commit comments

Comments
 (0)