From a52bb25731ebff5f74a01cc79167b274e2751f48 Mon Sep 17 00:00:00 2001 From: Jordan Rose Date: Mon, 22 Aug 2022 16:31:35 -0700 Subject: [PATCH] Typing while the emoji picker is up should enter search mode --- ts/components/conversation/ReactionPicker.tsx | 4 +- ts/components/emoji/EmojiPicker.tsx | 61 +++++++++++-------- ts/test-both/util/grapheme_test.ts | 20 +++++- ts/util/grapheme.ts | 10 ++- 4 files changed, 65 insertions(+), 30 deletions(-) diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index 0427725d4..dadc508b2 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -54,7 +54,7 @@ export const ReactionPicker = React.forwardRef( // Handle escape key React.useEffect(() => { const handler = (e: KeyboardEvent) => { - if (onClose && e.key === 'Escape') { + if (onClose && e.key === 'Escape' && !pickingOther) { onClose(); } }; @@ -64,7 +64,7 @@ export const ReactionPicker = React.forwardRef( return () => { document.removeEventListener('keydown', handler); }; - }, [onClose]); + }, [onClose, pickingOther]); // Handle EmojiPicker::onPickEmoji const onPickEmoji: EmojiPickerProps['onPickEmoji'] = React.useCallback( diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index afc503539..12f9838db 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -23,6 +23,7 @@ import FocusTrap from 'focus-trap-react'; import { Emoji } from './Emoji'; import { dataByCategory, search } from './lib'; import type { LocalizerType } from '../../types/Util'; +import { isSingleGrapheme } from '../../util/grapheme'; export type EmojiPickDataType = { skinTone?: number; @@ -152,35 +153,45 @@ export const EmojiPicker = React.memo( [doSend, onPickEmoji, selectedTone] ); - // Handle escape key + // Handle key presses, particularly Escape React.useEffect(() => { const handler = (event: KeyboardEvent) => { - if (searchMode && event.key === 'Escape') { - setScrollToRow(0); - setSearchText(''); - setSearchMode(false); - - event.preventDefault(); - event.stopPropagation(); - } else if ( - !searchMode && - !event.ctrlKey && - ![ - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'Shift', - 'Tab', - ' ', // Space - ].includes(event.key) - ) { - if (onClose) { - onClose(); + if (event.key === 'Escape') { + if (searchMode) { + setScrollToRow(0); + setSearchText(''); + setSearchMode(false); + } else { + onClose?.(); } - event.preventDefault(); event.stopPropagation(); + } else if (!searchMode && !event.ctrlKey && !event.metaKey) { + if ( + [ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Enter', + 'Shift', + 'Tab', + ' ', // Space + ].includes(event.key) + ) { + // Do nothing, these can be used to navigate around the picker. + } else if (isSingleGrapheme(event.key)) { + // A single grapheme means the user is typing text. Switch to search mode. + setSelectedCategory(categories[0]); + setSearchMode(true); + // Continue propagation, typing the first letter for search. + } else { + // For anything else, assume it's a special key that isn't one of the ones + // above (such as Delete or ContextMenu). + onClose?.(); + event.preventDefault(); + event.stopPropagation(); + } } }; @@ -189,7 +200,7 @@ export const EmojiPicker = React.memo( return () => { document.removeEventListener('keydown', handler); }; - }, [onClose, searchMode]); + }, [onClose, searchMode, setSearchMode]); const [, ...renderableCategories] = categories; diff --git a/ts/test-both/util/grapheme_test.ts b/ts/test-both/util/grapheme_test.ts index d40dc6b20..2610688dd 100644 --- a/ts/test-both/util/grapheme_test.ts +++ b/ts/test-both/util/grapheme_test.ts @@ -1,9 +1,9 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { getGraphemes, count } from '../../util/grapheme'; +import { getGraphemes, count, isSingleGrapheme } from '../../util/grapheme'; describe('grapheme utilities', () => { describe('getGraphemes', () => { @@ -63,4 +63,20 @@ describe('grapheme utilities', () => { assert.strictEqual(count('L̷̳͔̲͝Ģ̵̮̯̤̩̙͍̬̟͉̹̘̹͍͈̮̦̰̣͟͝O̶̴̮̻̮̗͘͡!̴̷̟͓͓'), 4); }); }); + + describe('isSingleGrapheme', () => { + it('returns false for the empty string', () => { + assert.isFalse(isSingleGrapheme('')); + }); + it('returns true for single graphemes', () => { + assert.isTrue(isSingleGrapheme('a')); + assert.isTrue(isSingleGrapheme('å')); + assert.isTrue(isSingleGrapheme('😍')); + }); + it('returns false for multiple graphemes', () => { + assert.isFalse(isSingleGrapheme('ab')); + assert.isFalse(isSingleGrapheme('a😍')); + assert.isFalse(isSingleGrapheme('😍a')); + }); + }); }); diff --git a/ts/util/grapheme.ts b/ts/util/grapheme.ts index 48943945d..1e192030b 100644 --- a/ts/util/grapheme.ts +++ b/ts/util/grapheme.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { map, size } from './iterables'; @@ -12,3 +12,11 @@ export function count(str: string): number { const segments = new Intl.Segmenter().segment(str); return size(segments); } + +export function isSingleGrapheme(str: string): boolean { + if (str === '') { + return false; + } + const segments = new Intl.Segmenter().segment(str); + return segments.containing(0).segment === str; +}