emoji-picker-react 4.21.0 → 4.22.0

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.
@@ -13,6 +13,12 @@ export declare type CategoryConfig = {
13
13
  category: Categories;
14
14
  name: string;
15
15
  icon?: React.ReactNode;
16
+ /**
17
+ * Selects a `CustomEmoji` group for `Categories.CUSTOM` entries. Each
18
+ * grouped entry renders its own section and nav tab; ungrouped entries
19
+ * share the classic customs bucket.
20
+ */
21
+ group?: string;
16
22
  };
17
23
  export declare type CategoryIcons = Partial<Record<Categories, React.ReactNode>>;
18
24
  export declare type EmojiData = {
package/llms.txt CHANGED
@@ -209,6 +209,7 @@ Pass the `customEmojis` prop to inject image-based emojis. Each entry uses this
209
209
  id: string; // Unique ID
210
210
  names: string[]; // Search keywords
211
211
  imgUrl: string; // Image source
212
+ group?: string; // Optional section; see grouping below
212
213
  }
213
214
  ```
214
215
 
@@ -224,6 +225,35 @@ Pass the `customEmojis` prop to inject image-based emojis. Each entry uses this
224
225
  />
225
226
  ```
226
227
 
228
+ ### Grouping custom emojis
229
+
230
+ Give customs a `group` to render each group as its own named section.
231
+ Reference the group from a `{ category: Categories.CUSTOM, group }`
232
+ entry in `categories` to place it anywhere in the order, with its own
233
+ `name` and `icon`. Customs without a group share the classic bucket.
234
+ Groups missing from `categories` are appended as their own sections
235
+ automatically (after the standard categories), so grouped emojis always
236
+ render somewhere. Omit `CUSTOM` from `categories` entirely to hide all
237
+ customs — explicit `categories` stay an allowlist:
238
+
239
+ ```tsx
240
+ import EmojiPicker, { Categories } from 'emoji-picker-react';
241
+
242
+ <EmojiPicker
243
+ customEmojis={[
244
+ { id: 'panda', names: ['panda'], imgUrl: pandaPng, group: 'animals' },
245
+ { id: 'ninja', names: ['ninja'], imgUrl: ninjaPng, group: 'people' },
246
+ { id: 'orphan', names: ['orphan'], imgUrl: orphanPng },
247
+ ]}
248
+ categories={[
249
+ Categories.SMILEYS_PEOPLE,
250
+ { category: Categories.CUSTOM, group: 'animals', name: 'Animals' },
251
+ { category: Categories.CUSTOM, group: 'people', name: 'People' },
252
+ { category: Categories.CUSTOM, name: 'Misc' },
253
+ ]}
254
+ />;
255
+ ```
256
+
227
257
  ## Preview Bar
228
258
 
229
259
  Control the footer preview area with `previewConfig`:
@@ -300,7 +330,7 @@ import EmojiPicker, { Categories } from 'emoji-picker-react';
300
330
  />;
