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.
- 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 +151 -30
- 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 +151 -30
- package/dist/emoji-picker-react.esm.js.map +1 -1
- package/dist/types/exposedTypes.d.ts +6 -0
- package/llms.txt +31 -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/PickerDataContext.tsx +28 -5
- package/src/components/navigation/CategoryNavigation.tsx +26 -9
- package/src/config/categoryConfig.ts +97 -5
- package/src/config/config.ts +1 -0
- package/src/config/customEmojiConfig.ts +8 -0
- package/src/hooks/useEmojiPreviewEvents.ts +17 -2
- package/src/hooks/useScrollCategoryIntoView.ts +2 -1
- package/src/types/exposedTypes.ts +6 -0
|
@@ -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
|
@@ -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) {
|
|
@@ -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
|
-
|
|
57
|
-
|
|
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)
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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={
|
|
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(
|
|
85
|
+
scrollCategoryIntoView(categoryId);
|
|
69
86
|
setTimeout(() => {
|
|
70
|
-
setActiveCategory(
|
|
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
|
-
|
|
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(
|
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
|
};
|
|
@@ -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
|
-
|
|
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>>;
|