emoji-picker-react 4.20.4 → 4.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/body/EmojiCategory.d.ts +2 -1
- package/dist/components/emoji/Emoji.d.ts +2 -1
- package/dist/components/header/SkinTonePicker/BtnSkinToneVariation.d.ts +2 -1
- package/dist/emoji-picker-react.cjs.development.js +158 -32
- package/dist/emoji-picker-react.cjs.development.js.map +1 -1
- package/dist/emoji-picker-react.cjs.production.min.js +1 -1
- package/dist/emoji-picker-react.cjs.production.min.js.map +1 -1
- package/dist/emoji-picker-react.esm.js +158 -32
- package/dist/emoji-picker-react.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/DomUtils/elementPositionInRow.ts +11 -0
- package/src/components/Reactions/Reactions.tsx +20 -0
- package/src/components/body/EmojiCategory.tsx +9 -1
- package/src/components/body/EmojiList.tsx +25 -3
- package/src/components/emoji/Emoji.tsx +3 -0
- package/src/components/header/SkinTonePicker/BtnSkinToneVariation.tsx +3 -0
- package/src/components/header/SkinTonePicker/SkinTonePicker.tsx +6 -0
- package/src/hooks/useKeyboardNavigation.ts +81 -1
package/package.json
CHANGED
|
@@ -11,6 +11,17 @@ export function elementCountInRow(
|
|
|
11
11
|
return 0;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// The virtualized layout positions emojis with a fixed per-row count that
|
|
15
|
+
// is rendered onto the category content element. That count is the single
|
|
16
|
+
// source of truth: deriving it again from fractional rect widths can be
|
|
17
|
+
// off by one (e.g. category padding shrinking the content box), which
|
|
18
|
+
// makes arrow-up/arrow-down drift sideways by a column.
|
|
19
|
+
// https://github.com/ealush/emoji-picker-react/issues/502
|
|
20
|
+
const declared = Number(parent.dataset?.emojisPerRow);
|
|
21
|
+
if (Number.isInteger(declared) && declared > 0) {
|
|
22
|
+
return declared;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
const parentWidth = parent.getBoundingClientRect().width;
|
|
15
26
|
const elementWidth = element.getBoundingClientRect().width;
|
|
16
27
|
return Math.floor(parentWidth / elementWidth);
|
|
@@ -52,6 +52,7 @@ export function Reactions() {
|
|
|
52
52
|
unified={reaction}
|
|
53
53
|
showVariations={false}
|
|
54
54
|
className={cx(styles.emojiButton)}
|
|
55
|
+
buttonClassName={cx(styles.reactionButton)}
|
|
55
56
|
noBackground
|
|
56
57
|
getEmojiUrl={getEmojiUrl}
|
|
57
58
|
/>
|
|
@@ -77,6 +78,25 @@ const styles = stylesheet.create({
|
|
|
77
78
|
alignItems: 'center',
|
|
78
79
|
height: '100%',
|
|
79
80
|
},
|
|
81
|
+
reactionButton: {
|
|
82
|
+
// Every picker button sets outline: none, and reaction buttons also
|
|
83
|
+
// suppress the focus background, so keyboard focus was invisible.
|
|
84
|
+
// Focus mirrors the hover treatment (scale plus a soft circular
|
|
85
|
+
// wash, no outline ring): the scale transform on the inner image
|
|
86
|
+
// alone is clipped by the button's overflow: hidden, and does
|
|
87
|
+
// nothing on inline native emoji, so the button itself carries the
|
|
88
|
+
// effect. Scoped to the reactions bar so grid focus treatment is
|
|
89
|
+
// untouched.
|
|
90
|
+
// https://github.com/ealush/emoji-picker-react/issues/473
|
|
91
|
+
':focus-visible': {
|
|
92
|
+
transform: 'scale(1.2)',
|
|
93
|
+
transition:
|
|
94
|
+
'background-color 0.2s, transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.5)',
|
|
95
|
+
backgroundColor: 'var(--epr-hover-bg-color-reduced-opacity)',
|
|
96
|
+
borderRadius: '50%',
|
|
97
|
+
outline: 'none',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
80
100
|
emojiButton: {
|
|
81
101
|
':hover': {
|
|
82
102
|
transform: 'scale(1.2)',
|
|
@@ -19,6 +19,7 @@ type Props = Readonly<{
|
|
|
19
19
|
hidden?: boolean;
|
|
20
20
|
hiddenOnSearch?: boolean;
|
|
21
21
|
height?: number;
|
|
22
|
+
emojisPerRow?: number;
|
|
22
23
|
}>;
|
|
23
24
|
|
|
24
25
|
export function EmojiCategory({
|
|
@@ -27,6 +28,7 @@ export function EmojiCategory({
|
|
|
27
28
|
hidden,
|
|
28
29
|
hiddenOnSearch,
|
|
29
30
|
height,
|
|
31
|
+
emojisPerRow,
|
|
30
32
|
}: Props) {
|
|
31
33
|
const category = categoryFromCategoryConfig(categoryConfig);
|
|
32
34
|
const categoryName = categoryNameFromCategoryConfig(categoryConfig);
|
|
@@ -39,10 +41,16 @@ export function EmojiCategory({
|
|
|
39
41
|
hiddenOnSearch && commonInteractionStyles.hiddenOnSearch,
|
|
40
42
|
)}
|
|
41
43
|
data-name={category}
|
|
44
|
+
role="rowgroup"
|
|
42
45
|
aria-label={categoryName}
|
|
43
46
|
>
|
|
44
47
|
<h2 className={cx(styles.label)}>{categoryName}</h2>
|
|
45
|
-
<div
|
|
48
|
+
<div
|
|
49
|
+
className={cx(styles.categoryContent)}
|
|
50
|
+
style={{ height }}
|
|
51
|
+
role="row"
|
|
52
|
+
data-emojis-per-row={emojisPerRow}
|
|
53
|
+
>
|
|
46
54
|
{children}
|
|
47
55
|
</div>
|
|
48
56
|
</li>
|
|
@@ -27,10 +27,19 @@ export function EmojiList({ scrollTop }: { scrollTop: number }) {
|
|
|
27
27
|
const labelHeight = getLabelHeight(EmojiListRef.current);
|
|
28
28
|
|
|
29
29
|
let topOffset = 0;
|
|
30
|
+
// role="grid" makes screen readers switch out of virtual-cursor mode so
|
|
31
|
+
// arrow keys reach the picker's keyboard handler. Each category is one
|
|
32
|
+
// rowgroup: visual rows shift under virtualization, so the category is
|
|
33
|
+
// the only stable row unit. Emoji buttons intentionally keep their
|
|
34
|
+
// native button role for activation semantics.
|
|
35
|
+
// https://github.com/ealush/emoji-picker-react/issues/508
|
|
36
|
+
// The list markup is kept for a backwards-compatible DOM structure;
|
|
37
|
+
// the grid role override is intentional.
|
|
30
38
|
return (
|
|
31
|
-
|
|
39
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
|
40
|
+
<ul className={cx(styles.emojiList)} ref={EmojiListRef} role="grid">
|
|
32
41
|
<MeasureEmoji />
|
|
33
|
-
{categories.map((categoryConfig) => {
|
|
42
|
+
{categories.map((categoryConfig, index) => {
|
|
34
43
|
const category = categoryFromCategoryConfig(categoryConfig);
|
|
35
44
|
|
|
36
45
|
const currentOffset = topOffset;
|
|
@@ -45,6 +54,7 @@ export function EmojiList({ scrollTop }: { scrollTop: number }) {
|
|
|
45
54
|
categoryEmojis={getEmojisByCategory(category)}
|
|
46
55
|
categoryConfig={categoryConfig}
|
|
47
56
|
topOffset={currentOffset}
|
|
57
|
+
isFirstCategory={index === 0}
|
|
48
58
|
onHeightReady={(height) => {
|
|
49
59
|
if (categoryHeights[category] !== height) {
|
|
50
60
|
setCategoryHeights((prev) => ({
|
|
@@ -67,27 +77,39 @@ function RenderCategory({
|
|
|
67
77
|
topOffset,
|
|
68
78
|
onHeightReady,
|
|
69
79
|
scrollTop,
|
|
80
|
+
isFirstCategory,
|
|
70
81
|
}: {
|
|
71
82
|
categoryEmojis: DataEmojis;
|
|
72
83
|
categoryConfig: CategoryConfig;
|
|
73
84
|
topOffset: number;
|
|
74
85
|
onHeightReady: (height: number) => void;
|
|
75
86
|
scrollTop: number;
|
|
87
|
+
isFirstCategory: boolean;
|
|
76
88
|
}) {
|
|
77
89
|
const [visibleCategories] = useVisibleCategoriesState();
|
|
78
90
|
|
|
91
|
+
// The observer is the only writer of visibleCategories. When it never
|
|
92
|
+
// fires (zero-height container at mount, hidden modal), no category
|
|
93
|
+
// would ever render. The first category paints optimistically to break
|
|
94
|
+
// the deadlock; the observer takes over from there.
|
|
95
|
+
// https://github.com/ealush/emoji-picker-react/issues/469
|
|
96
|
+
// https://github.com/ealush/emoji-picker-react/issues/475
|
|
97
|
+
const isCategoryVisible =
|
|
98
|
+
isFirstCategory || visibleCategories.includes(categoryConfig.category);
|
|
99
|
+
|
|
79
100
|
const { virtualizedCounter, emojis, dimensions } = useEmojiVirtualization({
|
|
80
101
|
categoryEmojis,
|
|
81
102
|
topOffset,
|
|
82
103
|
onHeightReady,
|
|
83
104
|
scrollTop,
|
|
84
|
-
isCategoryVisible
|
|
105
|
+
isCategoryVisible,
|
|
85
106
|
});
|
|
86
107
|
|
|
87
108
|
return (
|
|
88
109
|
<EmojiCategory
|
|
89
110
|
categoryConfig={categoryConfig}
|
|
90
111
|
height={dimensions?.categoryHeight}
|
|
112
|
+
emojisPerRow={dimensions?.emojisPerRow}
|
|
91
113
|
// Indicates that there are no visible emojis
|
|
92
114
|
// Hence, the category should be hidden
|
|
93
115
|
hidden={!emojis.length && virtualizedCounter === 0}
|
|
@@ -14,6 +14,7 @@ type ClickableEmojiProps = Readonly<
|
|
|
14
14
|
hiddenOnSearch?: boolean;
|
|
15
15
|
emoji: DataEmoji;
|
|
16
16
|
className?: string;
|
|
17
|
+
buttonClassName?: string;
|
|
17
18
|
noBackground?: boolean;
|
|
18
19
|
style?: React.CSSProperties;
|
|
19
20
|
}
|
|
@@ -30,6 +31,7 @@ export function ClickableEmoji({
|
|
|
30
31
|
lazyLoad,
|
|
31
32
|
getEmojiUrl,
|
|
32
33
|
className,
|
|
34
|
+
buttonClassName,
|
|
33
35
|
noBackground = false,
|
|
34
36
|
style,
|
|
35
37
|
}: ClickableEmojiProps) {
|
|
@@ -45,6 +47,7 @@ export function ClickableEmoji({
|
|
|
45
47
|
unified={unified}
|
|
46
48
|
noBackground={noBackground}
|
|
47
49
|
style={style}
|
|
50
|
+
className={buttonClassName}
|
|
48
51
|
>
|
|
49
52
|
<ViewOnlyEmoji
|
|
50
53
|
unified={unified}
|
|
@@ -12,6 +12,7 @@ type Props = {
|
|
|
12
12
|
isActive: boolean;
|
|
13
13
|
skinToneVariation: SkinTones;
|
|
14
14
|
style?: React.CSSProperties;
|
|
15
|
+
tabIndex?: number;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
// eslint-disable-next-line complexity
|
|
@@ -21,11 +22,13 @@ export function BtnSkinToneVariation({
|
|
|
21
22
|
isActive,
|
|
22
23
|
skinToneVariation,
|
|
23
24
|
style,
|
|
25
|
+
tabIndex,
|
|
24
26
|
}: Props) {
|
|
25
27
|
return (
|
|
26
28
|
<Button
|
|
27
29
|
style={style}
|
|
28
30
|
onClick={onClick}
|
|
31
|
+
tabIndex={tabIndex}
|
|
29
32
|
className={cx(
|
|
30
33
|
`epr-tone-${skinToneVariation}`,
|
|
31
34
|
styles.tone,
|
|
@@ -81,6 +81,12 @@ export function SkinTonePicker({
|
|
|
81
81
|
key={skinToneVariation}
|
|
82
82
|
skinToneVariation={skinToneVariation}
|
|
83
83
|
isOpen={isOpen}
|
|
84
|
+
// Roving tabindex: when the fan is closed the inactive tones
|
|
85
|
+
// are invisible (opacity: 0, stacked), so they must not be
|
|
86
|
+
// reachable via Tab. The active tone stays tabbable to open
|
|
87
|
+
// the fan from the keyboard.
|
|
88
|
+
// https://github.com/ealush/emoji-picker-react/issues/492
|
|
89
|
+
tabIndex={isOpen || active ? 0 : -1}
|
|
84
90
|
style={{
|
|
85
91
|
transform: cx(
|
|
86
92
|
vertical
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { hasNextElementSibling } from '../DomUtils/elementPositionInRow';
|
|
4
4
|
import {
|
|
5
|
+
focusElement,
|
|
5
6
|
focusNextElementSibling,
|
|
6
7
|
focusPrevElementSibling,
|
|
7
8
|
} from '../DomUtils/focusElement';
|
|
@@ -20,10 +21,14 @@ import {
|
|
|
20
21
|
useBodyRef,
|
|
21
22
|
useCategoryNavigationRef,
|
|
22
23
|
usePickerMainRef,
|
|
24
|
+
useReactionsRef,
|
|
23
25
|
useSearchInputRef,
|
|
24
26
|
useSkinTonePickerRef,
|
|
25
27
|
} from '../components/context/ElementRefContext';
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
useReactionsModeState,
|
|
30
|
+
useSkinToneFanOpenState,
|
|
31
|
+
} from '../components/context/PickerContext';
|
|
27
32
|
import { useSearchDisabledConfig } from '../config/useConfig';
|
|
28
33
|
|
|
29
34
|
import {
|
|
@@ -59,6 +64,7 @@ export function useKeyboardNavigation() {
|
|
|
59
64
|
useSearchInputKeyboardEvents();
|
|
60
65
|
useSkinTonePickerKeyboardEvents();
|
|
61
66
|
useCategoryNavigationKeyboardEvents();
|
|
67
|
+
useReactionsKeyboardEvents();
|
|
62
68
|
useBodyKeyboardEvents();
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -319,6 +325,80 @@ function useCategoryNavigationKeyboardEvents() {
|
|
|
319
325
|
}, [CategoryNavigationRef, BodyRef, onKeyDown]);
|
|
320
326
|
}
|
|
321
327
|
|
|
328
|
+
// Arrow keys move between reaction buttons (and the expand button)
|
|
329
|
+
// while the reactions bar has focus. The bar lives outside the scroll
|
|
330
|
+
// body, so the body handler never sees these events.
|
|
331
|
+
// https://github.com/ealush/emoji-picker-react/issues/411
|
|
332
|
+
function useReactionsKeyboardEvents() {
|
|
333
|
+
const ReactionsRef = useReactionsRef();
|
|
334
|
+
// The reactions bar mounts late when the user collapses the full
|
|
335
|
+
// picker, so the listener effect must rerun when it opens.
|
|
336
|
+
const [reactionsOpen] = useReactionsModeState();
|
|
337
|
+
|
|
338
|
+
const onKeyDown = useMemo(
|
|
339
|
+
() =>
|
|
340
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
341
|
+
const { key } = event;
|
|
342
|
+
const current = ReactionsRef.current;
|
|
343
|
+
|
|
344
|
+
if (!current) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const delta = reactionFocusDelta(key);
|
|
349
|
+
|
|
350
|
+
if (delta !== 0 && focusReactionSibling(current, delta)) {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
// reactionsOpen: the bar mounts late on collapse, and the new
|
|
355
|
+
// callback identity reinstalls the listener effect below.
|
|
356
|
+
[ReactionsRef, reactionsOpen],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const current = ReactionsRef.current;
|
|
361
|
+
|
|
362
|
+
if (!current) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
current.addEventListener('keydown', onKeyDown);
|
|
367
|
+
|
|
368
|
+
return () => {
|
|
369
|
+
current.removeEventListener('keydown', onKeyDown);
|
|
370
|
+
};
|
|
371
|
+
}, [ReactionsRef, onKeyDown]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function reactionFocusDelta(key: string): number {
|
|
375
|
+
switch (key) {
|
|
376
|
+
case KeyboardEvents.ArrowRight:
|
|
377
|
+
case KeyboardEvents.ArrowDown:
|
|
378
|
+
return 1;
|
|
379
|
+
case KeyboardEvents.ArrowLeft:
|
|
380
|
+
case KeyboardEvents.ArrowUp:
|
|
381
|
+
return -1;
|
|
382
|
+
default:
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function focusReactionSibling(root: HTMLElement, delta: number): boolean {
|
|
388
|
+
const buttons = Array.from(root.querySelectorAll('button'));
|
|
389
|
+
const activeIndex = buttons.indexOf(
|
|
390
|
+
getActiveElement() as HTMLButtonElement,
|
|
391
|
+
);
|
|
392
|
+
const sibling = activeIndex === -1 ? null : buttons[activeIndex + delta];
|
|
393
|
+
|
|
394
|
+
if (!sibling) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
focusElement(sibling);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
322
402
|
function useBodyKeyboardEvents() {
|
|
323
403
|
const BodyRef = useBodyRef();
|
|
324
404
|
const goUpFromBody = useGoUpFromBody();
|