301
331
  ```
302
332
 
303
- Note: if both methods are used for the same category, the icon from the `categories` configuration takes precedence over the `categoryIcons` prop.
333
+ Note: if both methods are used for the same category, the icon from the `categories` configuration takes precedence over the `categoryIcons` prop. `categoryIcons[Categories.CUSTOM]` applies to every custom group tab that does not define its own `icon`.
304
334
 
305
335
  ## Content Security Policy (CSP)
306
336
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.21.0",
2
+ "version": "4.22.0",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -8,7 +8,7 @@ import {
8
8
  stylesheet,
9
9
  } from '../../Stylesheet/stylesheet';
10
10
  import {
11
- categoryFromCategoryConfig,
11
+ categoryIdFromCategoryConfig,
12
12
  categoryNameFromCategoryConfig,
13
13
  } from '../../config/categoryConfig';
14
14
  import { CategoryConfig } from '../../types/exposedTypes';
@@ -30,7 +30,6 @@ export function EmojiCategory({
30
30
  height,
31
31
  emojisPerRow,
32
32
  }: Props) {
33
- const category = categoryFromCategoryConfig(categoryConfig);
34
33
  const categoryName = categoryNameFromCategoryConfig(categoryConfig);
35
34
 
36
35
  return (
@@ -40,7 +39,7 @@ export function EmojiCategory({
40
39
  hidden && commonStyles.hidden,
41
40
  hiddenOnSearch && commonInteractionStyles.hiddenOnSearch,
42
41
  )}
43
- data-name={category}
42
+ data-name={categoryIdFromCategoryConfig(categoryConfig)}
44
43
  role="rowgroup"
45
44
  aria-label={categoryName}
46
45
  >
@@ -4,7 +4,11 @@ import { cx } from 'shipstyles';
4
4
  import { ClassNames } from '../../DomUtils/classNames';
5
5
  import { getLabelHeight } from '../../DomUtils/elementPositionInRow';
6
6
  import { stylesheet } from '../../Stylesheet/stylesheet';
7
- import { categoryFromCategoryConfig } from '../../config/categoryConfig';
7
+ import {
8
+ categoryFromCategoryConfig,
9
+ categoryIdFromCategoryConfig,
10
+ customGroupFromCategoryConfig,
11
+ } from '../../config/categoryConfig';
8
12
  import { useCategoriesConfig } from '../../config/useConfig';
9
13
  import { DataEmojis } from '../../dataUtils/DataTypes';
10
14
  import { useEmojiVirtualization } from '../../hooks/useEmojiVirtualization';
@@ -41,25 +45,27 @@ export function EmojiList({ scrollTop }: { scrollTop: number }) {
41
45
  <MeasureEmoji />
42
46
  {categories.map((categoryConfig, index) => {
43
47
  const category = categoryFromCategoryConfig(categoryConfig);
48
+ const categoryId = categoryIdFromCategoryConfig(categoryConfig);
49
+ const group = customGroupFromCategoryConfig(categoryConfig);
44
50
 
45
51
  const currentOffset = topOffset;
46
- const categoryHeight = categoryHeights[category];
52
+ const categoryHeight = categoryHeights[categoryId];
47
53
  if (categoryHeight) {
48
54
  topOffset += categoryHeight + labelHeight;
49
55
  }
50
56
 
51
57
  return (
52
58
  <RenderCategory
53
- key={category}
54
- categoryEmojis={getEmojisByCategory(category)}
59
+ key={categoryId}
60
+ categoryEmojis={getEmojisByCategory(category, group)}
55
61
  categoryConfig={categoryConfig}
56
62
  topOffset={currentOffset}
57
63
  isFirstCategory={index === 0}
58
64
  onHeightReady={(height) => {
59
- if (categoryHeights[category] !== height) {
65
+ if (categoryHeights[categoryId] !== height) {
60
66
  setCategoryHeights((prev) => ({
61
67
  ...prev,
62
- [category]: height,
68
+ [categoryId]: height,
63
69
  }));
64
70
  }
65
71
  }}
@@ -95,7 +101,10 @@ function RenderCategory({
95
101
  // https://github.com/ealush/emoji-picker-react/issues/469
96
102
  // https://github.com/ealush/emoji-picker-react/issues/475
97
103
  const isCategoryVisible =
98
- isFirstCategory || visibleCategories.includes(categoryConfig.category);
104
+ isFirstCategory ||
105
+ visibleCategories.includes(
106
+ categoryIdFromCategoryConfig(categoryConfig),
107
+ );
99
108
 
100
109
  const { virtualizedCounter, emojis, dimensions } = useEmojiVirtualization({
101
110
  categoryEmojis,
@@ -1,6 +1,9 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { categoryFromCategoryConfig } from '../../config/categoryConfig';
3
+ import {
4
+ categoryFromCategoryConfig,
5
+ customGroupFromCategoryConfig,
6
+ } from '../../config/categoryConfig';
4
7
  import {
5
8
  useCategoriesConfig,
6
9
  useEmojiStyleConfig,
@@ -37,9 +40,12 @@ export function MeasureEmoji() {
37
40
  }
38
41
 
39
42
  const firstCategory = categories[0];
40
- const dummyEmoji = getEmojisByCategory(
41
- categoryFromCategoryConfig(firstCategory),
42
- )[0];
43
+ const dummyEmoji = firstCategory
44
+ ? getEmojisByCategory(
45
+ categoryFromCategoryConfig(firstCategory),
46
+ customGroupFromCategoryConfig(firstCategory),
47
+ )[0]
48
+ : undefined;
43
49
  const unified = dummyEmoji ? emojiUnified(dummyEmoji, activeSkinTone) : '';
44
50
 
45
51
  if (!dummyEmoji) {
@@ -26,6 +26,7 @@ export interface PickerDataContextValue {
26
26
  allEmojis: DataEmojis;
27
27
  allEmojisByUnified: Record<string, DataEmoji>;
28
28
  searchIndex: Record<string, Record<string, DataEmoji>>;
29
+ customGroups: Record<string, DataEmojis>;
29
30
  emojiByUnified: (unified?: string) => DataEmoji | undefined;
30
31
  activeVariationFromUnified: (unified: string) => SkinTones | null;
31
32
  }
@@ -35,6 +36,7 @@ const PickerDataContext = React.createContext<PickerDataContextValue>({
35
36
  allEmojis: [],
36
37
  allEmojisByUnified: {},
37
38
  searchIndex: {},
39
+ customGroups: {},
38
40
  emojiByUnified,
39
41
  activeVariationFromUnified: () => null,
40
42
  });
@@ -52,14 +54,27 @@ export function PickerDataProvider({
52
54
  // Clone to avoid mutation of shared source
53
55
  const newData: EmojiData = JSON.parse(JSON.stringify(emojiData));
54
56
 
57
+ const customGroups: Record<string, DataEmojis> = {};
58
+
55
59
  if (customEmojis && customEmojis.length > 0) {
56
- newData.emojis[Categories.CUSTOM] =
57
- customEmojis.map(customToRegularEmoji);
60
+ for (const emoji of customEmojis) {
61
+ if (!emoji.group) {
62
+ continue;
63
+ }
64
+ customGroups[emoji.group] = customGroups[emoji.group] ?? [];
65
+ customGroups[emoji.group].push(customToRegularEmoji(emoji));
66
+ }
67
+
68
+ newData.emojis[Categories.CUSTOM] = customEmojis
69
+ .filter(emoji => !emoji.group)
70
+ .map(customToRegularEmoji);
58
71
  }
59
72
 
60
73
  const emojis = newData.emojis || {};
61
74
 
62
- const allEmojis: DataEmojis = Object.values(emojis).flat();
75
+ const allEmojis: DataEmojis = Object.values(emojis)
76
+ .concat(Object.values(customGroups))
77
+ .flat();
63
78
  const allEmojisByUnified: Record<string, DataEmoji> = {};
64
79
  const searchIndex: Record<string, Record<string, DataEmoji>> = {};
65
80
 
@@ -91,6 +106,7 @@ export function PickerDataProvider({
91
106
  allEmojis,
92
107
  allEmojisByUnified,
93
108
  searchIndex,
109
+ customGroups,
94
110
  };
95
111
  }, [genericEmojiData, customEmojis]);
96
112
 
@@ -124,7 +140,7 @@ export function usePickerDataContext() {
124
140
  }
125
141
 
126
142
  export function useGetEmojisByCategory() {
127
- const { emojiData, emojiByUnified } = usePickerDataContext();
143
+ const { emojiData, emojiByUnified, customGroups } = usePickerDataContext();
128
144
  const suggestedEmojisModeConfig = useSuggestedEmojisModeConfig();
129
145
  const [suggestedUpdated] = useUpdateSuggested();
130
146
  // Suggestions come from localStorage, which doesn't exist during SSR.
@@ -151,11 +167,18 @@ export function useGetEmojisByCategory() {
151
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
152
168
  }, [isMounted, suggestedUpdated, suggestedEmojisModeConfig, emojiByUnified]);
153
169
 
154
- return function getEmojisByCategory(category: Categories): DataEmojis {
170
+ return function getEmojisByCategory(
171
+ category: Categories,
172
+ group?: string,
173
+ ): DataEmojis {
155
174
  if (category === Categories.SUGGESTED) {
156
175
  return suggested;
157
176
  }
158
177
 
178
+ if (category === Categories.CUSTOM && group) {
179
+ return customGroups[group] ?? [];
180
+ }
181
+
159
182
  return emojiData.emojis?.[category] ?? [];
160
183
  };
161
184
  }
@@ -3,7 +3,11 @@ import { useState } from 'react';
3
3
  import { cx } from 'shipstyles';
4
4
 
5
5
  import { stylesheet } from '../../Stylesheet/stylesheet';
6
- import { categoryFromCategoryConfig } from '../../config/categoryConfig';
6
+ import {
7
+ categoryFromCategoryConfig,
8
+ categoryIdFromCategoryConfig,
9
+ customGroupFromCategoryConfig,
10
+ } from '../../config/categoryConfig';
7
11
  import {
8
12
  useCategoriesConfig,
9
13
  useCategoryIconsConfig,
@@ -16,6 +20,7 @@ import { isCustomCategory } from '../../typeRefinements/typeRefinements';
16
20
  import { Categories } from '../../types/exposedTypes';
17
21
  import { useCategoryNavigationRef } from '../context/ElementRefContext';
18
22
  import { useVisibleCategoriesState } from '../context/PickerContext';
23
+ import { usePickerDataContext } from '../context/PickerDataContext';
19
24
 
20
25
  import { CategoryButton } from './CategoryButton';
21
26
 
@@ -30,11 +35,22 @@ export function CategoryNavigation() {
30
35
  const categoryIcons = useCategoryIconsConfig();
31
36
  const CategoryNavigationRef = useCategoryNavigationRef();
32
37
  const hideCustomCategory = useShouldHideCustomEmojis();
38
+ const { customGroups, emojiData } = usePickerDataContext();
33
39
 
34
- const visibleCategories = categoriesConfig.filter(
35
- categoryConfig =>
36
- !(isCustomCategory(categoryConfig) && hideCustomCategory),
37
- );
40
+ const visibleCategories = categoriesConfig.filter(categoryConfig => {
41
+ if (isCustomCategory(categoryConfig) && hideCustomCategory) {
42
+ return false;
43
+ }
44
+ // Tabs navigating to an empty (hidden) section are dead weight.
45
+ const group = customGroupFromCategoryConfig(categoryConfig);
46
+ if (group) {
47
+ return (customGroups[group]?.length ?? 0) > 0;
48
+ }
49
+ if (isCustomCategory(categoryConfig)) {
50
+ return (emojiData.emojis?.[Categories.CUSTOM]?.length ?? 0) > 0;
51
+ }
52
+ return true;
53
+ });
38
54
 
39
55
  // A single tab navigates nowhere — hide the bar to reclaim its space.
40
56
  // https://github.com/ealush/emoji-picker-react/issues/396
@@ -52,22 +68,23 @@ export function CategoryNavigation() {
52
68
  >
53
69
  {visibleCategories.map(categoryConfig => {
54
70
  const category = categoryFromCategoryConfig(categoryConfig);
55
- const isActiveCategory = category === activeCategory;
71
+ const categoryId = categoryIdFromCategoryConfig(categoryConfig);
72
+ const isActiveCategory = categoryId === activeCategory;
56
73
 
57
74
  const allowNavigation = !isSearchMode && !isActiveCategory;
58
75
 
59
76
  return (
60
77
  <CategoryButton
61
- key={category}
78
+ key={categoryId}
62
79
  category={category}
63
80
  isActiveCategory={isActiveCategory}
64
81
  allowNavigation={allowNavigation}
65
82
  categoryConfig={categoryConfig}
66
83
  customIcon={categoryIcons[category as Categories]}
67
84
  onClick={() => {
68
- scrollCategoryIntoView(category);
85
+ scrollCategoryIntoView(categoryId);
69
86
  setTimeout(() => {
70
- setActiveCategory(category);
87
+ setActiveCategory(categoryId);
71
88
  }, 10);
72
89
  }}
73
90
  />
@@ -5,6 +5,8 @@ import {
5
5
  SuggestionMode,
6
6
  } from '../types/exposedTypes';
7
7
 
8
+ import { CustomEmoji } from './customEmojiConfig';
9
+
8
10
  export { Categories };
9
11
 
10
12
  const categoriesOrdered: Categories[] = [
@@ -28,8 +30,52 @@ export const SuggestedRecent: CategoryConfig = {
28
30
  export type CustomCategoryConfig = {
29
31
  category: Categories.CUSTOM;
30
32
  name: string;
33
+ /**
34
+ * Selects the `CustomEmoji` group rendered in this section. Each entry
35
+ * may carry its own `name` and `icon`, so multiple custom sections can
36
+ * coexist — each placed wherever its entry sits in `categories`.
37
+ * Entries without a group share the ungrouped customs bucket.
38
+ */
39
+ group?: string;
31
40
  };
32
41
 
42
+ /**
43
+ * Stable identity for a category entry. Custom groups are namespaced so
44
+ * duplicate `{ category: CUSTOM }` configs no longer collide in React
45
+ * keys, scroll targets, and active-tab detection.
46
+ */
47
+ export function categoryIdFromCategoryConfig(
48
+ categoryConfig: CategoryConfig,
49
+ ): string {
50
+ const category = categoryFromCategoryConfig(categoryConfig);
51
+ if (isCustomGroupConfig(categoryConfig)) {
52
+ return `${category}:${categoryConfig.group}`;
53
+ }
54
+ return category;
55
+ }
56
+
57
+ function isCustomGroupConfig(
58
+ categoryConfig: CategoryConfig,
59
+ ): categoryConfig is CustomCategoryConfig & { group: string } {
60
+ if (categoryConfig.category !== Categories.CUSTOM) {
61
+ return false;
62
+ }
63
+ const group = (categoryConfig as CustomCategoryConfig).group;
64
+ return typeof group === 'string' && group.length > 0;
65
+ }
66
+
67
+ /**
68
+ * The `CustomEmoji` group rendered by a category entry, if any.
69
+ */
70
+ export function customGroupFromCategoryConfig(
71
+ categoryConfig: CategoryConfig,
72
+ ): string | undefined {
73
+ if (isCustomGroupConfig(categoryConfig)) {
74
+ return categoryConfig.group;
75
+ }
76
+ return undefined;
77
+ }
78
+
33
79
  const configByCategory: Record<Categories, CategoryConfig> = {
34
80
  [Categories.SUGGESTED]: {
35
81
  category: Categories.SUGGESTED,
@@ -100,6 +146,7 @@ export function mergeCategoriesConfig(
100
146
  userCategoriesConfig: UserCategoryConfig = [],
101
147
  modifiers: CategoryConfigModifiers = {},
102
148
  emojiData?: EmojiData,
149
+ customEmojis: CustomEmoji[] = [],
103
150
  ): CategoriesConfig {
104
151
  const extra = ((): Record<Categories, CategoryConfig> => {
105
152
  // 1. Start with localized categories from emojiData
@@ -124,11 +171,56 @@ export function mergeCategoriesConfig(
124
171
 
125
172
  const base = baseCategoriesConfig(extra);
126
173
 
127
- if (!userCategoriesConfig?.length) {
128
- return base;
129
- }
174
+ const explicit: CategoriesConfig = userCategoriesConfig?.length
175
+ ? userCategoriesConfig.map(mapUserEntry)
176
+ : base;
177
+
178
+ // Groups without a `categories` entry get an appended section so their
179
+ // emojis always render somewhere — unless the user opted out of customs
180
+ // entirely by listing `categories` without CUSTOM. `categories` remains
181
+ // the override for order, display name, and icon.
182
+ // https://github.com/ealush/emoji-picker-react/issues/510
183
+ const customInPlay =
184
+ !userCategoriesConfig?.length ||
185
+ userCategoriesConfig.some(
186
+ entry =>
187
+ entry === Categories.CUSTOM ||
188
+ (typeof entry !== 'string' && entry.category === Categories.CUSTOM),
189
+ );
190
+ const listedGroups = new Set(
191
+ explicit
192
+ .map(entry => customGroupFromCategoryConfig(entry))
193
+ .filter((group): group is string => !!group),
194
+ );
195
+ const appended: CategoriesConfig = !customInPlay
196
+ ? []
197
+ : Array.from(
198
+ new Set(
199
+ customEmojis
200
+ .map(emoji => emoji.group)
201
+ .filter((group): group is string => !!group),
202
+ ),
203
+ )
204
+ .filter(group => !listedGroups.has(group))
205
+ .map(group => ({
206
+ category: Categories.CUSTOM,
207
+ name: group,
208
+ group,
209
+ }));
210
+
211
+ // Collapse duplicate entries (same identity twice renders the same
212
+ // section twice under one React key); first occurrence wins.
213
+ const seen = new Set<string>();
214
+ return explicit.concat(appended).filter(entry => {
215
+ const id = categoryIdFromCategoryConfig(entry);
216
+ if (seen.has(id)) {
217
+ return false;
218
+ }
219
+ seen.add(id);
220
+ return true;
221
+ });
130
222
 
131
- return userCategoriesConfig.map((category) => {
223
+ function mapUserEntry(category: Categories | CategoryConfig) {
132
224
  if (typeof category === 'string') {
133
225
  return getBaseConfigByCategory(category, extra[category]);
134
226
  }
@@ -137,7 +229,7 @@ export function mergeCategoriesConfig(
137
229
  ...getBaseConfigByCategory(category.category, extra[category.category]),
138
230
  ...category,
139
231
  };
140
- });
232
+ }
141
233
  }
142
234
 
143
235
  function getBaseConfigByCategory(
@@ -64,6 +64,7 @@ export function mergeConfig(
64
64
  suggestionMode: config.suggestedEmojisMode,
65
65
  },
66
66
  userConfig.emojiData,
67
+ userConfig.customEmojis,
67
68
  );
68
69
 
69
70
  config.hiddenEmojis.forEach((emoji) => {
@@ -4,4 +4,12 @@ export type CustomEmoji = {
4
4
  names: string[];
5
5
  [EmojiProperties.imgUrl]: string;
6
6
  id: string;
7
+ /**
8
+ * Optional group name. Customs sharing a group render as their own
9
+ * category section; ungrouped customs keep the single shared bucket.
10
+ * Reference the group from a `{ category: Categories.CUSTOM, group }`
11
+ * entry in the `categories` prop to place and name the section.
12
+ * https://github.com/ealush/emoji-picker-react/issues/510
13
+ */
14
+ group?: string;
7
15
  };
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { useEffect } from 'react';
3
3
 
4
+ import { detectEmojyPartiallyBelowFold } from '../DomUtils/detectEmojyPartiallyBelowFold';
4
5
  import { focusElement } from '../DomUtils/focusElement';
5
6
  import {
6
7
  allUnifiedFromEmojiElement,
@@ -107,11 +108,25 @@ export function useEmojiPreviewEvents(
107
108
  // Pulling focus out of an external element steals the caret from
108
109
  // host-app inputs (issue #320). The guard runs again inside the
109
110
  // deferred callback in case focus moved out after this event.
110
- if (!isExternalElementFocused()) {
111
+ // Partially visible buttons are never focused: focusing would make
112
+ // the browser scroll them into view, yanking the scroll position
113
+ // while the user is browsing (see also PR #509).
114
+ if (
115
+ !isExternalElementFocused() &&
116
+ !isPartiallyBelowFold(button, bodyRef)
117
+ ) {
111
118
  focusElement(button, () => !isExternalElementFocused());
112
119
  }
113
120
  }
114
121
 
122
+ function isPartiallyBelowFold(
123
+ button: HTMLButtonElement,
124
+ body: HTMLElement | null,
125
+ ): boolean {
126
+ const belowFoldByPx = detectEmojyPartiallyBelowFold(button, body);
127
+ return belowFoldByPx < button.getBoundingClientRect().height;
128
+ }
129
+
115
130
  function isExternalElementFocused(): boolean {
116
131
  const active = document.activeElement as HTMLElement | null;
117
132
 
@@ -129,5 +144,5 @@ export function useEmojiPreviewEvents(
129
144
  bodyRef?.removeEventListener('blur', onLeave, true);
130
145
  bodyRef?.removeEventListener('keydown', onEscape);
131
146
  };
132
- }, [BodyRef, allow, setPreviewEmoji, isMouseDisallowed, allowMouseMove]);
147
+ }, [BodyRef, PickerMainRef, allow, setPreviewEmoji, isMouseDisallowed, allowMouseMove]);
133
148
  }
@@ -13,8 +13,9 @@ export function useScrollCategoryIntoView() {
13
13
  if (!BodyRef.current) {
14
14
  return;
15
15
  }
16
+ // Group names are user-controlled; escape for the attribute selector.
16
17
  const $category = BodyRef.current?.querySelector(
17
- `[data-name="${category}"]`,
18
+ `[data-name="${CSS.escape(category)}"]`,
18
19
  ) as NullableElement;
19
20
 
20
21
  if (!$category) {
@@ -15,6 +15,12 @@ export type CategoryConfig = {
15
15
  category: Categories;
16
16
  name: string;
17
17
  icon?: React.ReactNode;
18
+ /**
19
+ * Selects a `CustomEmoji` group for `Categories.CUSTOM` entries. Each
20
+ * grouped entry renders its own section and nav tab; ungrouped entries
21
+ * share the classic customs bucket.
22
+ */
23
+ group?: string;
18
24
  };
19
25
 
20
26
  export type CategoryIcons = Partial<Record<Categories, React.ReactNode>>;