emoji-picker-react 4.21.1 → 4.22.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.
@@ -1,4 +1,11 @@
1
- export declare function useActiveCategoryScrollDetection({ setActiveCategory, setVisibleCategories, }: {
1
+ export declare function useActiveCategoryScrollDetection({ setActiveCategory, setVisibleCategories, categoryIdentitiesKey, }: {
2
2
  setActiveCategory: (category: string) => void;
3
3
  setVisibleCategories: (categories: string[]) => void;
4
+ /**
5
+ * Stable string derived from the rendered category identities
6
+ * (e.g. ids joined). Re-subscribes the observer when sections are
7
+ * added or removed so new elements are observed and detached ones
8
+ * stop contributing; unrelated renders keep the same key.
9
+ */
10
+ categoryIdentitiesKey: string;
4
11
  }): void;
@@ -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,42 @@ 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
+ ### Updating groups at runtime
240
+
241
+ `categories` and `customEmojis` are immutable inputs: replacing either
242
+ array (even with a same-length array) rebuilds sections, tabs, search,
243
+ and lookup. Mutating an array in place is not detected — always provide
244
+ a new reference. Group strings are exact, case-sensitive identifiers.
245
+
246
+ ```tsx
247
+ import EmojiPicker, { Categories } from 'emoji-picker-react';
248
+
249
+ <EmojiPicker
250
+ customEmojis={[
251
+ { id: 'panda', names: ['panda'], imgUrl: pandaPng, group: 'animals' },
252
+ { id: 'ninja', names: ['ninja'], imgUrl: ninjaPng, group: 'people' },
253
+ { id: 'orphan', names: ['orphan'], imgUrl: orphanPng },
254
+ ]}
255
+ categories={[
256
+ Categories.SMILEYS_PEOPLE,
257
+ { category: Categories.CUSTOM, group: 'animals', name: 'Animals' },
258
+ { category: Categories.CUSTOM, group: 'people', name: 'People' },
259
+ { category: Categories.CUSTOM, name: 'Misc' },
260
+ ]}
261
+ />;
262
+ ```
263
+
227
264
  ## Preview Bar
228
265
 
229
266
  Control the footer preview area with `previewConfig`:
@@ -300,7 +337,7 @@ import EmojiPicker, { Categories } from 'emoji-picker-react';
300
337
  />;
301
338
  ```
302
339
 
303
- Note: if both methods are used for the same category, the icon from the `categories` configuration takes precedence over the `categoryIcons` prop.
340
+ 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
341
 
305
342
  ## Content Security Policy (CSP)
306
343
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.21.1",
2
+ "version": "4.22.1",
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) {
@@ -39,7 +39,11 @@ export function useSetConfig(config: PickerConfig) {
39
39
  // not gonna...
40
40
  // eslint-disable-next-line react-hooks/exhaustive-deps
41
41
  }, [
42
- config.customEmojis?.length,
42
+ // New references rebuild merged categories and data even at equal
43
+ // length; compareConfig above still gates no-op updates so inline
44
+ // literals don't loop.
45
+ config.categories,
46
+ config.customEmojis,
43
47
  config.open,
44
48
  config.emojiVersion,
45
49
  config.reactionsDefaultOpen,
@@ -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
  }
@@ -33,8 +34,9 @@ export interface PickerDataContextValue {
33
34
  const PickerDataContext = React.createContext<PickerDataContextValue>({
34
35
  emojiData: {} as EmojiData,
35
36
  allEmojis: [],
36
- allEmojisByUnified: {},
37
- searchIndex: {},
37
+ allEmojisByUnified: Object.create(null),
38
+ searchIndex: Object.create(null),
39
+ customGroups: Object.create(null),
38
40
  emojiByUnified,
39
41
  activeVariationFromUnified: () => null,
40
42
  });
@@ -52,16 +54,34 @@ 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
+ // Group names and emoji ids are user-controlled strings: use
58
+ // null-prototype dictionaries so names like `__proto__` behave as
59
+ // ordinary data instead of resolving Object.prototype members.
60
+ const customGroups: Record<string, DataEmojis> = Object.create(null);
61
+
55
62
  if (customEmojis && customEmojis.length > 0) {
56
- newData.emojis[Categories.CUSTOM] =
57
- customEmojis.map(customToRegularEmoji);
63
+ for (const emoji of customEmojis) {
64
+ if (!emoji.group) {
65
+ continue;
66
+ }
67
+ customGroups[emoji.group] = customGroups[emoji.group] ?? [];
68
+ customGroups[emoji.group].push(customToRegularEmoji(emoji));
69
+ }
70
+
71
+ newData.emojis[Categories.CUSTOM] = customEmojis
72
+ .filter(emoji => !emoji.group)
73
+ .map(customToRegularEmoji);
58
74
  }
59
75
 
60
76
  const emojis = newData.emojis || {};
61
77
 
62
- const allEmojis: DataEmojis = Object.values(emojis).flat();
63
- const allEmojisByUnified: Record<string, DataEmoji> = {};
64
- const searchIndex: Record<string, Record<string, DataEmoji>> = {};
78
+ const allEmojis: DataEmojis = Object.values(emojis)
79
+ .concat(Object.values(customGroups))
80
+ .flat();
81
+ const allEmojisByUnified: Record<string, DataEmoji> =
82
+ Object.create(null);
83
+ const searchIndex: Record<string, Record<string, DataEmoji>> =
84
+ Object.create(null);
65
85
 
66
86
  allEmojis.forEach((emoji) => {
67
87
  const unified = emoji[Keys.unified];
@@ -81,7 +101,7 @@ export function PickerDataProvider({
81
101
  .split('');
82
102
 
83
103
  joinedNameString.forEach((char: string) => {
84
- searchIndex[char] = searchIndex[char] ?? {};
104
+ searchIndex[char] = searchIndex[char] ?? Object.create(null);
85
105
  searchIndex[char][unified] = emoji;
86
106
  });
87
107
  });
@@ -91,6 +111,7 @@ export function PickerDataProvider({
91
111
  allEmojis,
92
112
  allEmojisByUnified,
93
113
  searchIndex,
114
+ customGroups,
94
115
  };
95
116
  }, [genericEmojiData, customEmojis]);
96
117
 
@@ -124,7 +145,7 @@ export function usePickerDataContext() {
124
145
  }
125
146
 
126
147
  export function useGetEmojisByCategory() {
127
- const { emojiData, emojiByUnified } = usePickerDataContext();
148
+ const { emojiData, emojiByUnified, customGroups } = usePickerDataContext();
128
149
  const suggestedEmojisModeConfig = useSuggestedEmojisModeConfig();
129
150
  const [suggestedUpdated] = useUpdateSuggested();
130
151
  // Suggestions come from localStorage, which doesn't exist during SSR.
@@ -151,11 +172,18 @@ export function useGetEmojisByCategory() {
151
172
  // eslint-disable-next-line react-hooks/exhaustive-deps
152
173
  }, [isMounted, suggestedUpdated, suggestedEmojisModeConfig, emojiByUnified]);
153
174
 
154
- return function getEmojisByCategory(category: Categories): DataEmojis {
175
+ return function getEmojisByCategory(
176
+ category: Categories,
177
+ group?: string,
178
+ ): DataEmojis {
155
179
  if (category === Categories.SUGGESTED) {
156
180
  return suggested;
157
181
  }
158
182
 
183
+ if (category === Categories.CUSTOM && group) {
184
+ return customGroups[group] ?? [];
185
+ }
186
+
159
187
  return emojiData.emojis?.[category] ?? [];
160
188
  };
161
189
  }
@@ -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
 
@@ -23,18 +28,40 @@ export function CategoryNavigation() {
23
28
  const [activeCategory, setActiveCategory] = useState<string | null>(null);
24
29
  const [, setVisibleCategories] = useVisibleCategoriesState();
25
30
  const scrollCategoryIntoView = useScrollCategoryIntoView();
26
- useActiveCategoryScrollDetection({ setActiveCategory, setVisibleCategories });
27
31
  const isSearchMode = useIsSearchMode();
28
32
 
29
33
  const categoriesConfig = useCategoriesConfig();
30
34
  const categoryIcons = useCategoryIconsConfig();
31
35
  const CategoryNavigationRef = useCategoryNavigationRef();
32
36
  const hideCustomCategory = useShouldHideCustomEmojis();
37
+ const { customGroups, emojiData } = usePickerDataContext();
33
38
 
34
- const visibleCategories = categoriesConfig.filter(
35
- categoryConfig =>
36
- !(isCustomCategory(categoryConfig) && hideCustomCategory),
37
- );
39
+ // Rendered section identities. Passed to the scroll-detection hook so
40
+ // added/removed sections are (re-)subscribed; stable across renders
41
+ // unless the effective category list changes.
42
+ const categoryIdentitiesKey = categoriesConfig
43
+ .map(entry => categoryIdFromCategoryConfig(entry))
44
+ .join('|');
45
+ useActiveCategoryScrollDetection({
46
+ setActiveCategory,
47
+ setVisibleCategories,
48
+ categoryIdentitiesKey,
49
+ });
50
+
51
+ const visibleCategories = categoriesConfig.filter(categoryConfig => {
52
+ if (isCustomCategory(categoryConfig) && hideCustomCategory) {
53
+ return false;
54
+ }
55
+ // Tabs navigating to an empty (hidden) section are dead weight.
56
+ const group = customGroupFromCategoryConfig(categoryConfig);
57
+ if (group) {
58
+ return (customGroups[group]?.length ?? 0) > 0;
59
+ }
60
+ if (isCustomCategory(categoryConfig)) {
61
+ return (emojiData.emojis?.[Categories.CUSTOM]?.length ?? 0) > 0;
62
+ }
63
+ return true;
64
+ });
38
65
 
39
66
  // A single tab navigates nowhere — hide the bar to reclaim its space.
40
67
  // https://github.com/ealush/emoji-picker-react/issues/396
@@ -52,22 +79,23 @@ export function CategoryNavigation() {
52
79
  >
53
80
  {visibleCategories.map(categoryConfig => {
54
81
  const category = categoryFromCategoryConfig(categoryConfig);
55
- const isActiveCategory = category === activeCategory;
82
+ const categoryId = categoryIdFromCategoryConfig(categoryConfig);
83
+ const isActiveCategory = categoryId === activeCategory;
56
84
 
57
85
  const allowNavigation = !isSearchMode && !isActiveCategory;
58
86
 
59
87
  return (
60
88
  <CategoryButton
61
- key={category}
89
+ key={categoryId}
62
90
  category={category}
63
91
  isActiveCategory={isActiveCategory}
64
92
  allowNavigation={allowNavigation}
65
93
  categoryConfig={categoryConfig}
66
94
  customIcon={categoryIcons[category as Categories]}
67
95
  onClick={() => {
68
- scrollCategoryIntoView(category);
96
+ scrollCategoryIntoView(categoryId);
69
97
  setTimeout(() => {
70
- setActiveCategory(category);
98
+ setActiveCategory(categoryId);
71
99
  }, 10);
72
100
  }}
73
101
  />
@@ -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(
@@ -1,4 +1,6 @@
1
+ import { UserCategoryConfig } from './categoryConfig';
1
2
  import { PickerConfig } from './config';
3
+ import { CustomEmoji } from './customEmojiConfig';
2
4
 
3
5
  // eslint-disable-next-line complexity
4
6
  export function compareConfig(prev: PickerConfig, next: PickerConfig) {
@@ -25,6 +27,35 @@ export function compareConfig(prev: PickerConfig, next: PickerConfig) {
25
27
  prev.searchDisabled === next.searchDisabled &&
26
28
  prev.skinTonePickerLocation === next.skinTonePickerLocation &&
27
29
  prevCustomEmojis.length === nextCustomEmojis.length &&
30
+ customEmojisKey(prevCustomEmojis) === customEmojisKey(nextCustomEmojis) &&
31
+ categoriesKey(prev.categories) === categoriesKey(next.categories) &&
28
32
  prev.emojiData === next.emojiData
29
33
  );
30
34
  }
35
+
36
+ /**
37
+ * Content identity for custom emojis. Length alone misses regroups and
38
+ * swaps, which would leave merged categories, search, and sections stale.
39
+ */
40
+ function customEmojisKey(customEmojis: CustomEmoji[]): string {
41
+ return customEmojis
42
+ .map(
43
+ emoji =>
44
+ `${emoji.id}:${emoji.group ?? ''}:${emoji.names.join(',')}:${emoji.imgUrl}`,
45
+ )
46
+ .join('|');
47
+ }
48
+
49
+ /**
50
+ * Content identity for the categories input. Order, labels, icons, and
51
+ * group selection all flow into the merged configuration.
52
+ */
53
+ function categoriesKey(categories: UserCategoryConfig | undefined): string {
54
+ return (categories ?? [])
55
+ .map(entry =>
56
+ typeof entry === 'string'
57
+ ? entry
58
+ : `${entry.category}:${entry.name}:${entry.group ?? ''}:${entry.icon ? '1' : ''}`,
59
+ )
60
+ .join('|');
61
+ }
@@ -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
  };
@@ -7,9 +7,17 @@ import { useBodyRef } from '../components/context/ElementRefContext';
7
7
  export function useActiveCategoryScrollDetection({
8
8
  setActiveCategory,
9
9
  setVisibleCategories,
10
+ categoryIdentitiesKey,
10
11
  }: {
11
12
  setActiveCategory: (category: string) => void;
12
13
  setVisibleCategories: (categories: string[]) => void;
14
+ /**
15
+ * Stable string derived from the rendered category identities
16
+ * (e.g. ids joined). Re-subscribes the observer when sections are
17
+ * added or removed so new elements are observed and detached ones
18
+ * stop contributing; unrelated renders keep the same key.
19
+ */
20
+ categoryIdentitiesKey: string;
13
21
  }) {
14
22
  const BodyRef = useBodyRef();
15
23
 
@@ -67,5 +75,5 @@ export function useActiveCategoryScrollDetection({
67
75
  return () => {
68
76
  observer.disconnect();
69
77
  };
70
- }, [BodyRef, setActiveCategory, setVisibleCategories]);
78
+ }, [BodyRef, categoryIdentitiesKey, setActiveCategory, setVisibleCategories]);
71
79
  }
@@ -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>>;