emoji-picker-react 5.0.0 → 5.0.2

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.
Files changed (57) hide show
  1. package/dist/DomUtils/classNames.d.ts +0 -1
  2. package/dist/DomUtils/keyboardNavigation.d.ts +10 -2
  3. package/dist/DomUtils/selectors.d.ts +2 -2
  4. package/dist/data/index.js +1 -1
  5. package/dist/data/skinToneVariations.js +2 -2
  6. package/dist/data/skinToneVariations.ts +8 -7
  7. package/dist/data-core/prepare.d.ts +6 -0
  8. package/dist/esm/chunks/chunk-56D5WCOZ.mjs +6729 -0
  9. package/dist/esm/chunks/chunk-7ZGTUZFB.mjs +8021 -12
  10. package/dist/esm/chunks/chunk-IODYR4CR.mjs +14 -0
  11. package/dist/esm/chunks/chunk-LO4TPPLH.mjs +32 -42
  12. package/dist/esm/chunks/chunk-RU7G34WK.mjs +156 -0
  13. package/dist/esm/chunks/emojis-6L3GRBAP.mjs +5 -7
  14. package/dist/esm/data.mjs +9 -16
  15. package/dist/esm/index.mjs +230 -340
  16. package/dist/esm/primitives/index.mjs +6 -65
  17. package/dist/hooks/preloadEmoji.d.ts +1 -5
  18. package/dist/hooks/useCategoryHeight.d.ts +2 -1
  19. package/dist/hooks/useEmojiVirtualization.d.ts +1 -0
  20. package/dist/index.js +1 -1
  21. package/dist/primitives/index.js +1 -1
  22. package/dist/virtualization/virtualizationHelpers.d.ts +2 -0
  23. package/llms-full.txt +9 -3
  24. package/package.json +2 -2
  25. package/src/DomUtils/classNames.ts +0 -4
  26. package/src/DomUtils/elementPositionInRow.ts +2 -2
  27. package/src/DomUtils/keyboardNavigation.ts +74 -10
  28. package/src/DomUtils/scrollTo.ts +2 -2
  29. package/src/DomUtils/selectors.ts +10 -16
  30. package/src/components/body/EmojiCategory.tsx +1 -1
  31. package/src/components/body/EmojiList.tsx +54 -23
  32. package/src/components/context/PickerDataContext.tsx +7 -2
  33. package/src/components/header/Search/Search.tsx +4 -2
  34. package/src/components/header/SkinTonePicker/BtnSkinToneVariation.tsx +7 -6
  35. package/src/components/main/defaultAppearance.tsx +2 -1
  36. package/src/config/config.ts +6 -3
  37. package/src/data/skinToneVariations.ts +8 -7
  38. package/src/data-core/pickerData.ts +8 -12
  39. package/src/data-core/prepare.ts +32 -39
  40. package/src/data-core/search.ts +10 -2
  41. package/src/dataUtils/emojiSelectors.ts +2 -1
  42. package/src/dataUtils/parseNativeEmoji.ts +3 -4
  43. package/src/dataUtils/suggestedEmojis.ts +25 -39
  44. package/src/hooks/preloadEmoji.ts +13 -52
  45. package/src/hooks/useActiveCategoryScrollDetection.ts +22 -19
  46. package/src/hooks/useCategoryHeight.ts +38 -6
  47. package/src/hooks/useDisallowedEmojis.ts +23 -13
  48. package/src/hooks/useEmojiVirtualization.tsx +102 -66
  49. package/src/hooks/useFilter.ts +10 -8
  50. package/src/hooks/useIsEmojiHidden.ts +16 -12
  51. package/src/hooks/useKeyboardNavigation.ts +104 -17
  52. package/src/primitives/structuralStyles.tsx +3 -0
  53. package/src/primitives/tokens.ts +1 -0
  54. package/src/virtualization/virtualizationHelpers.ts +9 -2
  55. package/dist/esm/chunks/chunk-46BUDQLE.mjs +0 -228
  56. package/dist/esm/chunks/chunk-FQIJ4HBW.mjs +0 -25
  57. package/dist/esm/chunks/chunk-KQPOIRSC.mjs +0 -9305
