Skip to content

Commit

Permalink
Refactor dropdown to use-keyboard-events + code cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
acusti committed Sep 21, 2023
1 parent ff942ad commit e4030e2
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 138 deletions.
25 changes: 24 additions & 1 deletion .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/dropdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@acusti/matchmaking": "^0.5.1",
"@acusti/styling": "^0.6.0",
"@acusti/use-is-out-of-bounds": "^0.7.0",
"@acusti/use-keyboard-events": "^0.4.0",
"clsx": "^1.2.1"
},
"peerDependencies": {
Expand Down
260 changes: 125 additions & 135 deletions packages/dropdown/src/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import InputText from '@acusti/input-text';
import { Style } from '@acusti/styling';
import useIsOutOfBounds from '@acusti/use-is-out-of-bounds';
import useKeyboardEvents, {
isEventTargetUsingKeyEvent,
} from '@acusti/use-keyboard-events';

Check failure on line 6 in packages/dropdown/src/Dropdown.tsx

View workflow job for this annotation

GitHub Actions / build (16.x)

Cannot find module '@acusti/use-keyboard-events' or its corresponding type declarations.

Check failure on line 6 in packages/dropdown/src/Dropdown.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Cannot find module '@acusti/use-keyboard-events' or its corresponding type declarations.

Check failure on line 6 in packages/dropdown/src/Dropdown.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

Cannot find module '@acusti/use-keyboard-events' or its corresponding type declarations.
import clsx from 'clsx';
import * as React from 'react';

Expand All @@ -19,7 +22,6 @@ import {
getActiveItemElement,
getItemElements,
ITEM_SELECTOR,
KEY_EVENT_ELEMENTS,
setActiveItem,
} from './helpers.js';

Expand Down Expand Up @@ -341,6 +343,127 @@ export default function Dropdown({
[closeDropdown, handleSubmitItem, onMouseUp],
);

const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const { altKey, ctrlKey, key, metaKey } = event;
const eventTarget = event.target as HTMLElement;
const dropdownElement = dropdownElementRef.current;
if (!dropdownElement) return;

const onEventHandled = () => {
event.stopPropagation();
event.preventDefault();
currentInputMethodRef.current = 'keyboard';
};

const isEventTargetingDropdown = dropdownElement.contains(eventTarget);

if (!isOpenRef.current) {
// If dropdown is closed, don’t handle key events if event target isn’t within dropdown
if (!isEventTargetingDropdown) return;
// Open the dropdown on spacebar, enter, or if isSearchable and user hits the ↑/↓ arrows
if (
key === ' ' ||
key === 'Enter' ||
(hasItemsRef.current && (key === 'ArrowUp' || key === 'ArrowDown'))
) {
onEventHandled();
setIsOpen(true);
}
return;
}

const isTargetUsingKeyEvents = isEventTargetUsingKeyEvent(event);

// If dropdown isOpen + hasItems & eventTargetNotUsingKeyEvents, handle characters
if (hasItemsRef.current && !isTargetUsingKeyEvents) {
let isEditingCharacters =
!ctrlKey && !metaKey && /^[A-Za-z0-9]$/.test(key);
// User could also be editing characters if there are already characters entered
// and they are hitting delete or spacebar
if (!isEditingCharacters && enteredCharactersRef.current) {
isEditingCharacters = key === ' ' || key === 'Backspace';
}

if (isEditingCharacters) {
onEventHandled();
if (key === 'Backspace') {
enteredCharactersRef.current = enteredCharactersRef.current.slice(
0,
-1,
);
} else {
enteredCharactersRef.current += key;
}

setActiveItem({
dropdownElement,
// If input element came from props, only override the input’s value
// with an exact text match so user can enter a value not in items
isExactMatch: isTriggerFromPropsRef.current,
text: enteredCharactersRef.current,
});

if (clearEnteredCharactersTimerRef.current) {
clearTimeout(clearEnteredCharactersTimerRef.current);
}

clearEnteredCharactersTimerRef.current = setTimeout(() => {
enteredCharactersRef.current = '';
clearEnteredCharactersTimerRef.current = null;
}, 1500);

return;
}
}

// If dropdown isOpen, handle submitting the value
if (key === 'Enter' || (key === ' ' && !inputElementRef.current)) {
onEventHandled();
handleSubmitItem(event);
return;
}

// If dropdown isOpen, handle closing it on escape or spacebar if !hasItems
if (
key === 'Escape' ||
(isEventTargetingDropdown && key === ' ' && !hasItemsRef.current)
) {
// Close dropdown if hasItems or event target not using key events
if (hasItemsRef.current || !isTargetUsingKeyEvents) {
closeDropdown();
}
return;
}

// Handle ↑/↓ arrows
if (hasItemsRef.current) {
if (key === 'ArrowUp') {
onEventHandled();
if (altKey || metaKey) {
setActiveItem({ dropdownElement, index: 0 });
} else {
setActiveItem({ dropdownElement, indexAddend: -1 });
}
return;
}
if (key === 'ArrowDown') {
onEventHandled();
if (altKey || metaKey) {
// Using a negative index counts back from the end
setActiveItem({ dropdownElement, index: -1 });
} else {
setActiveItem({ dropdownElement, indexAddend: 1 });
}
return;
}
}
},
[closeDropdown, handleSubmitItem],
);

