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
@@ -21,7 +21,7 @@ import {
21
21
  } from '../dataUtils/DataTypes';
22
22
  import { Categories, type EmojiData } from '../types/exposedTypes';
23
23
 
24
- import { getPreparedCore } from './prepare';
24
+ import { getPreparedCore, withoutFe0f } from './prepare';
25
25
 
26
26
  export interface PickerDataSnapshot {
27
27
  emojiData: EmojiData;
@@ -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,
@@ -136,15 +134,13 @@ export function getPickerDataSnapshot(
136
134
 
137
135
  const allEmojisByUnified: Record<string, DataEmoji> = Object.create(null);
138
136
 
137
+ // The base unified and each variation, and either U+FE0F spelling of
138
+ // them; an exact ID always wins over another record's spelling.
139
139
  allEmojis.forEach((emoji) => {
140
- const unified = emoji[Keys.unified];
141
- allEmojisByUnified[unified] = emoji;
142
-
143
- if (emoji[Keys.variations]) {
144
- emoji[Keys.variations]?.forEach((variation) => {
145
- allEmojisByUnified[variation] = emoji;
146
- });
147
- }
140
+ [emoji[Keys.unified], ...(emoji[Keys.variations] || [])].forEach((id) => {
141
+ allEmojisByUnified[id] = emoji;
142
+ allEmojisByUnified[withoutFe0f(id)] ||= emoji;
143
+ });
148
144
  });
149
145
 
150
146
  const snapshot: PickerDataSnapshot = {
@@ -55,6 +55,15 @@ export function normalizeUnified(input: string): string {
55
55
  return input.trim().toLowerCase();
56
56
  }
57
57
 
58
+ /**
59
+ * Datasets disagree on where U+FE0F (emoji presentation) appears in an ID:
60
+ * `2764` and `2764-fe0f` are the same heart. IDs are also indexed under
61
+ * this form, so either spelling finds the dataset's own record.
62
+ */
63
+ export function withoutFe0f(unified: string): string {
64
+ return unified.replace(/-fe0f/g, '');
65
+ }
66
+
58
67
  export function normalizeQuery(input: string): string {
59
68
  if (!input || typeof input !== 'string') {
60
69
  return '';
@@ -62,28 +71,10 @@ export function normalizeQuery(input: string): string {
62
71
  return input.trim().toLowerCase();
63
72
  }
64
73
 
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
74
  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(),
75
+ if (!Array.isArray(raw)) return [];
76
+ return (raw.filter((value) => typeof value === 'string') as string[]).map(
77
+ (value) => value.toLowerCase(),
87
78
  );
88
79
  }
89
80
 
@@ -106,33 +97,38 @@ function appendEntry(
106
97
  const names = readStringArray((entry as { n?: unknown }).n);
107
98
  const variations = readStringArray((entry as { v?: unknown }).v);
108
99
 
109
- const rawAddedIn = (entry as { a?: unknown }).a;
110
- const addedIn =
111
- typeof rawAddedIn === 'string' ? rawAddedIn : String(rawAddedIn ?? '');
100
+ const addedIn = String((entry as { a?: unknown }).a ?? '');
112
101
 
113
- const info = toEmojiInfo(unified, names, variations, addedIn);
102
+ // Freeze the arrays readStringArray owns directly, without copying them
103
+ // a second time. Caller arrays are never mutated or frozen.
104
+ const info: EmojiInfo = Object.freeze({
105
+ unified,
106
+ emoji: parseNativeEmoji(unified),
107
+ name: names[names.length - 1] || '',
108
+ names: Object.freeze(names),
109
+ variations: Object.freeze(variations),
110
+ addedIn,
111
+ });
114
112
  records.push(info);
115
113
  byUnified.set(unified, info);
116
- for (const variation of info.variations) {
117
- if (!byUnified.has(variation)) {
118
- byUnified.set(variation, info);
119
- }
114
+ for (const id of [unified, ...info.variations]) {
115
+ if (!byUnified.has(id)) byUnified.set(id, info);
116
+ if (!byUnified.has(withoutFe0f(id))) byUnified.set(withoutFe0f(id), info);
120
117
  }
121
118
  indexCharBucket(byCharMutable, names, info);
122
119
  }
123
120
 
124
- // One bucket entry per distinct character keeps the per-record cost
125
- // proportional to name length, not name length squared.
121
+ // Records are indexed in order. A bucket already ending in this record
122
+ // has seen the character, avoiding a temporary Set for every emoji.
126
123
  function indexCharBucket(
127
124
  byCharMutable: Map<string, EmojiInfo[]>,
128
125
  names: readonly string[],
129
126
  info: EmojiInfo,
130
127
  ): void {
131
- const distinctChars = Array.from(new Set(names.join('')));
132
- for (const char of distinctChars) {
128
+ for (const char of names.join('')) {
133
129
  const bucket = byCharMutable.get(char);
134
130
  if (bucket) {
135
- bucket.push(info);
131
+ if (bucket[bucket.length - 1] !== info) bucket.push(info);
136
132
  } else {
137
133
  byCharMutable.set(char, [info]);
138
134
  }
@@ -161,15 +157,12 @@ export function getPreparedCore(emojiData?: EmojiData): PreparedCore {
161
157
  }
162
158
  }
163
159
 
164
- const byChar = new Map<string, readonly EmojiInfo[]>();
165
- byCharMutable.forEach((bucket, char) => {
166
- byChar.set(char, Object.freeze(bucket));
167
- });
160
+ byCharMutable.forEach(Object.freeze);
168
161
 
169
162
  const core: PreparedCore = {
170
163
  records,
171
164
  byUnified,
172
- byChar,
165
+ byChar: byCharMutable,
173
166
  queryMemo: new Map<string, readonly EmojiInfo[]>(),
174
167
  };
175
168
 
@@ -1,7 +1,12 @@
1
1
  // Pure v5 data operations shared by the picker and `emoji-picker-react/data`.
2
2
  // No React / ShipStyles imports allowed in this module graph.
3
3
 
4
- import { getPreparedCore, normalizeQuery, normalizeUnified } from './prepare';
4
+ import {
5
+ getPreparedCore,
6
+ normalizeQuery,
7
+ normalizeUnified,
8
+ withoutFe0f,
9
+ } from './prepare';
5
10
  import type { EmojiDataOptions, EmojiInfo } from './types';
6
11
 
7
12
  export type { EmojiDataOptions, EmojiInfo };
@@ -74,7 +79,10 @@ export function getEmojiByUnified(
74
79
  return undefined;
75
80
  }
76
81
  const core = getPreparedCore(options?.emojiData);
77
- return core.byUnified.get(normalized);
82
+ return (
83
+ core.byUnified.get(normalized) ??
84
+ core.byUnified.get(withoutFe0f(normalized))
85
+ );
78
86
  }
79
87
 
80
88
  /**
@@ -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
  }
@@ -1,3 +1,5 @@
1
+ import { withoutFe0f } from '../data-core/prepare';
2
+
1
3
  import { DataEmoji, EmojiProperties } from './DataTypes';
2
4
 
3
5
  // Caller-defined Suggested contents.
@@ -47,46 +49,30 @@ export function resolveEntryId(
47
49
  if (!id) {
48
50
  return undefined;
49
51
  }
50
- if (isKnownRenderId(id, lookup)) {
51
- return id;
52
- }
53
- // Unified IDs and custom IDs are ASCII; anything else may be the
54
- // emoji character itself.
55
- if (Array.from(id).every((char) => (char.codePointAt(0) as number) < 0x80)) {
56
- return undefined;
52
+ let unified = id;
53
+ let record = lookup(id);
54
+ // Unified IDs and custom IDs are ASCII; anything else may be the emoji
55
+ // character itself, read as its code points.
56
+ if (!record && /[^\0-\x7f]/.test(id)) {
57
+ unified = Array.from(id)
58
+ .map((char) =>
59
+ (char.codePointAt(0) as number).toString(16).padStart(4, '0'),
60
+ )
61
+ .join('-');
62
+ record = lookup(unified);
57
63
  }
58
- return nativeToUnifiedCandidates(id).find((candidate) =>
59
- isKnownRenderId(candidate, lookup),
60
- );
61
- }
62
-
63
- // Dataset IDs are zero-padded code points joined by "-", and carry U+FE0F
64
- // where the emoji presentation needs it; typed or stored characters often
65
- // omit or add it. Try the exact sequence, then without FE0F, then with
66
- // FE0F after the first code point (e.g. "©" -> "00a9-fe0f").
67
- function nativeToUnifiedCandidates(native: string): string[] {
68
- const codePoints = Array.from(native).map((char) =>
69
- (char.codePointAt(0) as number).toString(16).padStart(4, '0'),
70
- );
71
- const exact = codePoints.join('-');
72
- const bare = codePoints.filter((codePoint) => codePoint !== 'fe0f');
73
- const withPresentation = [bare[0], 'fe0f', ...bare.slice(1)];
74
- return [exact, bare.join('-'), withPresentation.join('-')];
75
- }
76
-
77
- // A render identity is valid only as the base unified or a listed
78
- // variation. Anything else — e.g. a nonexistent variation that fell back
79
- // to the base record — is unknown and ignored.
80
- function isKnownRenderId(
81
- id: string,
82
- lookup: (normalizedId: string) => DataEmoji | undefined,
83
- ): boolean {
84
- const record = lookup(id);
85
64
  if (!record) {
86
- return false;
65
+ return undefined;
87
66
  }
88
- return (
89
- record[EmojiProperties.unified] === id ||
90
- (record[EmojiProperties.variations] ?? []).includes(id)
91
- );
67
+ // A render identity is the base unified or a listed variation; a
68
+ // nonexistent variation that fell back to the base record is unknown.
69
+ // Typed, stored or other datasets' IDs often omit or add U+FE0F: resolve
70
+ // to the record's own spelling of that identity.
71
+ const known = [
72
+ record[EmojiProperties.unified],
73
+ ...(record[EmojiProperties.variations] ?? []),
74
+ ];
75
+ return known.includes(unified)
76
+ ? unified
77
+ : known.find((each) => withoutFe0f(each) === withoutFe0f(unified));
92
78
  }
@@ -1,75 +1,36 @@
1
1
  import { GetEmojiUrl } from '../components/emoji/BaseEmojiProps';
2
2
  import { DataEmoji } from '../dataUtils/DataTypes';
3
3
  import { emojiUnified, emojiVariations } from '../dataUtils/emojiUtils';
4
- import { EmojiStyle, EmojiStyleValue } from '../types/exposedTypes';
5
- import { Dimensions } from '../virtualization/virtualizationHelpers';
6
-
7
- // eslint-disable-next-line max-params
8
- export function preloadEmojiIfNeeded(
9
- emoji: undefined | DataEmoji,
10
- emojiStyles: EmojiStyleValue,
11
- scrollTop: number,
12
- clientHeight: number,
13
- topOffset: number,
14
- style: { top: number } | undefined,
15
- dimensions: Dimensions,
16
- getEmojiUrl: GetEmojiUrl,
17
- ): void {
18
- if (!emoji) {
19
- return;
20
- }
21
-
22
- if (emojiStyles === EmojiStyle.NATIVE) {
23
- return;
24
- }
25
-
26
- const unified = emojiUnified(emoji);
27
-
28
- if (preloadedEmojs.has(unified)) {
29
- return;
30
- }
31
-
32
- // preload only if the emoji is a few rows below the viewport
33
- if (!style || !dimensions) {
34
- return;
35
- }
36
-
37
- setTimeout(() => {
38
- const emojiTop = topOffset + style.top;
39
- const viewportBottom = scrollTop + clientHeight;
40
-
41
- const isJustBelowViewport =
42
- emojiTop >= viewportBottom &&
43
- emojiTop < viewportBottom + dimensions.emojiSize * 2;
44
-
45
- if (isJustBelowViewport) {
46
- preloadEmoji(getEmojiUrl, emoji, emojiStyles);
47
- }
48
- });
49
- }
4
+ import { EmojiStyleValue } from '../types/exposedTypes';
50
5
 
51
6
  export function preloadEmoji(
52
7
  getEmojiUrl: GetEmojiUrl,
53
8
  emoji: undefined | DataEmoji,
54
9
  emojiStyle: EmojiStyleValue,
10
+ rendered?: string,
55
11
  ): void {
56
12
  if (!emoji) {
57
13
  return;
58
14
  }
59
15
 
60
16
  const unified = emojiUnified(emoji);
17
+ // With `rendered`, only the image the cell renders (the active tone's, or
18
+ // the base) is fetched. It is marked apart from a full preload, so a
19
+ // later full preload (on focus) still fetches every tone; a full one
20
+ // covers it.
21
+ const key = rendered ? rendered + '/only' : unified;
61
22
 
62
- if (preloadedEmojs.has(unified)) {
23
+ if (preloadedEmojs.has(unified) || preloadedEmojs.has(key)) {
63
24
  return;
64
25
  }
65
- preloadedEmojs.add(unified);
26
+ preloadedEmojs.add(key);
66
27
 
67
- emojiVariations(emoji)
68
- .concat(unified)
69
- .forEach((variation) => {
28
+ (rendered ? [rendered] : emojiVariations(emoji).concat(unified)).forEach(
29
+ (variation) => {
70
30
  const emojiUrl = getEmojiUrl(variation, emojiStyle);
71
31
  preloadImage(emojiUrl);
72
- });
32
+ },
33
+ );
73
34
  }
74
35
 
75
36
  export const preloadedEmojs: Set<string> = new Set();
@@ -1,9 +1,12 @@
1
1
  import { useEffect } from 'react';
2
2
 
3
3
  import { categoryNameFromDom } from '../DomUtils/categoryNameFromDom';
4
- import { asSelectors, ClassNames } from '../DomUtils/classNames';
4
+ import { ClassNames } from '../DomUtils/classNames';
5
5
  import { useBodyRef } from '../components/context/ElementRefContext';
6
- import { CategoriesConfig } from '../config/categoryConfig';
6
+ import {
7
+ CategoriesConfig,
8
+ categoryIdFromCategoryConfig,
9
+ } from '../config/categoryConfig';
7
10
 
8
11
  export function useActiveCategoryScrollDetection({
9
12
  setActiveCategory,
@@ -22,9 +25,11 @@ export function useActiveCategoryScrollDetection({
22
25
  const BodyRef = useBodyRef();
23
26
 
24
27
  useEffect(() => {
25
- const visibleCategories = new Map<string, number>();
26
- const intersectingCategories = new Map<string, boolean>();
27
- const targets = new Map<string, Element>();
28
+ if (typeof IntersectionObserver === 'undefined') {
29
+ setVisibleCategories(categories.map(categoryIdFromCategoryConfig));
30
+ return;
31
+ }
32
+ const observed = new Map<string, IntersectionObserverEntry>();
28
33
  const bodyRef = BodyRef.current;
29
34
  const observer = new IntersectionObserver(
30
35
  (entries) => {
@@ -39,31 +44,31 @@ export function useActiveCategoryScrollDetection({
39
44
  continue;
40
45
  }
41
46
 
42
- visibleCategories.set(id, entry.intersectionRatio);
43
- intersectingCategories.set(id, entry.isIntersecting);
44
- targets.set(id, entry.target);
47
+ observed.set(id, entry);
45
48
  }
46
49
 
47
- const ratios = Array.from(visibleCategories);
50
+ const ratios = Array.from(observed);
48
51
  const visibleCats = ratios
49
- .filter(([id, ratio]) => ratio > 0 || intersectingCategories.get(id))
52
+ .filter(
53
+ ([, entry]) => entry.intersectionRatio > 0 || entry.isIntersecting,
54
+ )
50
55
  .map(([id]) => id);
51
56
 
52
57
  setVisibleCategories(visibleCats);
53
58
  const lastCategory = ratios[ratios.length - 1];
54
59
 
55
- if (lastCategory?.[1] == 1) {
60
+ if (lastCategory?.[1].intersectionRatio === 1) {
56
61
  return setActiveCategory(lastCategory[0]);
57
62
  }
58
63
 
59
64
  const viewportTop = bodyRef.getBoundingClientRect().top;
60
- for (const [id, ratio] of ratios) {
65
+ for (const [id, entry] of ratios) {
61
66
  // Fractional offsets can leave less than a pixel of the preceding
62
67
  // section intersecting after a jump. It must not steal the tab
63
68
  // highlight from the section aligned with the viewport top.
64
69
  if (
65
- ratio &&
66
- targets.get(id)!.getBoundingClientRect().bottom > viewportTop + 1
70
+ entry.intersectionRatio &&
71
+ entry.target.getBoundingClientRect().bottom > viewportTop + 1
67
72
  ) {
68
73
  setActiveCategory(id);
69
74
  break;
@@ -75,11 +80,9 @@ export function useActiveCategoryScrollDetection({
75
80
  threshold: [0, 1],
76
81
  },
77
82
  );
78
- bodyRef
79
- ?.querySelectorAll(asSelectors(ClassNames.category))
80
- .forEach((el) => {
81
- observer.observe(el);
82
- });
83
+ bodyRef?.querySelectorAll(`.${ClassNames.category}`).forEach((el) => {
84
+ observer.observe(el);
85
+ });
83
86
 
84
87
  return () => {
85
88
  observer.disconnect();
@@ -1,11 +1,12 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { asSelectors, ClassNames } from '../DomUtils/classNames';
3
+ import { ClassNames } from '../DomUtils/classNames';
4
4
  import {
5
5
  elementBorderBoxHeight,
6
6
  EmojiButtonSelector,
7
7
  } from '../DomUtils/selectors';
8
8
  import {
9
+ useBodyRef,
9
10
  useEmojiListRef,
10
11
  usePickerMainRef,
11
12
  } from '../components/context/ElementRefContext';
@@ -33,7 +34,7 @@ function firstVisibleContentWidth(
33
34
  fallbackWidth: number,
34
35
  ): number {
35
36
  const contentBoxes = listEl.querySelectorAll(
36
- asSelectors(ClassNames.categoryContent),
37
+ `.${ClassNames.categoryContent}`,
37
38
  );
38
39
  for (const box of Array.from(contentBoxes)) {
39
40
  const width = (box as HTMLElement).clientWidth;
@@ -60,15 +61,20 @@ function trackColumnCount(
60
61
  previousRef.current = emojisPerRow;
61
62
  }
62
63
 
63
- export function useCategoryHeight(emojiCount: number):
64
+ export function useCategoryHeight(
65
+ emojiCount: number,
66
+ ready = true,
67
+ ):
64
68
  | {
65
69
  categoryHeight: number;
66
70
  emojisPerRow: number;
67
71
  emojiSize: number;
68
72
  rowWidth: number;
73
+ viewportHeight?: number;
69
74
  }
70
75
  | undefined {
71
76
  const EmojiListRef = useEmojiListRef();
77
+ const BodyRef = useBodyRef();
72
78
  const [isReactionsMode] = useReactionsModeState();
73
79
  const PickerMainRef = usePickerMainRef();
74
80
  const emojiSizeRef = React.useRef<number | undefined>(undefined);
@@ -82,6 +88,7 @@ export function useCategoryHeight(emojiCount: number):
82
88
  emojisPerRow: number;
83
89
  emojiSize: number;
84
90
  rowWidth: number;
91
+ viewportHeight?: number;
85
92
  }>();
86
93
 
87
94
  // Helper to compute and store dimensions based on current DOM
@@ -99,8 +106,8 @@ export function useCategoryHeight(emojiCount: number):
99
106
  emojiSizeRef.current = measured;
100
107
  }
101
108
  const emojiSize =
102
- emojiSizeFromContext ||
103
109
  measured ||
110
+ emojiSizeFromContext ||
104
111
  emojiSizeRef.current ||
105
112
  EMOJI_SIZE_DEFAULT;
106
113
  const pickerWidth = listEl.clientWidth;
@@ -113,13 +120,36 @@ export function useCategoryHeight(emojiCount: number):
113
120
  const rowCount = Math.ceil(emojiCount / emojisPerRow);
114
121
  const categoryHeight = rowCount * emojiSize;
115
122
 
123
+ const next = {
124
+ categoryHeight,
125
+ emojisPerRow,
126
+ emojiSize,
127
+ rowWidth,
128
+ // Virtualization reads the viewport height during render, so a picker
129
+ // that mounts collapsed (reactions first) must re-render once it opens.
130
+ viewportHeight: BodyRef.current?.clientHeight,
131
+ };
132
+
116
133
  trackColumnCount(emojisPerRowRef, emojisPerRow, registry);
117
- setDimensions({ categoryHeight, emojisPerRow, emojiSize, rowWidth });
118
- }, [EmojiListRef, emojiCount, emojiSizeFromContext, registry, columns]);
134
+ // Returning `previous` when nothing changed is what stops the layout
135
+ // effect below, which depends on `dimensions`, from looping forever.
136
+ // Both objects come from the literal above, so their keys share an order.
137
+ setDimensions((previous) =>
138
+ JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
139
+ );
140
+ }, [
141
+ EmojiListRef,
142
+ BodyRef,
143
+ emojiCount,
144
+ emojiSizeFromContext,
145
+ registry,
146
+ columns,
147
+ ]);
119
148
 
120
149
  // Restore measured row heights in the same commit as the filtered
121
150
  // contents. A passive effect leaves one frame of stale offsets that
122
151
  // category navigation can observe immediately after clearing search.
152
+ // Recheck an estimated size when positioned/visible cells first mount.
123
153
  useIsomorphicLayoutEffect(() => {
124
154
  computeAndSetDimensions();
125
155
  }, [
@@ -127,6 +157,8 @@ export function useCategoryHeight(emojiCount: number):
127
157
  isReactionsMode,
128
158
  computeAndSetDimensions,
129
159
  visibleCategories.length,
160
+ dimensions,
161
+ ready,
130
162
  ]);
131
163
 
132
164
  // Fluid layouts (width: 100%, sheets, docks) resize without a CSS
@@ -1,8 +1,11 @@
1
+ import { useCallback } from 'react';
2
+
1
3
  import {
2
4
  useActiveSkinToneState,
3
5
  useNativeEmojiSupport,
4
6
  } from '../components/context/PickerContext';
5
7
  import { useEmojiVersionConfig, useUnicodeToHide } from '../config/useConfig';
8
+ import { withoutFe0f } from '../data-core/prepare';
6
9
  import { DataEmoji } from '../dataUtils/DataTypes';
7
10
  import {
8
11
  addedIn,
@@ -20,17 +23,24 @@ export function useIsEmojiDisallowed() {
20
23
  const unicodeToHide = useUnicodeToHide();
21
24
  const [activeSkinTone] = useActiveSkinToneState();
22
25
 
23
- return function isEmojiDisallowed(
24
- emoji: DataEmoji,
25
- renderedUnified = emojiUnified(emoji, activeSkinTone),
26
- ) {
27
- if (unicodeToHide.has(unifiedWithoutSkinTone(emojiUnified(emoji))))
28
- return true;
29
- if (isCustomEmoji(emoji)) return false;
30
- const version = addedIn(emoji);
31
- return (
32
- version > cap ||
33
- !isNativeEmojiSupported(nativeSupport, renderedUnified, version)
34
- );
35
- };
26
+ return useCallback(
27
+ function isEmojiDisallowed(
28
+ emoji: DataEmoji,
29
+ renderedUnified = emojiUnified(emoji, activeSkinTone),
30
+ ) {
31
+ if (
32
+ unicodeToHide.has(
33
+ withoutFe0f(unifiedWithoutSkinTone(emojiUnified(emoji))),
34
+ )
35
+ )
36
+ return true;
37
+ if (isCustomEmoji(emoji)) return false;
38
+ const version = addedIn(emoji);
39
+ return (
40
+ version > cap ||
41
+ !isNativeEmojiSupported(nativeSupport, renderedUnified, version)
42
+ );
43
+ },
44
+ [activeSkinTone, unicodeToHide, cap, nativeSupport],
45
+ );
36
46
  }