@@ -1,3 +1,5 @@
1
+ export declare const OVERSCAN_ROWS = 2;
2
+ export declare function isJustBelowViewport(top: number, bottom: number, size: number): boolean;
1
3
  export declare function shouldVirtualize({ scrollTop, clientHeight, topOffset, style, dimensions, }: {
2
4
  scrollTop: number;
3
5
  clientHeight: number;
package/llms-full.txt CHANGED
@@ -227,7 +227,7 @@ Per-library snippets (plain CSS, CSS Modules, Tailwind, shadcn/ui, Emotion, styl
227
227
 
228
228
  Locale datasets translate emoji names and category names. `labels` translates controls and announcements; `previewConfig.defaultCaption` sets the preview caption. Complete localization covers every visible and announced string in your composition: see [INTERNATIONALIZATION.md](../../INTERNATIONALIZATION.md).
229
229
 
230
- Keep the managed ARIA props. Test keyboard selection, search and IME input, focus restoration and nested Escape inside the real host popover or dialog: Escape closes an open variation or tone menu before reaching the host. Custom emojis with duplicate names share an accessible name; scope test queries to a category.
230
+ Keep the managed ARIA props. Test keyboard selection, search and IME input, focus restoration and nested Escape inside the real host popover or dialog: Escape closes an open variation or tone menu, or clears a non-empty search, before reaching the host. Custom emojis with duplicate names share an accessible name; scope test queries to a category.
231
231
 
232
232
  <!-- source: docs/v5/PROMPTS.md -->
233
233
 
@@ -1406,7 +1406,7 @@ The registry item depends on `emoji-picker-react@^5.0.0`. The registry component
1406
1406
  </Popover>
1407
1407
  ```
1408
1408
 
1409
- The host owns insertion and dismissal. The Popover restores trigger focus. Escape first closes an open variation/tone menu, then a subsequent Escape reaches the host. The light/dark Storybook examples import the registry component itself; browser checks exercise search, keyboard insertion, dismissal, focus restoration and WCAG-tagged axe rules.
1409
+ The host owns insertion and dismissal. The Popover restores trigger focus. Escape first closes an open variation/tone menu or clears a non-empty search, then a subsequent Escape reaches the host. The light/dark Storybook examples import the registry component itself; browser checks exercise search, keyboard insertion, dismissal, focus restoration and WCAG-tagged axe rules.
1410
1410
 
1411
1411
  ## Working starter sources
1412
1412
 
@@ -1633,10 +1633,11 @@ Existing `reactionsDefaultOpen`, `allowExpandReactions`, `reactions`, `onReactio
1633
1633
 
1634
1634
  For standard emoji IDs:
1635
1635
  - matching is case-insensitive;
1636
+ - matching ignores where U+FE0F appears: `'2764'` and `'2764-fe0f'` both resolve to the dataset's own spelling, the same rule `reactions` and `hiddenEmojis` use;
1636
1637
  - a valid variation remains that exact variation for rendering rather than collapsing to the neutral base emoji;
1637
1638
  - duplicates are removed by canonical normalized ID, first occurrence wins.
1638
1639
 
1639
- Custom emoji IDs use the same case-insensitive rule — `customEmojis` are already lowercased when indexed, so there is no separate exact-match pass.
1640
+ Custom emoji IDs use the same case-insensitive rule: IDs are lowercased when indexed. Custom display names retain the supplied capitalization; search still matches them case-insensitively.
1640
1641
 
1641
1642
  Entries may be unified IDs, custom emoji IDs, or the emoji characters themselves (`'🧠'`, `'❤️'`), so recents stored as inserted text can be passed through unchanged; unknown entries are ignored.
1642
1643
 
@@ -1891,6 +1892,8 @@ Styling is opt-in in both directions:
1891
1892
 
1892
1893
  `columns` sets the emojis per row on EmojiPicker and Root. The picker's width then fits those columns plus a stable scrollbar gutter, unless the consumer sets a width (`width`, `style` or any class). A container narrower than the columns renders fewer columns instead of clipping. Without `columns`, the width decides the column count, as in v4.
1893
1894
 
1895
+ The intrinsic grid width remains present during an empty search, data loading or a load error.
1896
+
1894
1897
  ## 12. Data API
1895
1898
 
1896
1899
  The data entry point is defined in [DATA_API.md](./DATA_API.md):
@@ -2362,11 +2365,13 @@ The following variables are already documented public customization surface and
2362
2365
  - `--epr-preview-text-color`
2363
2366
 
2364
2367
  ### Native emoji font
2368
+ - `--epr-font-family` — font for picker text (`sans-serif` by default). Set it to `inherit` on the picker to use the surrounding application's font. Native emoji glyphs keep their separate font stack.
2365
2369
  - `--epr-emoji-font-family` (v5) — font stack for native emojis, e.g. a country-flag polyfill font; native support detection measures this same font.
2366
2370
 
2367
2371
  ### Skin tone
2368
2372
  - `--epr-skin-tone-picker-menu-color`
2369
2373
  - `--epr-skin-tone-size`
2374
+ - `--epr-skin-tone-neutral-color`, `--epr-skin-tone-light-color`, `--epr-skin-tone-medium-light-color`, `--epr-skin-tone-medium-color`, `--epr-skin-tone-medium-dark-color`, `--epr-skin-tone-dark-color` — the tone swatch colors (defaults `#ffd225`, `#ffdfbd`, `#e9c197`, `#c88e62`, `#a86637`, `#60463a`).
2370
2375
 
2371
2376
  ### Dark mode
2372
2377
  - `--epr-dark-bg-color`
@@ -3025,6 +3030,7 @@ No other data helpers are part of v5.
3025
3030
  - trims surrounding whitespace;
3026
3031
  - normalizes hexadecimal unified code to lowercase;
3027
3032
  - accepts base or variation unified code;
3033
+ - accepts either U+FE0F spelling (`2764` or `2764-fe0f`); an exact match wins;
3028
3034
  - variation lookup returns the canonical/base EmojiInfo whose `variations` includes the requested variation;
3029
3035
  - returns `undefined` for unknown/empty input;
3030
3036
  - does not mutate the dataset;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.0.0",
2
+ "version": "5.0.2",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -183,7 +183,7 @@
183
183
  },
184
184
  {
185
185
  "path": "dist/esm/index.mjs",
186
- "limit": "75 KB"
186
+ "limit": "76 KB"
187
187
  },
188
188
  {
189
189
  "path": "dist/esm/primitives/index.mjs",
@@ -21,7 +21,3 @@ export enum ClassNames {
21
21
  darkTheme = 'epr-dark-theme',
22
22
  autoTheme = 'epr-auto-theme',
23
23
  }
24
-
25
- export function asSelectors(...classNames: ClassNames[]): string {
26
- return classNames.map((c) => `.${c}`).join('');
27
- }
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_LABEL_HEIGHT } from '../components/main/labelHeight';
2
2
 
3
- import { ClassNames, asSelectors } from './classNames';
3
+ import { ClassNames } from './classNames';
4
4
  import { NullableElement } from './selectors';
5
5
 
6
6
  export function elementCountInRow(
@@ -74,7 +74,7 @@ export function getLabelHeight(parentNode: NullableElement) {
74
74
  return DEFAULT_LABEL_HEIGHT;
75
75
  }
76
76
 
77
- const label = parentNode.querySelector(asSelectors(ClassNames.label));
77
+ const label = parentNode.querySelector(`.${ClassNames.label}`);
78
78
 
79
79
  if (label) {
80
80
  const height = label.getBoundingClientRect().height;
@@ -38,12 +38,45 @@ function categoryGeometry(category: HTMLElement) {
38
38
  return { content, count, columns };
39
39
  }
40
40
 
41
+ export type MoveOptions = {
42
+ shouldFocus?: () => boolean;
43
+ /** Called when an upward move leaves the grid. */
44
+ exitUp?: () => void;
45
+ /** Receives the destination cell; focuses it by default. */
46
+ moveTo?: (emoji: HTMLElement) => void;
47
+ /** Source cell while a virtualized destination is being materialized. */
48
+ origin?: HTMLElement;
49
+ };
50
+
51
+ /** Move to the first filtered result, including a cell outside the viewport. */
52
+ export function focusFirstEmoji(
53
+ body: NullableElement,
54
+ options: MoveOptions,
55
+ ): boolean {
56
+ const category = body?.querySelector<HTMLElement>(
57
+ '[data-epr-part="category-content"]:not([data-epr-emoji-count="0"])',
58
+ )?.parentElement;
59
+ if (category) focusLogicalEmoji(category, 0, options);
60
+ return !!category;
61
+ }
62
+
63
+ const pendingMoves = new WeakMap<
64
+ HTMLElement,
65
+ {
66
+ category: HTMLElement;
67
+ index: number;
68
+ origin?: HTMLElement;
69
+ valid: () => boolean;
70
+ }
71
+ >();
72
+
41
73
  export function focusAdjacentEmoji(
42
74
  element: NullableElement,
43
75
  direction: number,
44
76
  row: boolean,
45
- { shouldFocus, exitUp }: { shouldFocus?: () => boolean; exitUp?: () => void },
77
+ options: MoveOptions,
46
78
  ) {
79
+ const { shouldFocus, exitUp } = options;
47
80
  if (!element || (shouldFocus && !shouldFocus())) return;
48
81
  const category = closestCategory(element);
49
82
  if (!category || element.dataset.eprIndex === undefined) {
@@ -55,15 +88,34 @@ export function focusAdjacentEmoji(
55
88
  );
56
89
  return;
57
90
  }
58
- const destination = logicalDestination(category, element, direction, row);
91
+ const cursor = gridCursor(category, element);
92
+ const destination = logicalDestination(
93
+ cursor.category,
94
+ cursor.index,
95
+ direction,
96
+ row,
97
+ );
59
98
  if (destination)
60
- focusLogicalEmoji(destination.category, destination.index, shouldFocus);
99
+ focusLogicalEmoji(destination.category, destination.index, {
100
+ ...options,
101
+ origin: element,
102
+ });
61
103
  else if (direction < 0) exitUp?.();
62
104
  }
63
105
 
106
+ // A key pressed before the previous move mounted its destination continues
107
+ // from that destination, not from the still-focused source cell.
108
+ function gridCursor(category: HTMLElement, element: HTMLElement) {
109
+ const body = closestScrollBody(category);
110
+ const pending = body && pendingMoves.get(body);
111
+ return pending?.origin === element && pending.valid()
112
+ ? pending
113
+ : { category, index: Number(element.dataset.eprIndex) };
114
+ }
115
+
64
116
  function logicalDestination(
65
117
  category: HTMLElement,
66
- element: HTMLElement,
118
+ currentIndex: number,
67
119
  direction: number,
68
120
  row: boolean,
69
121
  ) {
@@ -71,7 +123,6 @@ function logicalDestination(
71
123
  const columns = geometry.columns;
72
124
  let count = geometry.count;
73
125
  if (count <= 0) return null;
74
- const currentIndex = Number(element.dataset.eprIndex);
75
126
  const column = row ? currentIndex % columns : 0;
76
127
  let index = row
77
128
  ? (Math.floor(currentIndex / columns) + direction) * columns
@@ -105,23 +156,30 @@ function adjacentDestination(
105
156
  function focusLogicalEmoji(
106
157
  category: HTMLElement,
107
158
  index: number,
108
- shouldFocus?: () => boolean,
159
+ { shouldFocus, moveTo = (emoji) => emoji.focus(), origin }: MoveOptions,
109
160
  ) {
110
161
  const { content, columns, count } = categoryGeometry(category);
111
162
  const body = closestScrollBody(category);
112
163
  if (!body || !content || !columns) return;
113
164
  const size = content.clientHeight / Math.ceil(count / columns);
114
165
  let frames = 60;
115
- const valid = () =>
116
- (!shouldFocus || shouldFocus()) && body.contains(category);
166
+ const pending = { category, index, origin, valid: () => valid() };
167
+ pendingMoves.set(body, pending);
168
+ const valid = (): boolean =>
169
+ pendingMoves.get(body) === pending &&
170
+ (!shouldFocus || shouldFocus()) &&
171
+ body.contains(category);
117
172
  const attempt = () => {
118
173
  if (!valid()) return;
119
174
  const emoji = category.querySelector<HTMLElement>(
120
175
  `${VisibleEmojiSelector}[data-epr-index="${index}"]`,
121
176
  );
122
177
  if (emoji) {
123
- emoji.focus();
178
+ moveTo(emoji);
124
179
  scrollEmojiAboveLabel(emoji, valid);
180
+ // Keep the scroll guard valid until a later navigation replaces it,
181
+ // but only unfinished moves supply a logical cursor to another key.
182
+ pending.origin = undefined;
125
183
  return;
126
184
  }
127
185
  if (!size || --frames <= 0) return;
@@ -133,5 +191,11 @@ function focusLogicalEmoji(
133
191
  categoryLabelHeight(category);
134
192
  requestAnimationFrame(attempt);
135
193
  };
136
- requestAnimationFrame(attempt);
194
+ // Mounted cells can move immediately, so a second key in this frame
195
+ // reads the new position. Only unmounted destinations need a frame.
196
+ if (
197
+ category.querySelector(`${VisibleEmojiSelector}[data-epr-index="${index}"]`)
198
+ )
199
+ attempt();
200
+ else requestAnimationFrame(attempt);
137
201
  }
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
2
2
 
3
3
  import { useBodyRef } from '../components/context/ElementRefContext';
4
4
 
5
- import { asSelectors, ClassNames } from './classNames';
5
+ import { ClassNames } from './classNames';
6
6
  import {
7
7
  categoryLabelHeight,
8
8
  closestCategory,
@@ -53,7 +53,7 @@ export function scrollEmojiAboveLabel(
53
53
  return;
54
54
  }
55
55
 
56
- if (emoji.closest(asSelectors(ClassNames.variationPicker))) {
56
+ if (emoji.closest(`.${ClassNames.variationPicker}`)) {
57
57
  return;
58
58
  }
59
59
 
@@ -1,16 +1,12 @@
1
1
  import { unifiedWithoutSkinTone } from '../dataUtils/emojiSelectors';
2
2
 
3
- import { asSelectors, ClassNames } from './classNames';
3
+ import { ClassNames } from './classNames';
4
4
  import { firstVisibleElementInContainer } from './elementPositionInRow';
5
5
 
6
6
  export type NullableElement = HTMLElement | null;
7
7
 
8
- export const EmojiButtonSelector = `button${asSelectors(ClassNames.emoji)}`;
9
- export const VisibleEmojiSelector = [
10
- EmojiButtonSelector,
11
- asSelectors(ClassNames.visible),
12
- `:not(${asSelectors(ClassNames.hidden)})`,
13
- ].join('');
8
+ export const EmojiButtonSelector = `button.${ClassNames.emoji}`;
9
+ export const VisibleEmojiSelector = `${EmojiButtonSelector}.${ClassNames.visible}:not(.${ClassNames.hidden})`;
14
10
 
15
11
  export function buttonFromTarget(
16
12
  emojiElement: NullableElement,
@@ -35,7 +31,7 @@ export function isEmojiElement(element: NullableElement): boolean {
35
31
  export function categoryLabelFromCategory(
36
32
  category: NullableElement,
37
33
  ): NullableElement {
38
- return category?.querySelector(asSelectors(ClassNames.label)) ?? null;
34
+ return category?.querySelector(`.${ClassNames.label}`) ?? null;
39
35
  }
40
36
 
41
37
  export function closestCategoryLabel(
@@ -74,7 +70,7 @@ export function categoryLabelHeight(category: NullableElement): number {
74
70
  }
75
71
 
76
72
  const categoryWithoutLabel = category.querySelector(
77
- asSelectors(ClassNames.categoryContent),
73
+ `.${ClassNames.categoryContent}`,
78
74
  );
79
75
 
80
76
  return (
@@ -96,9 +92,9 @@ export function isEmojiBehindLabel(emoji: NullableElement): boolean {
96
92
  export function queryScrollBody(root: NullableElement): NullableElement {
97
93
  if (!root) return null;
98
94
 
99
- return root.matches(asSelectors(ClassNames.scrollBody))
95
+ return root.matches(`.${ClassNames.scrollBody}`)
100
96
  ? root
101
- : root.querySelector(asSelectors(ClassNames.scrollBody));
97
+ : root.querySelector(`.${ClassNames.scrollBody}`);
102
98
  }
103
99
 
104
100
  export function emojiDistanceFromScrollTop(emoji: NullableElement): number {
@@ -114,7 +110,7 @@ export function closestScrollBody(element: NullableElement): NullableElement {
114
110
  return null;
115
111
  }
116
112
 
117
- return element.closest(asSelectors(ClassNames.scrollBody)) ?? null;
113
+ return element.closest(`.${ClassNames.scrollBody}`) ?? null;
118
114
  }
119
115
 
120
116
  export function emojiTruOffsetLeft(element: NullableElement): number {
@@ -221,14 +217,12 @@ export function closestCategory(element: NullableElement) {
221
217
  if (!element) {
222
218
  return null;
223
219
  }
224
- return element.closest(asSelectors(ClassNames.category)) as HTMLElement;
220
+ return element.closest(`.${ClassNames.category}`) as HTMLElement;
225
221
  }
226
222
 
227
223
  export function closestCategoryContent(element: NullableElement) {
228
224
  if (!element) {
229
225
  return null;
230
226
  }
231
- return element.closest(
232
- asSelectors(ClassNames.categoryContent),
233
- ) as HTMLElement;
227
+ return element.closest(`.${ClassNames.categoryContent}`) as HTMLElement;
234
228
  }
@@ -55,7 +55,7 @@ export function EmojiCategory({
55
55
  >
56
56
  {CategoryHeader ? (
57
57
  <CategoryHeader
58
- className={cx(styles.label)}
58
+ className={cx(styles.label, appearance && styles.labelAppearance)}
59
59
  aria-hidden
60
60
  data-epr-part="category-label"
61
61
  category={{ id: categoryId, name: categoryName }}
@@ -15,8 +15,12 @@ import { useEmojiVirtualization } from '../../hooks/useEmojiVirtualization';
15
15
  import { useRegisterRegion } from '../../hooks/useRegisterRegion';
16
16
  import { useMergedRefs } from '../../primitives/nativeProps';
17
17
  import { CategoryConfig } from '../../types/exposedTypes';
18
- import { useEmojiListRef } from '../context/ElementRefContext';
19
- import { useVisibleCategoriesState } from '../context/PickerContext';
18
+ import { OVERSCAN_ROWS } from '../../virtualization/virtualizationHelpers';
19
+ import { useBodyRef, useEmojiListRef } from '../context/ElementRefContext';
20
+ import {
21
+ useEmojiSizeState,
22
+ useVisibleCategoriesState,
23
+ } from '../context/PickerContext';
20
24
  import { useGetEmojisByCategory } from '../context/PickerDataContext';
21
25
 
22
26
  import { EmojiCategory } from './EmojiCategory';
@@ -41,6 +45,22 @@ export function EmojiList({
41
45
  [key: string]: number;
42
46
  }>({});
43
47
  const EmojiListRef = useEmojiListRef();
48
+ const BodyRef = useBodyRef();
49
+ const [emojiSize] = useEmojiSizeState();
50
+ const [visibleCategories] = useVisibleCategoriesState();
51
+ const onHeightReady = React.useCallback(
52
+ (categoryId: string, height: number) => {
53
+ setCategoryHeights((previous) =>
54
+ previous[categoryId] === height
55
+ ? previous
56
+ : {
57
+ ...previous,
58
+ [categoryId]: height,
59
+ },
60
+ );
61
+ },
62
+ [],
63
+ );
44
64
  const mergedRef = useMergedRefs(EmojiListRef, outerRef);
45
65
  const getEmojisByCategory = useGetEmojisByCategory();
46
66
  useRegisterRegion('grid', EmojiListRef);
@@ -87,17 +107,28 @@ export function EmojiList({
87
107
  categoryEmojis={getEmojisByCategory(category, group)}
88
108
  categoryConfig={categoryConfig}
89
109
  topOffset={currentOffset}
90
- isFirstCategory={index === 0}
110
+ isCategoryVisible={
111
+ index === 0 || visibleCategories.includes(categoryId)
112
+ }
91
113
  positioned={isPositioned}
92
- onHeightReady={(height) => {
93
- if (categoryHeights[categoryId] !== height) {
94
- setCategoryHeights((prev) => ({
95
- ...prev,
96
- [categoryId]: height,
97
- }));
98
- }
99
- }}
100
- scrollTop={scrollTop}
114
+ onHeightReady={onHeightReady}
115
+ // Outside a section's rendering/preload margin, scrolling
116
+ // cannot change its cells. Keep its prop stable so it can bail.
117
+ scrollTop={
118
+ categoryHeight === undefined
119
+ ? scrollTop
120
+ : Math.max(
121
+ currentOffset -
122
+ (BodyRef.current?.clientHeight ?? 0) -
123
+ (emojiSize || 40) * (OVERSCAN_ROWS + 1),
124
+ Math.min(
125
+ scrollTop,
126
+ currentOffset +
127
+ categoryHeight +
128
+ (emojiSize || 40) * (OVERSCAN_ROWS + 1),
129
+ ),
130
+ )
131
+ }
101
132
  />
102
133
  );
103
134
  })}
@@ -105,24 +136,28 @@ export function EmojiList({
105
136
  );
106
137
  }
107
138
 
108
- function RenderCategory({
139
+ const RenderCategory = /* @__PURE__ */ React.memo(function RenderCategory({
109
140
  categoryEmojis,
110
141
  categoryConfig,
111
142
  topOffset,
112
143
  onHeightReady,
113
144
  scrollTop,
114
- isFirstCategory,
145
+ isCategoryVisible,
115
146
  positioned,
116
147
  }: {
117
148
  categoryEmojis: DataEmojis;
118
149
  categoryConfig: CategoryConfig;
119
150
  topOffset: number;
120
- onHeightReady: (height: number) => void;
151
+ onHeightReady: (categoryId: string, height: number) => void;
121
152
  scrollTop: number;
122
- isFirstCategory: boolean;
153
+ isCategoryVisible: boolean;
123
154
  positioned: boolean;
124
155
  }) {
125
- const [visibleCategories] = useVisibleCategoriesState();
156
+ const categoryId = categoryIdFromCategoryConfig(categoryConfig);
157
+ const reportHeight = React.useCallback(
158
+ (height: number) => onHeightReady(categoryId, height),
159
+ [categoryId, onHeightReady],
160
+ );
126
161
 
127
162
  // The observer is the only writer of visibleCategories. When it never
128
163
  // fires (zero-height container at mount, hidden modal), no category
@@ -130,15 +165,11 @@ function RenderCategory({
130
165
  // the deadlock; the observer takes over from there.
131
166
  // https://github.com/ealush/emoji-picker-react/issues/469
132
167
  // https://github.com/ealush/emoji-picker-react/issues/475
133
- const isCategoryVisible =
134
- isFirstCategory ||
135
- visibleCategories.includes(categoryIdFromCategoryConfig(categoryConfig));
136
-
137
168
  const { virtualizedCounter, emojis, dimensions, emojiCount } =
138
169
  useEmojiVirtualization({
139
170
  categoryEmojis,
140
171
  topOffset,
141
- onHeightReady,
172
+ onHeightReady: reportHeight,
142
173
  scrollTop,
143
174
  isCategoryVisible,
144
175
  positioned,
@@ -157,7 +188,7 @@ function RenderCategory({
157
188
  {emojis}
158
189
  </EmojiCategory>
159
190
  );
160
- }
191
+ });
161
192
 
162
193
  const styles = /* @__PURE__ */ (() =>
163
194
  stylesheet.create({
@@ -5,7 +5,7 @@ import {
5
5
  useSuggestedEmojisModeConfig,
6
6
  } from '../../config/useConfig';
7
7
  import { getPickerDataSnapshot } from '../../data-core/pickerData';
8
- import { normalizeQuery } from '../../data-core/prepare';
8
+ import { normalizeQuery, withoutFe0f } from '../../data-core/prepare';
9
9
  import { searchEmojis } from '../../data-core/search';
10
10
  import {
11
11
  DataEmoji,
@@ -79,6 +79,7 @@ export function PickerDataProvider({
79
79
 
80
80
  const result =
81
81
  data.allEmojisByUnified[unified] ??
82
+ data.allEmojisByUnified[withoutFe0f(unified)] ??
82
83
  data.allEmojisByUnified[unifiedWithoutSkinTone(unified)];
83
84
  return result;
84
85
  },
@@ -109,7 +110,11 @@ export function PickerDataProvider({
109
110
  }
110
111
  }
111
112
  for (const custom of allCustomEmojis) {
112
- if (emojiNames(custom).some((name) => name.includes(normalized))) {
113
+ if (
114
+ emojiNames(custom).some((name) =>
115
+ name.toLowerCase().includes(normalized),
116
+ )
117
+ ) {
113
118
  dict[custom[Keys.unified]] = custom;
114
119
  }
115
120
  }
@@ -96,10 +96,12 @@ const styles = /* @__PURE__ */ (() =>
96
96
  border: '1px solid var(--epr-search-border-color)',
97
97
  ':focus': {
98
98
  backgroundColor: 'var(--epr-search-input-bg-color-active)',
99
- border: '1px solid var(--epr-search-border-color-active)',
99
+ border:
100
+ '1px solid var(--epr-search-border-color-active, var(--epr-highlight-color, Highlight))',
100
101
  },
101
102
  '::placeholder': {
102
- color: 'var(--epr-search-input-placeholder-color)',
103
+ color:
104
+ 'var(--epr-search-input-placeholder-color, var(--epr-text-color, GrayText))',
103
105
  },
104
106
  },
105
107
  }))();
@@ -108,23 +108,24 @@ const styles = /* @__PURE__ */ (() =>
108
108
  ':focus': {
109
109
  boxShadow: '0 0 0 3px var(--epr-focus-bg-color)',
110
110
  },
111
+ // Swatch colors are themeable; the fallbacks are the defaults.
111
112
  '&.epr-tone-neutral': {
112
- backgroundColor: '#ffd225',
113
+ backgroundColor: 'var(--epr-skin-tone-neutral-color, #ffd225)',
113
114
  },
114
115
  '&.epr-tone-1f3fb': {
115
- backgroundColor: '#ffdfbd',
116
+ backgroundColor: 'var(--epr-skin-tone-light-color, #ffdfbd)',
116
117
  },
117
118
  '&.epr-tone-1f3fc': {
118
- backgroundColor: '#e9c197',
119
+ backgroundColor: 'var(--epr-skin-tone-medium-light-color, #e9c197)',
119
120
  },
120
121
  '&.epr-tone-1f3fd': {
121
- backgroundColor: '#c88e62',
122
+ backgroundColor: 'var(--epr-skin-tone-medium-color, #c88e62)',
122
123
  },
123
124
  '&.epr-tone-1f3fe': {
124
- backgroundColor: '#a86637',
125
+ backgroundColor: 'var(--epr-skin-tone-medium-dark-color, #a86637)',
125
126
  },
126
127
  '&.epr-tone-1f3ff': {
127
- backgroundColor: '#60463a',
128
+ backgroundColor: 'var(--epr-skin-tone-dark-color, #60463a)',
128
129
  },
129
130
  },
130
131
  }))();
@@ -157,10 +157,11 @@ const styles = /* @__PURE__ */ (() =>
157
157
  backgroundColor: 'var(--epr-bg-color)',
158
158
  overflow: 'hidden',
159
159
  transition: 'height 0.3s ease-in-out, background-color 0.1s ease-in-out',
160
+ fontFamily: 'var(--epr-font-family, inherit)',
160
161
  '*': {
161
162
  boxSizing: 'border-box',
162
163
  // Overridable without a specificity fight (v4 hard-coded sans-serif).
163
- fontFamily: 'var(--epr-font-family, sans-serif)',
164
+ fontFamily: 'inherit',
164
165
  },
165
166
  },
166
167
  baseVariables: {
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import { DEFAULT_REACTIONS } from '../components/Reactions/DEFAULT_REACTIONS';
4
4
  import { GetEmojiUrl } from '../components/emoji/BaseEmojiProps';
5
+ import { withoutFe0f } from '../data-core/prepare';
5
6
  import { emojiUrlByUnified } from '../dataUtils/emojiUtils';
6
7
  import type { EmojiDataInput } from '../hooks/useResolvedEmojiData';
7
8
  import {
@@ -27,7 +28,8 @@ import {
27
28
  } from './categoryConfig';
28
29
  import { CustomEmoji } from './customEmojiConfig';
29
30
 
30
- const KNOWN_FAILING_EMOJIS = ['2640-fe0f', '2642-fe0f', '2695-fe0f'];
31
+ // Stored without U+FE0F, like every hiddenEmojis entry.
32
+ const KNOWN_FAILING_EMOJIS = ['2640', '2642', '2695'];
31
33
 
32
34
  export const DEFAULT_SEARCH_PLACEHOLDER = 'Search';
33
35
  export const DEFAULT_PICKER_WIDTH = 350;
@@ -171,9 +173,10 @@ export function mergeConfig(
171
173
 
172
174
  // Dataset ids are lowercase; accept the uppercase spelling consumers
173
175
  // copy from docs and tools (and stray whitespace), like `reactions` and
174
- // `suggestedEmojis` already do.
176
+ // `suggestedEmojis` already do. Kept without U+FE0F, so either spelling
177
+ // of an ID hides the emoji (see useIsEmojiDisallowed).
175
178
  config.hiddenEmojis.forEach((emoji) => {
176
- const id = String(emoji).trim().toLowerCase();
179
+ const id = withoutFe0f(String(emoji).trim().toLowerCase());
177
180
  if (id) config.unicodeToHide.add(id);
178
181
  });
179
182
 
@@ -9,13 +9,14 @@ const skinToneVariations = [
9
9
  SkinTones.DARK,
10
10
  ];
11
11
 
12
- export const skinTonesNamed = Object.entries(SkinTones).reduce(
13
- (acc, [key, value]) => {
14
- acc[value] = key;
15
- return acc;
16
- },
17
- {} as Record<string, string>,
18
- );
12
+ export const skinTonesNamed = /* @__PURE__ */ (() =>
13
+ Object.entries(SkinTones).reduce(
14
+ (acc, [key, value]) => {
15
+ acc[value] = key;
16
+ return acc;
17
+ },
18
+ {} as Record<string, string>,
19
+ ))();
19
20
 
20
21
  export const skinTonesMapped: Record<string, string> =
21
22
  skinToneVariations.reduce(