useKeyboardEvents({ ignoreUsedKeyboardEvents: false, onKeyDown: handleKeyDown });

const cleanupEventListenersRef = useRef<() => void>(noop);

const handleRef = useCallback(
Expand Down Expand Up @@ -394,135 +517,6 @@ export default function Dropdown({
}
};

const handleGlobalKeyDown = (event: KeyboardEvent) => {
const { altKey, ctrlKey, key, metaKey } = event;
const eventTarget = event.target as HTMLElement;
const dropdownElement = dropdownElementRef.current;
if (!dropdownElement) return;

const onEventHandled = () => {
event.stopPropagation();
event.preventDefault();
currentInputMethodRef.current = 'keyboard';
};

const isEventTargetingDropdown = dropdownElement.contains(eventTarget);

if (!isOpenRef.current) {
// If dropdown is closed, don’t handle key events if event target isn’t within dropdown
if (!isEventTargetingDropdown) return;
// Open the dropdown on spacebar, enter, or if isSearchable and user hits the ↑/↓ arrows
if (
key === ' ' ||
key === 'Enter' ||
(hasItemsRef.current &&
(key === 'ArrowUp' || key === 'ArrowDown'))
) {
onEventHandled();
setIsOpen(true);
return;
}

return;
}

// If dropdown isOpen, hasItems, and not isSearchable, handle entering characters
if (hasItemsRef.current && !inputElementRef.current) {
let isEditingCharacters =
!ctrlKey && !metaKey && /^[A-Za-z0-9]$/.test(key);
// User could also be editing characters if there are already characters entered
// and they are hitting delete or spacebar
if (!isEditingCharacters && enteredCharactersRef.current) {
isEditingCharacters = key === ' ' || key === 'Backspace';
}

if (isEditingCharacters) {
onEventHandled();
if (key === 'Backspace') {
enteredCharactersRef.current =
enteredCharactersRef.current.slice(0, -1);
} else {
enteredCharactersRef.current += key;
}

setActiveItem({
dropdownElement,
// If input element came from props, only override the input’s value
// with an exact text match so user can enter a value not in items
isExactMatch: isTriggerFromPropsRef.current,
text: enteredCharactersRef.current,
});

if (clearEnteredCharactersTimerRef.current) {
clearTimeout(clearEnteredCharactersTimerRef.current);
}

clearEnteredCharactersTimerRef.current = setTimeout(() => {
enteredCharactersRef.current = '';
clearEnteredCharactersTimerRef.current = null;
}, 1500);

return;
}
}
// If dropdown isOpen, handle submitting the value
if (key === 'Enter' || (key === ' ' && !inputElementRef.current)) {
onEventHandled();
handleSubmitItem(event);
return;
}
// If dropdown isOpen, handle closing it on escape or spacebar if !hasItems
if (
key === 'Escape' ||
(isEventTargetingDropdown && key === ' ' && !hasItemsRef.current)
) {
// If there are no items & event target element uses key events, don’t close it
if (
!hasItemsRef.current &&
(eventTarget.isContentEditable ||
KEY_EVENT_ELEMENTS.has(eventTarget.tagName))
) {
return;
}
closeDropdown();
return;
}
// Handle ↑/↓ arrows
if (hasItemsRef.current) {
if (key === 'ArrowUp') {
onEventHandled();
if (altKey || metaKey) {
setActiveItem({
dropdownElement,
index: 0,
});
} else {
setActiveItem({
dropdownElement,
indexAddend: -1,
});
}
return;
}
if (key === 'ArrowDown') {
onEventHandled();
if (altKey || metaKey) {
// Using a negative index counts back from the end
setActiveItem({
dropdownElement,
index: -1,
});
} else {
setActiveItem({
dropdownElement,
indexAddend: 1,
});
}
return;
}
}
};

// Close dropdown if any element is focused outside of this dropdown
const handleGlobalFocusIn = ({ target }: Event) => {
if (!isOpenRef.current) return;
Expand All @@ -541,13 +535,11 @@ export default function Dropdown({
};

document.addEventListener('focusin', handleGlobalFocusIn);
document.addEventListener('keydown', handleGlobalKeyDown);
document.addEventListener('mousedown', handleGlobalMouseDown);
document.addEventListener('mouseup', handleGlobalMouseUp);

if (ownerDocument !== document) {
ownerDocument.addEventListener('focusin', handleGlobalFocusIn);
ownerDocument.addEventListener('keydown', handleGlobalKeyDown);
ownerDocument.addEventListener('mousedown', handleGlobalMouseDown);
ownerDocument.addEventListener('mouseup', handleGlobalMouseUp);
}
Expand Down Expand Up @@ -585,13 +577,11 @@ export default function Dropdown({

cleanupEventListenersRef.current = () => {
document.removeEventListener('focusin', handleGlobalFocusIn);
document.removeEventListener('keydown', handleGlobalKeyDown);
document.removeEventListener('mousedown', handleGlobalMouseDown);
document.removeEventListener('mouseup', handleGlobalMouseUp);

if (ownerDocument !== document) {
ownerDocument.removeEventListener('focusin', handleGlobalFocusIn);
ownerDocument.removeEventListener('keydown', handleGlobalKeyDown);
ownerDocument.removeEventListener('mousedown', handleGlobalMouseDown);
ownerDocument.removeEventListener('mouseup', handleGlobalMouseUp);
}
Expand All @@ -601,7 +591,7 @@ export default function Dropdown({
}
};
},
[closeDropdown, handleSubmitItem, isOpenOnMount, isTriggerFromProps],
[closeDropdown, isOpenOnMount, isTriggerFromProps],
);

const handleTriggerFocus = useCallback(() => {
Expand Down
1 change: 0 additions & 1 deletion packages/dropdown/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getBestMatch } from '@acusti/matchmaking';
import { BODY_SELECTOR } from './styles.js';

export const ITEM_SELECTOR = `[data-ukt-item], [data-ukt-value]`;
export const KEY_EVENT_ELEMENTS = new Set(['INPUT', 'TEXTAREA']);

export const getItemElements = (dropdownElement: HTMLElement | null) => {
if (!dropdownElement) return null;
Expand Down
3 changes: 2 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ __metadata:
"@acusti/matchmaking": ^0.5.1
"@acusti/styling": ^0.6.0
"@acusti/use-is-out-of-bounds": ^0.7.0
"@acusti/use-keyboard-events": ^0.4.0
"@testing-library/dom": ^9.3.1
"@testing-library/react": ^14.0.0
"@testing-library/user-event": ^14.4.3
Expand Down Expand Up @@ -219,7 +220,7 @@ __metadata:
languageName: unknown
linkType: soft

"@acusti/use-keyboard-events@workspace:packages/use-keyboard-events":
"@acusti/use-keyboard-events@^0.4.0, @acusti/use-keyboard-events@workspace:packages/use-keyboard-events":
version: 0.0.0-use.local
resolution: "@acusti/use-keyboard-events@workspace:packages/use-keyboard-events"
dependencies:
Expand Down

0 comments on commit e4030e2

Please sign in to comment.