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.
- package/dist/components/context/PickerDataContext.d.ts +2 -1
- package/dist/config/categoryConfig.d.ts +19 -1
- package/dist/config/customEmojiConfig.d.ts +8 -0
- package/dist/emoji-picker-react.cjs.development.js +180 -42
- package/dist/emoji-picker-react.cjs.development.js.map +1 -1
- package/dist/emoji-picker-react.cjs.production.min.js +1 -1
- package/dist/emoji-picker-react.cjs.production.min.js.map +1 -1
- package/dist/emoji-picker-react.esm.js +180 -42
- package/dist/emoji-picker-react.esm.js.map +1 -1
- package/dist/hooks/useActiveCategoryScrollDetection.d.ts +8 -1
- package/dist/types/exposedTypes.d.ts +6 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/src/components/body/EmojiCategory.tsx +2 -3
- package/src/components/body/EmojiList.tsx +16 -7
- package/src/components/body/MeasureEmoji.tsx +10 -4
- package/src/components/context/PickerConfigContext.tsx +5 -1
- package/src/components/context/PickerDataContext.tsx +38 -10
- package/src/components/navigation/CategoryNavigation.tsx +38 -10
- package/src/config/categoryConfig.ts +97 -5
- package/src/config/compareConfig.ts +31 -0
- package/src/config/config.ts +1 -0
- package/src/config/customEmojiConfig.ts +8 -0
- package/src/hooks/useActiveCategoryScrollDetection.ts +9 -1
- package/src/hooks/useScrollCategoryIntoView.ts +2 -1
- package/src/types/exposedTypes.ts +6 -0
|
@@ -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
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
stylesheet,
|
|
9
9
|
} from '../../Stylesheet/stylesheet';
|
|
10
10
|
import {
|
|
11
|
-
|
|
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={
|
|
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 {
|
|
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[
|
|
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={
|
|
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[
|
|
65
|
+
if (categoryHeights[categoryId] !== height) {
|
|
60
66
|
setCategoryHeights((prev) => ({
|
|
61
67
|
...prev,
|
|
62
|
-
[
|
|
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 ||
|
|
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 {
|
|
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 =
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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)
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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={
|
|
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(
|
|
96
|
+
scrollCategoryIntoView(categoryId);
|
|
69
97
|
setTimeout(() => {
|
|
70
|
-
setActiveCategory(
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/config/config.ts
CHANGED
|
@@ -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>>;
|