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
@@ -13,17 +13,20 @@ import {
13
13
  useLazyLoadEmojisConfig,
14
14
  useSkinTonesDisabledConfig,
15
15
  } from '../config/useConfig';
16
- import { DataEmojis } from '../dataUtils/DataTypes';
16
+ import { DataEmoji, DataEmojis } from '../dataUtils/DataTypes';
17
17
  import { emojiUnified } from '../dataUtils/emojiUtils';
18
+ import { EmojiStyle } from '../types/exposedTypes';
18
19
  import {
19
20
  getEmojiPositionStyle,
20
21
  shouldVirtualize,
22
+ isJustBelowViewport,
21
23
  } from '../virtualization/virtualizationHelpers';
22
24
 
23
- import { preloadEmojiIfNeeded } from './preloadEmoji';
25
+ import { preloadEmoji } from './preloadEmoji';
24
26
  import { useCategoryHeight } from './useCategoryHeight';
25
27
  import { useIsEmojiDisallowed } from './useDisallowedEmojis';
26
28
  import { useIsEmojiHidden } from './useIsEmojiHidden';
29
+ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
27
30
 
28
31
  export function useEmojiVirtualization({
29
32
  categoryEmojis,
@@ -53,31 +56,42 @@ export function useEmojiVirtualization({
53
56
  // Native glyphs checked exactly: the cells this pass renders (visible
54
57
  // rows plus virtualization's margin rows), nothing else.
55
58
  const probe: string[] = [];
56
-
57
- let virtualizedCounter = 0;
59
+ const preloads: DataEmoji[] = [];
60
+ const [committed, setCommitted] = React.useState(false);
58
61
 
59
62
  // A section shows each rendered emoji once. Recents can hold the same
60
63
  // emoji under two tones (e.g. neutral and medium); the active tone
61
64
  // renders both as one identity, which must not be listed or keyed twice.
62
- const rendered = new Set<string>();
63
- const emojisToPush = categoryEmojis.filter((emoji) => {
64
- const isDisallowed = isEmojiDisallowed(emoji);
65
- const { hidden } = isEmojiHidden(emoji);
65
+ const emojisToPush = React.useMemo(() => {
66
+ const rendered = new Set<string>();
67
+ return categoryEmojis.filter((emoji) => {
68
+ const isDisallowed = isEmojiDisallowed(emoji);
69
+ const { hidden } = isEmojiHidden(emoji);
66
70
 
67
- if (hidden || isDisallowed) {
68
- return false;
69
- }
70
- const unified = emojiUnified(emoji, activeSkinTone);
71
- if (rendered.has(unified)) {
72
- return false;
73
- }
74
- rendered.add(unified);
75
- return true;
76
- });
71
+ if (hidden || isDisallowed) {
72
+ return false;
73
+ }
74
+ const unified = emojiUnified(emoji, activeSkinTone);
75
+ if (rendered.has(unified)) {
76
+ return false;
77
+ }
78
+ rendered.add(unified);
79
+ return true;
80
+ });
81
+ }, [categoryEmojis, activeSkinTone, isEmojiHidden, isEmojiDisallowed]);
77
82
 
78
- const dimensions = useCategoryHeight(emojisToPush.length);
83
+ const dimensions = useCategoryHeight(
84
+ emojisToPush.length,
85
+ isCategoryVisible && positioned,
86
+ );
79
87
 
88
+ // Guarantee a render even when a hidden picker has no measurement cell.
89
+ // A passive update avoids flushing all observer effects before first paint.
80
90
  useEffect(() => {
91
+ if (!dimensions) setCommitted(true);
92
+ }, [dimensions]);
93
+
94
+ useIsomorphicLayoutEffect(() => {
81
95
  if (dimensions) {
82
96
  onHeightReady(dimensions.categoryHeight);
83
97
  }
@@ -94,61 +108,83 @@ export function useEmojiVirtualization({
94
108
  dimensions,
95
109
  });
96
110
 
97
- const emojis = emojisToPush.reduce((accumulator, emoji, index) => {
98
- const unified = emojiUnified(emoji, activeSkinTone);
99
- const style = getEmojiPositionStyle(dimensions, index);
111
+ const needsPreload = (style: { top: number } | undefined) =>
112
+ emojiStyle !== EmojiStyle.NATIVE &&
113
+ dimensions &&
114
+ style &&
115
+ isJustBelowViewport(
116
+ topOffset + style.top,
117
+ scrollTop + (BodyRef.current?.clientHeight ?? 0),
118
+ dimensions.emojiSize,
119
+ );
100
120
 
101
- if (isVirtualized(style)) {
102
- virtualizedCounter++;
103
- preloadEmojiIfNeeded(
104
- emoji,
105
- emojiStyle,
106
- scrollTop,
107
- BodyRef.current?.clientHeight ?? 0,
108
- topOffset,
109
- style,
110
- dimensions,
111
- getEmojiUrl,
112
- );
113
- return accumulator;
114
- }
121
+ const waitingForLayout =
122
+ (!committed && !dimensions) ||
123
+ ((!dimensions || !positioned) && BodyRef.current?.clientWidth);
115
124
 
116
- if (!isCategoryVisible) {
117
- virtualizedCounter++;
118
- return accumulator;
119
- }
125
+ // MeasureEmoji supplies geometry before cells are built. Layout-free
126
+ // environments can render after the first commit without measurements.
127
+ const emojis = waitingForLayout
128
+ ? []
129
+ : emojisToPush.reduce((accumulator, emoji, index) => {
130
+ const style = getEmojiPositionStyle(dimensions, index);
120
131
 
121
- // Wait for layout: an unmeasured pass renders a whole category, and
122
- // while sections above are unmeasured this one renders off-screen
123
- // cells as if it were at the top.
124
- if (dimensions && positioned) probe.push(unified);
125
- accumulator.push(
126
- <ClickableEmoji
127
- showVariations={showVariations}
128
- key={unified}
129
- emoji={emoji}
130
- unified={unified}
131
- emojiStyle={emojiStyle}
132
- lazyLoad={lazyLoadEmojis}
133
- getEmojiUrl={getEmojiUrl}
134
- style={{
135
- ...style,
136
- position: 'absolute',
137
- }}
138
- index={index}
139
- // Grid semantics (issue #508): each emoji is a cell of its
140
- // category row; native button activation is kept.
141
- role="gridcell"
142
- />,
143
- );
144
- return accumulator;
145
- }, [] as ReactNode[]);
132
+ if (isVirtualized(style)) {
133
+ if (needsPreload(style)) preloads.push(emoji);
134
+ return accumulator;
135
+ }
136
+
137
+ if (!isCategoryVisible) {
138
+ return accumulator;
139
+ }
140
+
141
+ const unified = emojiUnified(emoji, activeSkinTone);
142
+ // Wait for layout: an unmeasured pass renders a whole category, and
143
+ // while sections above are unmeasured this one renders off-screen
144
+ // cells as if it were at the top.
145
+ if (dimensions && positioned) probe.push(unified);
146
+ accumulator.push(
147
+ <ClickableEmoji
148
+ showVariations={showVariations}
149
+ key={unified}
150
+ emoji={emoji}
151
+ unified={unified}
152
+ emojiStyle={emojiStyle}
153
+ lazyLoad={lazyLoadEmojis}
154
+ getEmojiUrl={getEmojiUrl}
155
+ style={{
156
+ ...style,
157
+ position: 'absolute',
158
+ }}
159
+ index={index}
160
+ // Grid semantics (issue #508): each emoji is a cell of its
161
+ // category row; native button activation is kept.
162
+ role="gridcell"
163
+ />,
164
+ );
165
+ return accumulator;
166
+ }, [] as ReactNode[]);
146
167
 
147
168
  // Every commit: requests for already-checked glyphs are free.
148
169
  useEffect(() => requestNativeProbe(probe));
170
+ // Image requests belong to committed work, never an abandoned render.
171
+ // The proximity and cache checks run synchronously, without per-cell timers.
172
+ // Only the image a nearby cell will render is fetched: every skin tone of
173
+ // every nearby cell is hundreds of requests per scroll and long frames on
174
+ // slow devices. Focusing a cell (useOnFocus) fetches its variations.
175
+ useEffect(() => {
176
+ preloads.forEach((emoji) =>
177
+ preloadEmoji(
178
+ getEmojiUrl,
179
+ emoji,
180
+ emojiStyle,
181
+ emojiUnified(emoji, activeSkinTone),
182
+ ),
183
+ );
184
+ });
149
185
 
150
186
  return {
151
- virtualizedCounter,
187
+ virtualizedCounter: emojisToPush.length - emojis.length,
152
188
  emojis,
153
189
  dimensions,
154
190
  emojiCount: emojisToPush.length,
@@ -100,13 +100,11 @@ function useApplySearch() {
100
100
  if (!mountedRef.current) {
101
101
  return;
102
102
  }
103
- setSearchTerm(searchTerm ? searchTerm?.toLowerCase() : searchTerm).then(
104
- () => {
105
- if (registry.isCurrent(token)) {
106
- scrollTo(PickerMainRef.current, 0, () => registry.isCurrent(token));
107
- }
108
- },
109
- );
103
+ setSearchTerm(searchTerm).then(() => {
104
+ if (registry.isCurrent(token)) {
105
+ scrollTo(PickerMainRef.current, 0, () => registry.isCurrent(token));
106
+ }
107
+ });
110
108
  });
111
109
  };
112
110
  }
@@ -115,7 +113,11 @@ export function useIsEmojiFiltered(): (unified: string) => boolean {
115
113
  const { current: filter } = useFilterRef();
116
114
  const [searchTerm] = useSearchTermState();
117
115
 
118
- return (unified) => isEmojiFilteredBySearchTerm(unified, filter, searchTerm);
116
+ return React.useCallback(
117
+ (unified: string) =>
118
+ isEmojiFilteredBySearchTerm(unified, filter, searchTerm),
119
+ [filter, searchTerm],
120
+ );
119
121
  }
120
122
 
121
123
  export function isEmojiFilteredBySearchTerm(
@@ -1,3 +1,5 @@
1
+ import { useCallback } from 'react';
2
+
1
3
  import {
2
4
  useActiveSkinToneState,
3
5
  useEmojisThatFailedToLoadState,
@@ -9,21 +11,23 @@ import { useIsEmojiFiltered } from './useFilter';
9
11
 
10
12
  export function useIsEmojiHidden(): (emoji: DataEmoji) => IsHiddenReturn {
11
13
  const [activeSkinTone] = useActiveSkinToneState();
12
- const [emojisThatFailedToLoad] = useEmojisThatFailedToLoadState();
14
+ // The tuple changes when its Root-local Set is mutated by an image error.
15
+ const failures = useEmojisThatFailedToLoadState();
13
16
  const isEmojiFiltered = useIsEmojiFiltered();
14
17
 
15
- return (emoji: DataEmoji): IsHiddenReturn => {
16
- const failedToLoad = emojisThatFailedToLoad.has(
17
- emojiUnified(emoji, activeSkinTone),
18
- );
19
- const filteredOut = isEmojiFiltered(emojiCanonicalUnified(emoji));
18
+ return useCallback(
19
+ (emoji: DataEmoji): IsHiddenReturn => {
20
+ const failedToLoad = failures[0].has(emojiUnified(emoji, activeSkinTone));
21
+ const filteredOut = isEmojiFiltered(emojiCanonicalUnified(emoji));
20
22
 
21
- return {
22
- failedToLoad,
23
- filteredOut,
24
- hidden: failedToLoad || filteredOut,
25
- };
26
- };
23
+ return {
24
+ failedToLoad,
25
+ filteredOut,
26
+ hidden: failedToLoad || filteredOut,
27
+ };
28
+ },
29
+ [activeSkinTone, failures, isEmojiFiltered],
30
+ );
27
31
  }
28
32
 
29
33
  type IsHiddenReturn = {
@@ -9,12 +9,12 @@ import {
9
9
  import { getActiveElement } from '../DomUtils/getActiveElement';
10
10
  import { isRtl } from '../DomUtils/isRtl';
11
11
  import {
12
- focusAndClickFirstVisibleEmoji,
12
+ focusFirstEmoji,
13
13
  focusFirstVisibleEmoji,
14
14
  focusAdjacentEmoji,
15
15
  } from '../DomUtils/keyboardNavigation';
16
16
  import { useScrollTo } from '../DomUtils/scrollTo';
17
- import { buttonFromTarget } from '../DomUtils/selectors';
17
+ import { buttonFromTarget, firstVisibleEmoji } from '../DomUtils/selectors';
18
18
  import {
19
19
  ElementRef,
20
20
  useBodyRef,
@@ -27,9 +27,16 @@ import {
27
27
  import {
28
28
  useNavigationRegistry,
29
29
  useSearchComposingState,
30
+ useSearchDisplayState,
31
+ useSearchTermState,
30
32
  useSkinToneFanOpenState,
31
33
  } from '../components/context/PickerContext';
32
- import { useSearchDisabledConfig } from '../config/useConfig';
34
+ import { usePickerDataContext } from '../components/context/PickerDataContext';
35
+ import {
36
+ useSearchDisabledConfig,
37
+ useSuggestedEmojisConfig,
38
+ } from '../config/useConfig';
39
+ import { normalizeQuery } from '../data-core/prepare';
33
40
  import {
34
41
  NavigationRegionKind,
35
42
  RegisteredRegion,
@@ -47,7 +54,11 @@ import {
47
54
  useFocusSkinTonePicker,
48
55
  } from './useFocus';
49
56
  import useIsSearchMode from './useIsSearchMode';
50
- import { useClearSearchValue, useTypeToSearchKey } from './useSearchController';
57
+ import {
58
+ useAcceptedSearchValue,
59
+ useClearSearchValue,
60
+ useTypeToSearchKey,
61
+ } from './useSearchController';
51
62
  import useSetVariationPicker from './useSetVariationPicker';
52
63
 
53
64
  export function useKeyboardNavigation() {
@@ -76,6 +87,65 @@ export function useKeyboardNavigation() {
76
87
  const focusPrevRegionFromGrid = useFocusPrevRegionFrom('grid');
77
88
  const setVariationPicker = useSetVariationPicker();
78
89
  const registry = useNavigationRegistry();
90
+ const [searchTerm] = useSearchTermState();
91
+ const suggestions = JSON.stringify(useSuggestedEmojisConfig());
92
+ const { emojiData } = usePickerDataContext();
93
+ const searchValue = useAcceptedSearchValue();
94
+ // A controlled parent may not have accepted what the input shows yet.
95
+ const [displayed] = useSearchDisplayState();
96
+ // Enter pressed while the input is ahead of the debounced results: the
97
+ // query it was pressed for, selected once those results commit.
98
+ const pendingPick = useRef<string | null>(null);
99
+ const pickEmoji = (emoji: HTMLElement | null) => {
100
+ pendingPick.current = null;
101
+ focusElement(emoji);
102
+ emoji?.click();
103
+ };
104
+ const selectFirstResult = () => {
105
+ const query = pendingPick.current;
106
+ const generation = registry.currentGeneration();
107
+ if (
108
+ !focusFirstEmoji(BodyRef.current, {
109
+ shouldFocus: () =>
110
+ pendingPick.current === query &&
111
+ SearchInputRef.current?.value === query &&
112
+ registry.isCurrent(generation),
113
+ moveTo: pickEmoji,
114
+ })
115
+ )
116
+ pendingPick.current = null;
117
+ };
118
+ // Each committed result set settles a queued Enter for its query.
119
+ useEffect(() => {
120
+ const pending = pendingPick.current;
121
+ pendingPick.current = null;
122
+ if (
123
+ pending !== null &&
124
+ pending === displayed &&
125
+ normalizeQuery(pending) === searchTerm
126
+ ) {
127
+ pendingPick.current = pending;
128
+ selectFirstResult();
129
+ }
130
+ // Per committed result set; selection reads live refs.
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [searchTerm, suggestions, emojiData]);
133
+ // Enter in search selects the first result, even one scrolled out of view.
134
+ const selectFromSearch = () => {
135
+ // Remember fast Enter while the input is ahead of the debounced grid.
136
+ if (
137
+ ![searchValue, displayed].every(
138
+ (value) => normalizeQuery(value) === searchTerm,
139
+ )
140
+ ) {
141
+ pendingPick.current = displayed;
142
+ return;
143
+ }
144
+ if (isSearchMode) {
145
+ pendingPick.current = displayed;
146
+ selectFirstResult();
147
+ } else pickEmoji(firstVisibleEmoji(BodyRef.current));
148
+ };
79
149
  const goDownFromSearchInput = () => {
80
150
  if (isSearchMode) return focusFirstVisibleEmoji(BodyRef.current);
81
151
  focusCategoryNavigation();
@@ -90,14 +160,21 @@ export function useKeyboardNavigation() {
90
160
  disallowMouseMove();
91
161
  switch (key) {
92
162
  case 'Escape':
93
- event.preventDefault();
94
163
  if (hasOpenToggles()) {
164
+ event.preventDefault();
95
165
  closeAllOpenToggles();
96
166
  // A nested variation/tone menu consumes Escape before the
97
167
  // host popover or autocomplete dismisses the whole picker.
98
168
  event.stopPropagation();
99
169
  return;
100
170
  }
171
+ // Clearing a search consumes the key. With nothing to clear, the
172
+ // event stays untouched (this runs in window capture, before host
173
+ // listeners that skip default-prevented keys, such as Radix).
174
+ if (searchValue || displayed) {
175
+ event.preventDefault();
176
+ event.stopPropagation();
177
+ }
101
178
  clearSearch();
102
179
  scrollTo(0);
103
180
  focusSearchInput();
@@ -129,7 +206,7 @@ export function useKeyboardNavigation() {
129
206
  break;
130
207
  case 'Enter':
131
208
  event.preventDefault();
132
- focusAndClickFirstVisibleEmoji(BodyRef.current);
209
+ selectFromSearch();
133
210
  break;
134
211
  }
135
212
  };
@@ -245,20 +322,15 @@ export function useKeyboardNavigation() {
245
322
  }
246
323
  };
247
324
  const [composing] = useSearchComposingState();
248
- const handler = useRef<(event: KeyboardEvent, menusOnly?: boolean) => void>(
325
+ const handler = useRef<(event: KeyboardEvent, escapeOnly?: boolean) => void>(
249
326
  () => {},
250
327
  );
251
328
  // One listener owns the native lifecycle; current callbacks supply state
252
329
  // and live region refs without reattaching on every picker render.
253
- handler.current = (event, menusOnly) => {
254
- if (
255
- composing ||
256
- event.isComposing ||
257
- event.keyCode === 229 ||
258
- (menusOnly && !hasOpenToggles())
259
- )
260
- return;
261
- if (!menusOnly) {
330
+ handler.current = (event, escapeOnly) => {
331
+ if (composing || event.isComposing || event.keyCode === 229) return;
332
+ if (event.key !== 'Enter') pendingPick.current = null;
333
+ if (!escapeOnly) {
262
334
  search(event);
263
335
  tone(event);
264
336
  categories(event);
@@ -272,16 +344,31 @@ export function useKeyboardNavigation() {
272
344
  // Descendant parts are read through live refs by the delegated handler.
273
345
  const current = ref.current;
274
346
  if (!current) return;
347
+ // Escape runs once, in the window capture phase, so a consumed key is
348
+ // stopped before host dismissal listeners (Radix captures on document).
349
+ let captured: KeyboardEvent | undefined;
275
350
  const scoped = (event: KeyboardEvent) => {
351
+ if (event === captured) return;
276
352
  if (eventBelongsToPicker(event, current)) handler.current(event);
277
353
  };
278
354
  const capture = (event: KeyboardEvent) => {
279
- if (event.key === 'Escape' && eventBelongsToPicker(event, current))
355
+ if (event.key === 'Escape' && eventBelongsToPicker(event, current)) {
356
+ captured = event;
280
357
  handler.current(event, true);
358
+ }
359
+ };
360
+ // Editing the query (paste, autocorrect) or leaving the picker cancels
361
+ // a queued Enter.
362
+ const cancelPendingPick = () => {
363
+ pendingPick.current = null;
281
364
  };
365
+ current.addEventListener('input', cancelPendingPick);
366
+ current.addEventListener('focusout', cancelPendingPick);
282
367
  current.addEventListener('keydown', scoped);
283
368
  window.addEventListener('keydown', capture, true);
284
369
  return () => {
370
+ current.removeEventListener('input', cancelPendingPick);
371
+ current.removeEventListener('focusout', cancelPendingPick);
285
372
  current.removeEventListener('keydown', scoped);
286
373
  window.removeEventListener('keydown', capture, true);
287
374
  };
@@ -46,6 +46,9 @@ const boxSizingReset =
46
46
  // vertical tab rail still adds its own width beside the grid.
47
47
  ':where([data-epr-columns] :is([data-epr-part=preview],[data-epr-part=search],[aria-orientation=horizontal])){contain:inline-size}' +
48
48
  ':where([data-epr-columns] [data-epr-part=viewport]){scrollbar-gutter:stable}' +
49
+ // A permanent sizing row keeps intrinsic width when all categories are
50
+ // hidden (no matches) or the dataset is still loading/has failed.
51
+ ':where([data-epr-columns] [data-epr-part=list])::before{content:"";display:block;height:0;width:calc(var(--epr-columns)*var(--epr-emoji-fullsize) + 2*var(--epr-horizontal-padding));max-width:100%}' +
49
52
  ':where([data-epr-columns] [data-epr-part=category-content]){width:calc(var(--epr-columns)*var(--epr-emoji-fullsize));min-width:calc(100% - 2*var(--epr-horizontal-padding));max-width:calc(100% - 2*var(--epr-horizontal-padding))}';
50
53
 
51
54
  export const structuralStyles = /* @__PURE__ */ (() =>
@@ -140,6 +140,7 @@ export const darkPickerTokens: Record<string, string> = {
140
140
 
141
141
  /** Full light preset: geometry plus light colors (v4 defaults). */
142
142
  export const defaultPickerTokens: Record<string, string> = {
143
+ '--epr-font-family': 'sans-serif',
143
144
  ...lightPickerTokens,
144
145
  ...structuralPickerTokens,
145
146
  };
@@ -1,3 +1,9 @@
1
+ export const OVERSCAN_ROWS = 2;
2
+
3
+ export function isJustBelowViewport(top: number, bottom: number, size: number) {
4
+ return top >= bottom && top < bottom + size * OVERSCAN_ROWS;
5
+ }
6
+
1
7
  export function shouldVirtualize({
2
8
  scrollTop,
3
9
  clientHeight,
@@ -19,8 +25,9 @@ export function shouldVirtualize({
19
25
  const emojiBottom = emojiTop + dimensions.emojiSize;
20
26
 
21
27
  const isVisible =
22
- emojiBottom + dimensions.emojiSize * 2 >= scrollTop &&
23
- emojiTop <= scrollTop + clientHeight + dimensions.emojiSize;
28
+ emojiBottom + dimensions.emojiSize * OVERSCAN_ROWS >= scrollTop &&
29
+ emojiTop <=
30
+ scrollTop + clientHeight + dimensions.emojiSize * (OVERSCAN_ROWS - 1);
24
31
 
25
32
  return !isVisible;
26
33
  }