emoji-picker-react 4.20.4 → 4.20.5

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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.20.4",
2
+ "version": "4.20.5",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -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 className={cx(styles.categoryContent)} style={{ height }}>
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,8 +27,17 @@ 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 (see below).
38
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
30
39
  return (
31
- <ul className={cx(styles.emojiList)} ref={EmojiListRef}>
40
+ <ul className={cx(styles.emojiList)} ref={EmojiListRef} role="grid">
32
41
  <MeasureEmoji />
33
42
  {categories.map((categoryConfig) => {
34
43
  const category = categoryFromCategoryConfig(categoryConfig);
@@ -88,6 +97,7 @@ function RenderCategory({
88
97
  <EmojiCategory
89
98
  categoryConfig={categoryConfig}
90
99
  height={dimensions?.categoryHeight}
100
+ emojisPerRow={dimensions?.emojisPerRow}
91
101
  // Indicates that there are no visible emojis
92
102
  // Hence, the category should be hidden
93
103
  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 { useSkinToneFanOpenState } from '../components/context/PickerContext';
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();