emoji-picker-react 5.0.0 → 5.0.1

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 (52) 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/esm/chunks/chunk-7ZGTUZFB.mjs +8021 -12
  8. package/dist/esm/chunks/chunk-ACXAQEKG.mjs +149 -0
  9. package/dist/esm/chunks/chunk-F2KS7DCA.mjs +6735 -0
  10. package/dist/esm/chunks/chunk-L5W4PFRJ.mjs +14 -0
  11. package/dist/esm/chunks/chunk-LO4TPPLH.mjs +32 -42
  12. package/dist/esm/chunks/emojis-6L3GRBAP.mjs +5 -7
  13. package/dist/esm/data.mjs +9 -16
  14. package/dist/esm/index.mjs +230 -340
  15. package/dist/esm/primitives/index.mjs +6 -65
  16. package/dist/hooks/preloadEmoji.d.ts +0 -4
  17. package/dist/hooks/useCategoryHeight.d.ts +2 -1
  18. package/dist/hooks/useEmojiVirtualization.d.ts +1 -0
  19. package/dist/index.js +1 -1
  20. package/dist/primitives/index.js +1 -1
  21. package/dist/virtualization/virtualizationHelpers.d.ts +2 -0
  22. package/llms-full.txt +6 -3
  23. package/package.json +1 -1
  24. package/src/DomUtils/classNames.ts +0 -4
  25. package/src/DomUtils/elementPositionInRow.ts +2 -2
  26. package/src/DomUtils/keyboardNavigation.ts +74 -10
  27. package/src/DomUtils/scrollTo.ts +2 -2
  28. package/src/DomUtils/selectors.ts +10 -16
  29. package/src/components/body/EmojiCategory.tsx +1 -1
  30. package/src/components/body/EmojiList.tsx +54 -23
  31. package/src/components/context/PickerDataContext.tsx +5 -1
  32. package/src/components/header/Search/Search.tsx +4 -2
  33. package/src/components/main/defaultAppearance.tsx +2 -1
  34. package/src/data/skinToneVariations.ts +8 -7
  35. package/src/data-core/pickerData.ts +1 -3
  36. package/src/data-core/prepare.ts +20 -35
  37. package/src/dataUtils/emojiSelectors.ts +2 -1
  38. package/src/dataUtils/parseNativeEmoji.ts +3 -4
  39. package/src/hooks/preloadEmoji.ts +1 -46
  40. package/src/hooks/useActiveCategoryScrollDetection.ts +22 -19
  41. package/src/hooks/useCategoryHeight.ts +38 -6
  42. package/src/hooks/useDisallowedEmojis.ts +18 -13
  43. package/src/hooks/useEmojiVirtualization.tsx +93 -67
  44. package/src/hooks/useFilter.ts +10 -8
  45. package/src/hooks/useIsEmojiHidden.ts +16 -12
  46. package/src/hooks/useKeyboardNavigation.ts +104 -17
  47. package/src/primitives/structuralStyles.tsx +3 -0
  48. package/src/primitives/tokens.ts +1 -0
  49. package/src/virtualization/virtualizationHelpers.ts +9 -2
  50. package/dist/esm/chunks/chunk-46BUDQLE.mjs +0 -228
  51. package/dist/esm/chunks/chunk-FQIJ4HBW.mjs +0 -25
  52. 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
 
@@ -1636,7 +1636,7 @@ For standard emoji IDs:
1636
1636
  - a valid variation remains that exact variation for rendering rather than collapsing to the neutral base emoji;
1637
1637
  - duplicates are removed by canonical normalized ID, first occurrence wins.
1638
1638
 
1639
- Custom emoji IDs use the same case-insensitive rule — `customEmojis` are already lowercased when indexed, so there is no separate exact-match pass.
1639
+ 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
1640
 
1641
1641
  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
1642
 
@@ -1891,6 +1891,8 @@ Styling is opt-in in both directions:
1891
1891
 
1892
1892
  `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
1893
 
1894
+ The intrinsic grid width remains present during an empty search, data loading or a load error.
1895
+
1894
1896
  ## 12. Data API
1895
1897
 
1896
1898
  The data entry point is defined in [DATA_API.md](./DATA_API.md):
@@ -2362,6 +2364,7 @@ The following variables are already documented public customization surface and
2362
2364
  - `--epr-preview-text-color`
2363
2365
 
2364
2366
  ### Native emoji font
2367
+ - `--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
2368
  - `--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
2369
 
2367
2370
  ### Skin tone
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.0.0",
2
+ "version": "5.0.1",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -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({
@@ -109,7 +109,11 @@ export function PickerDataProvider({
109
109
  }
110
110
  }
111
111
  for (const custom of allCustomEmojis) {
112
- if (emojiNames(custom).some((name) => name.includes(normalized))) {
112
+ if (
113
+ emojiNames(custom).some((name) =>
114
+ name.toLowerCase().includes(normalized),
115
+ )
116
+ ) {
113
117
  dict[custom[Keys.unified]] = custom;
114
118
  }
115
119
  }
@@ -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
  }))();
@@ -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: {
@@ -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(
@@ -49,9 +49,7 @@ const NO_CUSTOM: object = {};
49
49
 
50
50
  function customToRegularEmoji(emoji: CustomEmoji): DataEmoji {
51
51
  return {
52
- [EmojiProperties.name]: emoji.names.map((name: string) =>
53
- name.toLowerCase(),
54
- ),
52
+ [EmojiProperties.name]: [...emoji.names],
55
53
  [EmojiProperties.unified]: emoji.id.toLowerCase(),
56
54
  [EmojiProperties.added_in]: '0',
57
55
  [EmojiProperties.imgUrl]: emoji.imgUrl,
@@ -62,28 +62,10 @@ export function normalizeQuery(input: string): string {
62
62
  return input.trim().toLowerCase();
63
63
  }
64
64
 
65
- function toEmojiInfo(
66
- unified: string,
67
- names: readonly string[],
68
- variations: readonly string[] | undefined,
69
- addedIn: string,
70
- ): EmojiInfo {
71
- return Object.freeze({
72
- unified,
73
- emoji: parseNativeEmoji(unified),
74
- name: names[names.length - 1] || '',
75
- names: Object.freeze([...names]),
76
- variations: Object.freeze([...(variations ?? [])]),
77
- addedIn,
78
- });
79
- }
80
-
81
65
  function readStringArray(raw: unknown): string[] {
82
- if (!Array.isArray(raw)) {
83
- return [];
84
- }
85
- return (raw.filter((n) => typeof n === 'string') as string[]).map((n) =>
86
- n.toLowerCase(),
66
+ if (!Array.isArray(raw)) return [];
67
+ return (raw.filter((value) => typeof value === 'string') as string[]).map(
68
+ (value) => value.toLowerCase(),
87
69
  );
88
70
  }
89
71
 
@@ -106,11 +88,18 @@ function appendEntry(
106
88
  const names = readStringArray((entry as { n?: unknown }).n);
107
89
  const variations = readStringArray((entry as { v?: unknown }).v);
108
90
 
109
- const rawAddedIn = (entry as { a?: unknown }).a;
110
- const addedIn =
111
- typeof rawAddedIn === 'string' ? rawAddedIn : String(rawAddedIn ?? '');
91
+ const addedIn = String((entry as { a?: unknown }).a ?? '');
112
92
 
113
- const info = toEmojiInfo(unified, names, variations, addedIn);
93
+ // Freeze the arrays readStringArray owns directly, without copying them
94
+ // a second time. Caller arrays are never mutated or frozen.
95
+ const info: EmojiInfo = Object.freeze({
96
+ unified,
97
+ emoji: parseNativeEmoji(unified),
98
+ name: names[names.length - 1] || '',
99
+ names: Object.freeze(names),
100
+ variations: Object.freeze(variations),
101
+ addedIn,
102
+ });
114
103
  records.push(info);
115
104
  byUnified.set(unified, info);
116
105
  for (const variation of info.variations) {
@@ -121,18 +110,17 @@ function appendEntry(
121
110
  indexCharBucket(byCharMutable, names, info);
122
111
  }
123
112
 
124
- // One bucket entry per distinct character keeps the per-record cost
125
- // proportional to name length, not name length squared.
113
+ // Records are indexed in order. A bucket already ending in this record
114
+ // has seen the character, avoiding a temporary Set for every emoji.
126
115
  function indexCharBucket(
127
116
  byCharMutable: Map<string, EmojiInfo[]>,
128
117
  names: readonly string[],
129
118
  info: EmojiInfo,
130
119
  ): void {
131
- const distinctChars = Array.from(new Set(names.join('')));
132
- for (const char of distinctChars) {
120
+ for (const char of names.join('')) {
133
121
  const bucket = byCharMutable.get(char);
134
122
  if (bucket) {
135
- bucket.push(info);
123
+ if (bucket[bucket.length - 1] !== info) bucket.push(info);
136
124
  } else {
137
125
  byCharMutable.set(char, [info]);
138
126
  }
@@ -161,15 +149,12 @@ export function getPreparedCore(emojiData?: EmojiData): PreparedCore {
161
149
  }
162
150
  }
163
151
 
164
- const byChar = new Map<string, readonly EmojiInfo[]>();
165
- byCharMutable.forEach((bucket, char) => {
166
- byChar.set(char, Object.freeze(bucket));
167
- });
152
+ byCharMutable.forEach(Object.freeze);
168
153
 
169
154
  const core: PreparedCore = {
170
155
  records,
171
156
  byUnified,
172
- byChar,
157
+ byChar: byCharMutable,
173
158
  queryMemo: new Map<string, readonly EmojiInfo[]>(),
174
159
  };
175
160
 
@@ -82,7 +82,8 @@ export function emojiVariationUnified(
82
82
  // only if the registered dataset changes, so importing this module costs
83
83
  // nothing and does not pull the dataset into a bundle.
84
84
  let indexedSource: EmojiData | null = null;
85
- let allEmojisByUnified: Record<string, DataEmoji> = Object.create(null);
85
+ let allEmojisByUnified: Record<string, DataEmoji> =
86
+ /* @__PURE__ */ Object.create(null);
86
87
 
87
88
  function registryIndex(): Record<string, DataEmoji> {
88
89
  const source = getRegisteredDefaultEmojiData();
@@ -1,6 +1,5 @@
1
1
  export function parseNativeEmoji(unified: string): string {
2
- return unified
3
- .split('-')
4
- .map((hex) => String.fromCodePoint(parseInt(hex, 16)))
5
- .join('');
2
+ return String.fromCodePoint(
3
+ ...unified.split('-').map((hex) => parseInt(hex, 16)),
4
+ );
6
5
  }