@fibery/ui-kit 1.23.0 → 1.24.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/package.json +2 -1
- package/src/emoji-picker/app-icon-picker.tsx +21 -15
- package/src/emoji-picker/emoji-picker-content-with-color.tsx +9 -11
- package/src/emoji-picker/emoji-picker-preview-footer.tsx +94 -0
- package/src/emoji-picker/emoji-picker.tsx +3 -28
- package/src/emoji-picker/primitives/category-label.tsx +42 -0
- package/src/emoji-picker/primitives/content.tsx +15 -3
- package/src/emoji-picker/primitives/emoji.tsx +2 -0
- package/src/emoji-picker/primitives/footer.tsx +4 -2
- package/src/emoji-picker/primitives/grid-item.tsx +73 -0
- package/src/emoji-picker/primitives/grid.tsx +331 -77
- package/src/emoji-picker/primitives/preview-provider.tsx +27 -0
- package/src/emoji-picker/primitives/root.tsx +8 -5
- package/src/emoji-picker/primitives/search.tsx +50 -6
- package/src/select/select-in-popover.tsx +14 -12
- package/src/emoji-picker/primitives/category.tsx +0 -177
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fibery/ui-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.1",
|
|
4
4
|
"main": "index.ts",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"react-popper": "2.3.0",
|
|
67
67
|
"react-select": "5.3.2",
|
|
68
68
|
"react-select-country-list": "2.2.1",
|
|
69
|
+
"react-virtuoso": "4.6.0",
|
|
69
70
|
"react-windowed-select": "5.0.0",
|
|
70
71
|
"screenfull": "6.0.1",
|
|
71
72
|
"ua-parser-js": "0.7.24",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {CustomEmoji, EmojiItem} from "@fibery/emoji-data";
|
|
2
|
-
import {
|
|
2
|
+
import {useCallback} from "react";
|
|
3
3
|
import {cardTypeColors, getIconColor} from "../design-system";
|
|
4
|
+
import {useThemeMode} from "../theme-provider";
|
|
4
5
|
import {$TSFixMe} from "../tsfixme";
|
|
5
|
-
import {EmojiPickerContentWithColor} from "./emoji-picker-content-with-color";
|
|
6
|
+
import {EmojiPickerContentWithColor, EmojiPickerContentWithColorProps} from "./emoji-picker-content-with-color";
|
|
6
7
|
import {EmojiPickerContent} from "./primitives/content";
|
|
7
8
|
import {EmojiPickerRoot} from "./primitives/root";
|
|
8
9
|
import {LazyIconDataStore} from "./stores/lazy-icon-data-store";
|
|
@@ -28,6 +29,23 @@ export const AppIconPicker: React.FC<AppIconPickerProps> = ({
|
|
|
28
29
|
}) => {
|
|
29
30
|
const themeMode = useThemeMode();
|
|
30
31
|
|
|
32
|
+
const renderEmoji = useCallback<NonNullable<EmojiPickerContentWithColorProps["renderEmoji"]>>(
|
|
33
|
+
({emoji}) => (
|
|
34
|
+
<svg
|
|
35
|
+
style={{
|
|
36
|
+
display: "block",
|
|
37
|
+
width: 20,
|
|
38
|
+
height: 20,
|
|
39
|
+
fill: color ? getIconColor(themeMode, color) : undefined,
|
|
40
|
+
}}
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
>
|
|
43
|
+
<use href={(emoji as CustomEmoji).imageUrl} />
|
|
44
|
+
</svg>
|
|
45
|
+
),
|
|
46
|
+
[color, themeMode]
|
|
47
|
+
);
|
|
48
|
+
|
|
31
49
|
return (
|
|
32
50
|
<EmojiPickerRoot i18n={i18n} onEmojiSelect={onSelect}>
|
|
33
51
|
<EmojiPickerContent emojiHoverColor={color ? getHoverColor?.(color) : undefined} emojiSize={20}>
|
|
@@ -38,19 +56,7 @@ export const AppIconPicker: React.FC<AppIconPickerProps> = ({
|
|
|
38
56
|
getHoverColor={getHoverColor}
|
|
39
57
|
onColorSelect={onColorSelect}
|
|
40
58
|
searchAutofocus={autoFocus}
|
|
41
|
-
renderEmoji={
|
|
42
|
-
<svg
|
|
43
|
-
style={{
|
|
44
|
-
display: "block",
|
|
45
|
-
width: 20,
|
|
46
|
-
height: 20,
|
|
47
|
-
fill: color ? getIconColor(themeMode, color) : undefined,
|
|
48
|
-
}}
|
|
49
|
-
viewBox="0 0 24 24"
|
|
50
|
-
>
|
|
51
|
-
<use href={(emoji as CustomEmoji).imageUrl} />
|
|
52
|
-
</svg>
|
|
53
|
-
)}
|
|
59
|
+
renderEmoji={renderEmoji}
|
|
54
60
|
/>
|
|
55
61
|
</LazyIconDataStore>
|
|
56
62
|
</EmojiPickerContent>
|
|
@@ -1,26 +1,22 @@
|
|
|
1
|
-
import {css
|
|
2
|
-
import {useState} from "react";
|
|
1
|
+
import {css} from "@linaria/core";
|
|
2
|
+
import {useState, useRef} from "react";
|
|
3
3
|
import {IconButton} from "../button/icon-button";
|
|
4
4
|
import {CollapsibleContent, CollapsibleRoot, CollapsibleTrigger} from "../collapsible";
|
|
5
5
|
import {ColorPicker} from "../color-picker";
|
|
6
|
-
import {useThemeMode} from "../theme-provider";
|
|
7
6
|
import {getIconColor, space} from "../design-system";
|
|
8
7
|
import ColorCoding from "../icons/react/ColorCoding";
|
|
9
8
|
import ColorCodingFilled from "../icons/react/ColorCodingFilled";
|
|
9
|
+
import {useThemeMode} from "../theme-provider";
|
|
10
|
+
import {Tooltip} from "../tooltip";
|
|
10
11
|
import {$TSFixMe} from "../tsfixme";
|
|
11
|
-
import {EmojiPickerGrid, EmojiPickerGridProps} from "./primitives/grid";
|
|
12
|
+
import {EmojiPickerGrid, type EmojiPickerGridHandle, EmojiPickerGridProps} from "./primitives/grid";
|
|
12
13
|
import {EmojiPickerHeader} from "./primitives/header";
|
|
13
14
|
import {EmojiPickerSearch} from "./primitives/search";
|
|
14
|
-
import {Tooltip} from "../tooltip";
|
|
15
15
|
|
|
16
16
|
const colorPickerWrapperCss = css`
|
|
17
17
|
padding-top: ${space.s12}px;
|
|
18
18
|
`;
|
|
19
19
|
|
|
20
|
-
const disableScrollCss = css`
|
|
21
|
-
overflow-y: hidden;
|
|
22
|
-
`;
|
|
23
|
-
|
|
24
20
|
type ColorPickerCollapsibleTrigger = {opened: boolean; color?: string};
|
|
25
21
|
|
|
26
22
|
const ColorPickerCollapsibleTrigger: React.FC<ColorPickerCollapsibleTrigger> = ({opened, color}) => {
|
|
@@ -50,7 +46,7 @@ const ColorPickerCollapsibleTrigger: React.FC<ColorPickerCollapsibleTrigger> = (
|
|
|
50
46
|
};
|
|
51
47
|
|
|
52
48
|
const collapsibleWrapperCss = css`
|
|
53
|
-
max-height:
|
|
49
|
+
max-height: 308px; // search + emoji grid
|
|
54
50
|
overflow: hidden;
|
|
55
51
|
`;
|
|
56
52
|
|
|
@@ -74,6 +70,7 @@ export const EmojiPickerContentWithColor: React.FC<EmojiPickerContentWithColorPr
|
|
|
74
70
|
renderEmoji,
|
|
75
71
|
}) => {
|
|
76
72
|
const [colorOpened, setColorOpened] = useState(false);
|
|
73
|
+
const gridRef = useRef<EmojiPickerGridHandle>(null);
|
|
77
74
|
|
|
78
75
|
const showColorPicker = Boolean(onColorSelect);
|
|
79
76
|
|
|
@@ -87,6 +84,7 @@ export const EmojiPickerContentWithColor: React.FC<EmojiPickerContentWithColorPr
|
|
|
87
84
|
<EmojiPickerHeader>
|
|
88
85
|
<EmojiPickerSearch
|
|
89
86
|
searchRef={searchRef}
|
|
87
|
+
gridRef={gridRef}
|
|
90
88
|
autoFocus={searchAutofocus}
|
|
91
89
|
extraAction={showColorPicker ? <ColorPickerCollapsibleTrigger opened={colorOpened} color={color} /> : null}
|
|
92
90
|
/>
|
|
@@ -104,7 +102,7 @@ export const EmojiPickerContentWithColor: React.FC<EmojiPickerContentWithColorPr
|
|
|
104
102
|
) : null}
|
|
105
103
|
</EmojiPickerHeader>
|
|
106
104
|
|
|
107
|
-
<EmojiPickerGrid
|
|
105
|
+
<EmojiPickerGrid ref={gridRef} disableScroll={colorOpened} renderEmoji={renderEmoji} />
|
|
108
106
|
</CollapsibleRoot>
|
|
109
107
|
);
|
|
110
108
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {EmojiItem} from "@fibery/emoji-data";
|
|
2
|
+
import {measureScrollbar} from "@fibery/helpers/utils/measure-scrollbar";
|
|
3
|
+
import {css} from "@linaria/core";
|
|
4
|
+
import {Button} from "../button";
|
|
5
|
+
import {fontFamily, space, themeVars, typeSizes} from "../design-system";
|
|
6
|
+
import {Emoji} from "./primitives/emoji";
|
|
7
|
+
import {EmojiPickerFooter} from "./primitives/footer";
|
|
8
|
+
import {useEmojiPreview} from "./primitives/preview-provider";
|
|
9
|
+
import {useEmojiPickerCtx} from "./primitives/root";
|
|
10
|
+
import {useEmojiSkin} from "./primitives/skin-provider";
|
|
11
|
+
import {EmojiPickerSkinTone} from "./primitives/skin-tone";
|
|
12
|
+
|
|
13
|
+
const previewCss = css`
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: ${space.s8}px;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
`;
|
|
19
|
+
const previewTitleWrapperCss = css`
|
|
20
|
+
max-width: 230px;
|
|
21
|
+
`;
|
|
22
|
+
const previewTitleCss = css`
|
|
23
|
+
font-family: ${fontFamily};
|
|
24
|
+
font-size: ${typeSizes[5]}px;
|
|
25
|
+
line-height: 1.25;
|
|
26
|
+
color: ${themeVars.textColor};
|
|
27
|
+
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
text-overflow: ellipsis;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
`;
|
|
32
|
+
const previewSubtitleCss = css`
|
|
33
|
+
font-family: ${fontFamily};
|
|
34
|
+
letter-spacing: 0.5px;
|
|
35
|
+
font-size: ${typeSizes[6]}px;
|
|
36
|
+
line-height: 1.25;
|
|
37
|
+
color: ${themeVars.accentTextColor};
|
|
38
|
+
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
text-overflow: ellipsis;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const EmojiPreview: React.FC<{emoji: EmojiItem}> = ({emoji}) => {
|
|
45
|
+
const emojiPickerCtx = useEmojiPickerCtx();
|
|
46
|
+
const skin = useEmojiSkin();
|
|
47
|
+
|
|
48
|
+
const title = emoji.name;
|
|
49
|
+
const subTitle = emoji.shortNames.map((short) => `:${short}:`).join(" ");
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={previewCss}>
|
|
53
|
+
<Emoji emoji={emoji.id} size={24} version={emojiPickerCtx.version} skin={skin} native={emojiPickerCtx.native} />
|
|
54
|
+
<div className={previewTitleWrapperCss}>
|
|
55
|
+
<div className={previewTitleCss}>{title}</div>
|
|
56
|
+
<div className={previewSubtitleCss}>{subTitle}</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const EmojiPickerPreviewFooter: React.FC<{
|
|
63
|
+
onAddCustomEmoji?: () => void;
|
|
64
|
+
showSkinTones?: boolean;
|
|
65
|
+
}> = ({onAddCustomEmoji, showSkinTones}) => {
|
|
66
|
+
const emojiPreview = useEmojiPreview();
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<EmojiPickerFooter>
|
|
70
|
+
{emojiPreview ? (
|
|
71
|
+
<EmojiPreview emoji={emojiPreview.emoji} />
|
|
72
|
+
) : onAddCustomEmoji ? (
|
|
73
|
+
<Button size=":button-size/small" onClick={onAddCustomEmoji}>
|
|
74
|
+
Add Emoji
|
|
75
|
+
</Button>
|
|
76
|
+
) : null}
|
|
77
|
+
|
|
78
|
+
{showSkinTones ? (
|
|
79
|
+
<div
|
|
80
|
+
className={css`
|
|
81
|
+
position: absolute;
|
|
82
|
+
top: 4px;
|
|
83
|
+
right: ${space.s12}px;
|
|
84
|
+
`}
|
|
85
|
+
style={{
|
|
86
|
+
right: space.s12 + measureScrollbar(),
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<EmojiPickerSkinTone />
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
</EmojiPickerFooter>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import {EmojiItem} from "@fibery/emoji-data";
|
|
2
|
-
import {
|
|
3
|
-
import {Button} from "../button";
|
|
4
|
-
import {cardTypeColors, space} from "../design-system";
|
|
2
|
+
import {cardTypeColors} from "../design-system";
|
|
5
3
|
import {$TSFixMe} from "../tsfixme";
|
|
6
4
|
import {EmojiPickerContentWithColor} from "./emoji-picker-content-with-color";
|
|
5
|
+
import {EmojiPickerPreviewFooter} from "./emoji-picker-preview-footer";
|
|
7
6
|
import {EmojiPickerContent} from "./primitives/content";
|
|
8
|
-
import {EmojiPickerFooter} from "./primitives/footer";
|
|
9
7
|
import {EmojiPickerRoot} from "./primitives/root";
|
|
10
|
-
import {EmojiPickerSkinTone} from "./primitives/skin-tone";
|
|
11
|
-
import {measureScrollbar} from "@fibery/helpers/utils/measure-scrollbar";
|
|
12
8
|
|
|
13
9
|
const i18n = {search: "Search emoji..."};
|
|
14
10
|
|
|
@@ -49,28 +45,7 @@ export const EmojiPicker: React.FC<EmojiPickerProps> = ({
|
|
|
49
45
|
/>
|
|
50
46
|
|
|
51
47
|
{showFooter ? (
|
|
52
|
-
<
|
|
53
|
-
{onAddCustomEmoji ? (
|
|
54
|
-
<Button size=":button-size/small" onClick={onAddCustomEmoji}>
|
|
55
|
-
Add Emoji
|
|
56
|
-
</Button>
|
|
57
|
-
) : null}
|
|
58
|
-
|
|
59
|
-
{showSkinTones ? (
|
|
60
|
-
<div
|
|
61
|
-
className={css`
|
|
62
|
-
position: absolute;
|
|
63
|
-
top: 4px;
|
|
64
|
-
right: ${space.s12}px;
|
|
65
|
-
`}
|
|
66
|
-
style={{
|
|
67
|
-
right: space.s12 + measureScrollbar(),
|
|
68
|
-
}}
|
|
69
|
-
>
|
|
70
|
-
<EmojiPickerSkinTone />
|
|
71
|
-
</div>
|
|
72
|
-
) : null}
|
|
73
|
-
</EmojiPickerFooter>
|
|
48
|
+
<EmojiPickerPreviewFooter onAddCustomEmoji={onAddCustomEmoji} showSkinTones={showSkinTones} />
|
|
74
49
|
) : null}
|
|
75
50
|
</EmojiPickerContent>
|
|
76
51
|
</EmojiPickerRoot>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {Category} from "@fibery/emoji-data";
|
|
2
|
+
|
|
3
|
+
import {css} from "@linaria/core";
|
|
4
|
+
import {space, textStyles, themeVars} from "../../design-system";
|
|
5
|
+
import {useEmojiPickerCtx, useEmojiPickerI18N} from "./root";
|
|
6
|
+
import {IconButton} from "../../button/icon-button";
|
|
7
|
+
import Settings from "../../icons/react/Settings";
|
|
8
|
+
|
|
9
|
+
const categoryLabelCss = css`
|
|
10
|
+
${textStyles.heading6}
|
|
11
|
+
/* in designs it's 31px but we make it equal to emoji size just to make virtualization easier */
|
|
12
|
+
height: var(--fibery-emoji-container-size, 32px);
|
|
13
|
+
color: ${themeVars.accentTextColor};
|
|
14
|
+
background-color: ${themeVars.actionMenuBg};
|
|
15
|
+
padding: ${space.s12}px ${space.s6}px ${space.s4}px ${space.s6}px;
|
|
16
|
+
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: ${space.s4}px;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
type EmojiPickerCategoryLabelProps = {
|
|
23
|
+
category: Category;
|
|
24
|
+
};
|
|
25
|
+
export const EmojiPickerCategoryLabel: React.FC<EmojiPickerCategoryLabelProps> = ({category}) => {
|
|
26
|
+
const i18n = useEmojiPickerI18N();
|
|
27
|
+
const emojiPickerCtx = useEmojiPickerCtx();
|
|
28
|
+
|
|
29
|
+
const label = i18n.categories[category.id as keyof (typeof i18n)["categories"]] || category.name;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={categoryLabelCss}>
|
|
33
|
+
<span>{label}</span>
|
|
34
|
+
|
|
35
|
+
{category.id === "custom" && emojiPickerCtx.onCustomCategorySettings && (
|
|
36
|
+
<IconButton onClick={emojiPickerCtx.onCustomCategorySettings}>
|
|
37
|
+
<Settings iconSize={16} />
|
|
38
|
+
</IconButton>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
@@ -12,6 +12,7 @@ const emojiPickerCss = css`
|
|
|
12
12
|
type EmojiPickerContentSettings = {
|
|
13
13
|
perLine: number;
|
|
14
14
|
emojiSize: number;
|
|
15
|
+
emojiContainerSize: number;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
const [Provider, useCtx] = createContext<EmojiPickerContentSettings>("EmojiPickerContentSettings");
|
|
@@ -28,18 +29,29 @@ export const EmojiPickerContent: React.FC<React.PropsWithChildren<EmojiPickerCon
|
|
|
28
29
|
children,
|
|
29
30
|
}) => {
|
|
30
31
|
const width = perLine * (emojiSize + emojiPadding * 2) + contentHorizontalPadding * 2 + measureScrollbar();
|
|
32
|
+
const emojiContainerSize = emojiSize + emojiPadding * 2;
|
|
31
33
|
|
|
32
|
-
const contentSettings = useMemo(
|
|
34
|
+
const contentSettings = useMemo<EmojiPickerContentSettings>(
|
|
33
35
|
() => ({
|
|
36
|
+
emojiContainerSize,
|
|
34
37
|
emojiSize,
|
|
35
38
|
perLine,
|
|
36
39
|
}),
|
|
37
|
-
[emojiSize, perLine]
|
|
40
|
+
[emojiContainerSize, emojiSize, perLine]
|
|
38
41
|
);
|
|
39
42
|
|
|
40
43
|
return (
|
|
41
44
|
<Provider value={contentSettings}>
|
|
42
|
-
<div
|
|
45
|
+
<div
|
|
46
|
+
style={
|
|
47
|
+
{
|
|
48
|
+
"--fibery-emoji-container-size": `${emojiContainerSize}px`,
|
|
49
|
+
"--fibery-emoji-hover-color": emojiHoverColor,
|
|
50
|
+
width,
|
|
51
|
+
} as CSSProperties
|
|
52
|
+
}
|
|
53
|
+
className={emojiPickerCss}
|
|
54
|
+
>
|
|
43
55
|
{children}
|
|
44
56
|
</div>
|
|
45
57
|
</Provider>
|
|
@@ -23,12 +23,14 @@ const emojiInlineImgCss = css`
|
|
|
23
23
|
vertical-align: -0.1em;
|
|
24
24
|
object-fit: contain;
|
|
25
25
|
overflow: hidden;
|
|
26
|
+
-webkit-touch-callout: none;
|
|
26
27
|
`;
|
|
27
28
|
const emojiSizedImgCss = css`
|
|
28
29
|
width: 100%;
|
|
29
30
|
height: 100%;
|
|
30
31
|
object-fit: contain;
|
|
31
32
|
overflow: hidden;
|
|
33
|
+
-webkit-touch-callout: none;
|
|
32
34
|
`;
|
|
33
35
|
|
|
34
36
|
// This is needed to keep text-selection working for image based emojis Details: https://the.fibery.io/SoftDev/bug/Improve-selection-text-with-emojis-8963
|
|
@@ -4,11 +4,13 @@ import {space} from "../../design-system";
|
|
|
4
4
|
const footerCss = css`
|
|
5
5
|
position: relative;
|
|
6
6
|
border-top: 1px solid var(--fibery-color-separatorColor);
|
|
7
|
-
padding: ${space.
|
|
7
|
+
padding: ${space.s6}px ${space.s12}px;
|
|
8
8
|
min-height: 40px;
|
|
9
|
+
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
9
12
|
`;
|
|
10
13
|
|
|
11
14
|
export const EmojiPickerFooter: React.FC<React.PropsWithChildren> = ({children}) => {
|
|
12
|
-
// TODO: Preview + ColorPicker + Skin Tone
|
|
13
15
|
return <div className={footerCss}>{children}</div>;
|
|
14
16
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type {EmojiItem} from "@fibery/emoji-data";
|
|
2
|
+
import {css} from "@linaria/core";
|
|
3
|
+
import {memo} from "react";
|
|
4
|
+
import {IconButton} from "../../button/icon-button";
|
|
5
|
+
import {themeVars} from "../../design-system";
|
|
6
|
+
import {useEmojiDataStoreSelector} from "../stores/emoji-data-store";
|
|
7
|
+
import {useEmojiPickerContentSettings} from "./content";
|
|
8
|
+
import {Emoji} from "./emoji";
|
|
9
|
+
import {useSetEmojiPreview} from "./preview-provider";
|
|
10
|
+
import {useEmojiPickerCtx} from "./root";
|
|
11
|
+
import {useEmojiSkin} from "./skin-provider";
|
|
12
|
+
|
|
13
|
+
export type EmojiPickerGridItemProps = {
|
|
14
|
+
id: string;
|
|
15
|
+
categoryId: string;
|
|
16
|
+
highlighted?: boolean;
|
|
17
|
+
/** Custom renderer for emoji items */
|
|
18
|
+
renderEmoji?: (args: {emoji: EmojiItem}) => React.ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const EmojiPickerGridItem = memo<EmojiPickerGridItemProps>(({id, categoryId, renderEmoji, highlighted}) => {
|
|
22
|
+
const setEmojiPreview = useSetEmojiPreview();
|
|
23
|
+
const skin = useEmojiSkin();
|
|
24
|
+
const emojiPickerCtx = useEmojiPickerCtx();
|
|
25
|
+
const emojiContentSettings = useEmojiPickerContentSettings();
|
|
26
|
+
|
|
27
|
+
const emoji = useEmojiDataStoreSelector((store) => store.get(id, {skin}));
|
|
28
|
+
|
|
29
|
+
if (!emoji) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// TODO: title should be shown in preview in footer
|
|
34
|
+
const title = emoji.shortNames ? emoji.shortNames[0] : undefined;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<IconButton
|
|
38
|
+
style={{
|
|
39
|
+
width: emojiContentSettings.emojiContainerSize,
|
|
40
|
+
height: emojiContentSettings.emojiContainerSize,
|
|
41
|
+
}}
|
|
42
|
+
size="big"
|
|
43
|
+
onClick={() => emojiPickerCtx.onEmojiSelect(emoji)}
|
|
44
|
+
onMouseEnter={() => setEmojiPreview({emoji, categoryId})}
|
|
45
|
+
onMouseLeave={() => setEmojiPreview(null)}
|
|
46
|
+
data-highlighted={highlighted}
|
|
47
|
+
className={css`
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
&:hover:not(:disabled),
|
|
52
|
+
&:focus-visible,
|
|
53
|
+
&[data-highlighted="true"] {
|
|
54
|
+
background-color: var(--fibery-emoji-hover-color, ${themeVars.badgeBgColor});
|
|
55
|
+
}
|
|
56
|
+
`}
|
|
57
|
+
title={title}
|
|
58
|
+
aria-label={emoji.name}
|
|
59
|
+
>
|
|
60
|
+
{renderEmoji ? (
|
|
61
|
+
renderEmoji({emoji})
|
|
62
|
+
) : (
|
|
63
|
+
<Emoji
|
|
64
|
+
emoji={id}
|
|
65
|
+
size={emojiContentSettings.emojiSize}
|
|
66
|
+
version={emojiPickerCtx.version}
|
|
67
|
+
skin={skin}
|
|
68
|
+
native={emojiPickerCtx.native}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</IconButton>
|
|
72
|
+
);
|
|
73
|
+
});
|
|
@@ -1,101 +1,355 @@
|
|
|
1
1
|
import {css, cx} from "@linaria/core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import {useMemo, useRef, forwardRef, useImperativeHandle} from "react";
|
|
3
|
+
import {GroupedVirtuoso, GroupedVirtuosoHandle, CalculateViewLocation} from "react-virtuoso";
|
|
4
|
+
import {textStyles, themeVars} from "../../design-system";
|
|
5
|
+
import {EmojiDataStore, useEmojiDataStore, useEmojiDataStoreSelector} from "../stores/emoji-data-store";
|
|
4
6
|
import {get as getFrequentlyUsed} from "../utils/frequently";
|
|
5
|
-
import {
|
|
7
|
+
import {EmojiPickerGridItem, EmojiPickerGridItemProps} from "./grid-item";
|
|
8
|
+
import {EmojiPickerCategoryLabel} from "./category-label";
|
|
6
9
|
import {useEmojiPickerContentSettings} from "./content";
|
|
10
|
+
import {Emoji} from "./emoji";
|
|
7
11
|
import {contentHorizontalPadding} from "./layout";
|
|
12
|
+
import {useEmojiPickerI18N} from "./root";
|
|
8
13
|
import {useFoundEmojis} from "./search-provider";
|
|
14
|
+
import {useEmojiPreview, useSetEmojiPreview} from "./preview-provider";
|
|
15
|
+
import type {Category, EmojiItem} from "@fibery/emoji-data";
|
|
9
16
|
|
|
10
17
|
const gridWrapperCss = css`
|
|
11
|
-
height: 258px;
|
|
12
|
-
overflow-y: scroll;
|
|
13
18
|
overflow-x: hidden;
|
|
14
19
|
|
|
15
20
|
padding: 0 ${contentHorizontalPadding}px;
|
|
16
21
|
`;
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
const disableScrollCss = css`
|
|
24
|
+
overflow-y: hidden !important;
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const makeSearchCategory = ({emojis}: {emojis: string[]}) => ({
|
|
28
|
+
id: "search",
|
|
29
|
+
name: "Search",
|
|
30
|
+
emojis,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const makeRecentCategory = ({emojis}: {emojis: string[]}) => ({
|
|
34
|
+
id: "recent",
|
|
35
|
+
name: "Recent",
|
|
36
|
+
emojis,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const notFoundCss = css`
|
|
40
|
+
padding-top: 102px;
|
|
41
|
+
text-align: center;
|
|
21
42
|
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
${textStyles.regular};
|
|
44
|
+
color: ${themeVars.accentTextColor};
|
|
45
|
+
`;
|
|
24
46
|
|
|
25
|
-
|
|
47
|
+
const NotFound = () => {
|
|
48
|
+
const i18n = useEmojiPickerI18N();
|
|
26
49
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
return (
|
|
51
|
+
<div className={notFoundCss}>
|
|
52
|
+
<Emoji emoji="sleuth_or_spy" size={38} />
|
|
53
|
+
<div>{i18n.notfound}</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ListItem = {
|
|
59
|
+
categoryId: string;
|
|
60
|
+
emojis: string[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const makeData = ({
|
|
64
|
+
emojiData,
|
|
65
|
+
foundEmojis,
|
|
66
|
+
categories,
|
|
67
|
+
perLine,
|
|
68
|
+
}: {
|
|
69
|
+
emojiData: EmojiDataStore;
|
|
70
|
+
foundEmojis: EmojiItem[] | null;
|
|
71
|
+
categories: readonly Category[];
|
|
72
|
+
perLine: number;
|
|
73
|
+
}) => {
|
|
74
|
+
let allCategories = [];
|
|
75
|
+
|
|
76
|
+
if (foundEmojis) {
|
|
77
|
+
allCategories = [makeSearchCategory({emojis: foundEmojis.map(({id}) => id)})];
|
|
78
|
+
} else {
|
|
79
|
+
const recentCategory = makeRecentCategory({
|
|
80
|
+
emojis: getFrequentlyUsed(perLine).filter((id) => {
|
|
32
81
|
const emoji = emojiData.get(id);
|
|
33
82
|
const hidden = emoji && "hidden" in emoji ? emoji.hidden : false;
|
|
34
83
|
return emoji && !hidden;
|
|
35
84
|
}),
|
|
36
|
-
})
|
|
37
|
-
[contentSettings, emojiData]
|
|
38
|
-
);
|
|
85
|
+
});
|
|
39
86
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
) : null}
|
|
50
|
-
{categories
|
|
51
|
-
.filter((category) => category.emojis.length !== 0)
|
|
52
|
-
.map((category, idx) => {
|
|
53
|
-
return (
|
|
54
|
-
<EmojiPickerCategory
|
|
55
|
-
preRender={idx < 2}
|
|
56
|
-
key={category.id}
|
|
57
|
-
category={category}
|
|
58
|
-
scrollParent={scrollParent}
|
|
59
|
-
renderEmoji={renderEmoji}
|
|
60
|
-
/>
|
|
61
|
-
);
|
|
62
|
-
})}
|
|
63
|
-
</>
|
|
64
|
-
);
|
|
65
|
-
};
|
|
66
|
-
export type EmojiPickerGridProps = {
|
|
67
|
-
className?: string;
|
|
68
|
-
} & Pick<EmojiPickerCategoryProps, "renderEmoji">;
|
|
69
|
-
|
|
70
|
-
export const EmojiPickerGrid: React.FC<EmojiPickerGridProps> = ({className, renderEmoji}) => {
|
|
71
|
-
const foundEmojis = useFoundEmojis();
|
|
72
|
-
const [gridElement, setGridElement] = useState<HTMLDivElement | null>(null);
|
|
73
|
-
|
|
74
|
-
const searchCategory = useMemo(
|
|
75
|
-
() =>
|
|
76
|
-
foundEmojis
|
|
77
|
-
? {
|
|
78
|
-
id: "search",
|
|
79
|
-
name: "Search",
|
|
80
|
-
emojis: foundEmojis.map(({id}) => id),
|
|
81
|
-
}
|
|
82
|
-
: null,
|
|
83
|
-
[foundEmojis]
|
|
84
|
-
);
|
|
87
|
+
allCategories = [recentCategory, ...categories].filter((c) => c.emojis.length > 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const counts = [];
|
|
91
|
+
|
|
92
|
+
const dataByRows: Array<ListItem> = [];
|
|
93
|
+
|
|
94
|
+
for (const {emojis, id} of allCategories) {
|
|
95
|
+
const rowsForCategory = Math.ceil(emojis.length / perLine);
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
let tmp: string[] = [];
|
|
98
|
+
emojis.forEach((em) => {
|
|
99
|
+
tmp.push(em);
|
|
100
|
+
|
|
101
|
+
if (tmp.length === perLine) {
|
|
102
|
+
dataByRows.push({categoryId: id, emojis: tmp});
|
|
103
|
+
tmp = [];
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (tmp.length > 0) {
|
|
107
|
+
dataByRows.push({categoryId: id, emojis: tmp});
|
|
89
108
|
}
|
|
90
|
-
}, [foundEmojis, gridElement]);
|
|
91
109
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
counts.push(rowsForCategory);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {allCategories, counts, dataByRows};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// workaround for https://github.com/petyosi/react-virtuoso/issues/1019
|
|
117
|
+
const calculateViewLocation: CalculateViewLocation = ({
|
|
118
|
+
itemTop,
|
|
119
|
+
itemBottom,
|
|
120
|
+
viewportTop,
|
|
121
|
+
viewportBottom,
|
|
122
|
+
locationParams: {behavior, ...rest},
|
|
123
|
+
}) => {
|
|
124
|
+
if (itemTop - 32 < viewportTop) {
|
|
125
|
+
return {...rest, behavior, align: "start"};
|
|
126
|
+
}
|
|
127
|
+
if (itemBottom > viewportBottom) {
|
|
128
|
+
return {...rest, behavior, align: "end"};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
101
131
|
};
|
|
132
|
+
|
|
133
|
+
export type EmojiPickerGridHandle = {
|
|
134
|
+
navigate: (direction: "up" | "down" | "left" | "right") => boolean;
|
|
135
|
+
scrollToTop: () => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type EmojiPickerGridProps = {
|
|
139
|
+
disableScroll?: boolean;
|
|
140
|
+
} & Pick<EmojiPickerGridItemProps, "renderEmoji">;
|
|
141
|
+
|
|
142
|
+
export const EmojiPickerGrid = forwardRef<EmojiPickerGridHandle, EmojiPickerGridProps>(
|
|
143
|
+
({disableScroll, renderEmoji}, ref) => {
|
|
144
|
+
const setEmojiPreview = useSetEmojiPreview();
|
|
145
|
+
const foundEmojis = useFoundEmojis();
|
|
146
|
+
const virtuosoRef = useRef<GroupedVirtuosoHandle>(null);
|
|
147
|
+
// const [gridElement, setGridElement] = useState<HTMLDivElement | null>(null);
|
|
148
|
+
|
|
149
|
+
const contentSettings = useEmojiPickerContentSettings();
|
|
150
|
+
|
|
151
|
+
// TODO: accessing emojiData inside useMemo is not reactive so it won't be re-rendered if emojis are modified
|
|
152
|
+
const emojiData = useEmojiDataStore();
|
|
153
|
+
|
|
154
|
+
const categories = useEmojiDataStoreSelector((store) => store.getCategories());
|
|
155
|
+
|
|
156
|
+
const {counts, allCategories, dataByRows} = useMemo(
|
|
157
|
+
() => makeData({categories, emojiData, foundEmojis, perLine: contentSettings.perLine}),
|
|
158
|
+
[categories, contentSettings.perLine, emojiData, foundEmojis]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const emojiPreview = useEmojiPreview();
|
|
162
|
+
|
|
163
|
+
useImperativeHandle(
|
|
164
|
+
ref,
|
|
165
|
+
() => {
|
|
166
|
+
return {
|
|
167
|
+
scrollToTop: () => {
|
|
168
|
+
virtuosoRef.current?.scrollToIndex(0);
|
|
169
|
+
},
|
|
170
|
+
navigate(direction) {
|
|
171
|
+
const virtuoso = virtuosoRef.current;
|
|
172
|
+
if (!virtuoso) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// TODO: not optimal. Probably better to lift up in context and store focused index separately instead of calculating it every time
|
|
177
|
+
let colIdx = -1;
|
|
178
|
+
let rowIdx = emojiPreview
|
|
179
|
+
? dataByRows.findIndex(
|
|
180
|
+
({categoryId, emojis}) =>
|
|
181
|
+
categoryId === emojiPreview.categoryId &&
|
|
182
|
+
(colIdx = emojis.findIndex((id) => id === emojiPreview.emoji.id)) > -1
|
|
183
|
+
)
|
|
184
|
+
: -1;
|
|
185
|
+
|
|
186
|
+
switch (direction) {
|
|
187
|
+
case "down": {
|
|
188
|
+
rowIdx++;
|
|
189
|
+
const nextRow = dataByRows[rowIdx];
|
|
190
|
+
if (nextRow) {
|
|
191
|
+
colIdx = Math.min(Math.max(colIdx, 0), nextRow.emojis.length - 1);
|
|
192
|
+
|
|
193
|
+
virtuoso.scrollIntoView({
|
|
194
|
+
index: rowIdx,
|
|
195
|
+
done: () =>
|
|
196
|
+
setEmojiPreview({
|
|
197
|
+
categoryId: nextRow.categoryId,
|
|
198
|
+
emoji: emojiData.get(nextRow.emojis[colIdx]) as EmojiItem,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "up": {
|
|
207
|
+
rowIdx--;
|
|
208
|
+
const nextRow = dataByRows[rowIdx];
|
|
209
|
+
if (nextRow) {
|
|
210
|
+
colIdx = Math.min(Math.max(colIdx, 0), nextRow.emojis.length - 1);
|
|
211
|
+
|
|
212
|
+
virtuoso.scrollIntoView({
|
|
213
|
+
index: rowIdx,
|
|
214
|
+
calculateViewLocation: calculateViewLocation,
|
|
215
|
+
done: () =>
|
|
216
|
+
setEmojiPreview({
|
|
217
|
+
categoryId: nextRow.categoryId,
|
|
218
|
+
emoji: emojiData.get(nextRow.emojis[colIdx]) as EmojiItem,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "right": {
|
|
228
|
+
let nextRow = dataByRows[rowIdx];
|
|
229
|
+
colIdx++;
|
|
230
|
+
|
|
231
|
+
if (!nextRow?.emojis[colIdx]) {
|
|
232
|
+
rowIdx++;
|
|
233
|
+
nextRow = dataByRows[rowIdx];
|
|
234
|
+
|
|
235
|
+
if (nextRow) {
|
|
236
|
+
colIdx = 0;
|
|
237
|
+
virtuoso.scrollIntoView({
|
|
238
|
+
index: rowIdx,
|
|
239
|
+
done: () =>
|
|
240
|
+
setEmojiPreview({
|
|
241
|
+
categoryId: nextRow.categoryId,
|
|
242
|
+
emoji: emojiData.get(nextRow.emojis[colIdx]) as EmojiItem,
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
return true;
|
|
246
|
+
} else {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setEmojiPreview({
|
|
252
|
+
categoryId: nextRow.categoryId,
|
|
253
|
+
emoji: emojiData.get(nextRow.emojis[colIdx]) as EmojiItem,
|
|
254
|
+
});
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
case "left": {
|
|
258
|
+
const row = dataByRows[rowIdx];
|
|
259
|
+
colIdx--;
|
|
260
|
+
|
|
261
|
+
if (!row?.emojis[colIdx]) {
|
|
262
|
+
rowIdx--;
|
|
263
|
+
const nextRow = dataByRows[rowIdx];
|
|
264
|
+
|
|
265
|
+
if (nextRow) {
|
|
266
|
+
colIdx = nextRow.emojis.length - 1;
|
|
267
|
+
virtuoso.scrollIntoView({
|
|
268
|
+
index: rowIdx,
|
|
269
|
+
calculateViewLocation,
|
|
270
|
+
done: () =>
|
|
271
|
+
setEmojiPreview({
|
|
272
|
+
categoryId: nextRow.categoryId,
|
|
273
|
+
emoji: emojiData.get(nextRow.emojis[colIdx]) as EmojiItem,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
return true;
|
|
277
|
+
} else {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
setEmojiPreview({
|
|
283
|
+
categoryId: row.categoryId,
|
|
284
|
+
emoji: emojiData.get(row.emojis[colIdx]) as EmojiItem,
|
|
285
|
+
});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
default:
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return false;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
[dataByRows, emojiData, emojiPreview, setEmojiPreview]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div
|
|
301
|
+
className={css`
|
|
302
|
+
height: 258px;
|
|
303
|
+
position: relative;
|
|
304
|
+
`}
|
|
305
|
+
>
|
|
306
|
+
<GroupedVirtuoso
|
|
307
|
+
ref={virtuosoRef}
|
|
308
|
+
className={cx(gridWrapperCss, disableScroll && disableScrollCss)}
|
|
309
|
+
defaultItemHeight={contentSettings.emojiContainerSize}
|
|
310
|
+
fixedItemHeight={contentSettings.emojiContainerSize}
|
|
311
|
+
increaseViewportBy={130}
|
|
312
|
+
groupCounts={counts}
|
|
313
|
+
groupContent={(i) => <EmojiPickerCategoryLabel category={allCategories[i]} />}
|
|
314
|
+
itemContent={(rowIdx) => {
|
|
315
|
+
const row = dataByRows[rowIdx];
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div
|
|
319
|
+
className={css`
|
|
320
|
+
display: flex;
|
|
321
|
+
`}
|
|
322
|
+
>
|
|
323
|
+
{row.emojis.map((id) => {
|
|
324
|
+
return (
|
|
325
|
+
<EmojiPickerGridItem
|
|
326
|
+
key={`${row.categoryId}-${id}`}
|
|
327
|
+
id={id}
|
|
328
|
+
categoryId={row.categoryId}
|
|
329
|
+
renderEmoji={renderEmoji}
|
|
330
|
+
highlighted={Boolean(
|
|
331
|
+
emojiPreview && emojiPreview.emoji.id === id && emojiPreview.categoryId === row.categoryId
|
|
332
|
+
)}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
})}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
{/* don't want to remount GroupedVirtuoso that's why I positioned NotFound as absolute */}
|
|
342
|
+
{foundEmojis && foundEmojis.length === 0 ? (
|
|
343
|
+
<div
|
|
344
|
+
className={css`
|
|
345
|
+
position: absolute;
|
|
346
|
+
inset: 0;
|
|
347
|
+
`}
|
|
348
|
+
>
|
|
349
|
+
<NotFound />
|
|
350
|
+
</div>
|
|
351
|
+
) : null}
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {useCallback, useState} from "react";
|
|
2
|
+
import {createContext} from "@fibery/react/src/create-context";
|
|
3
|
+
import type {EmojiItem} from "@fibery/emoji-data";
|
|
4
|
+
|
|
5
|
+
type EmojiPreview = {emoji: EmojiItem; categoryId: string} | null;
|
|
6
|
+
|
|
7
|
+
type Setter = (v: EmojiPreview) => void;
|
|
8
|
+
|
|
9
|
+
const [SetterProvider, useSetterCtx] = createContext<Setter>("EmojiPreviewSetterProvider");
|
|
10
|
+
const [ValueProvider, useValueCtx] = createContext<EmojiPreview>("EmojiPreviewProvider");
|
|
11
|
+
|
|
12
|
+
export const EmojiPreviewProvider: React.FC<React.PropsWithChildren> = ({children}) => {
|
|
13
|
+
const [preview, setPreview] = useState<EmojiPreview>(null);
|
|
14
|
+
|
|
15
|
+
const setter = useCallback<Setter>((v) => {
|
|
16
|
+
setPreview(v);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<SetterProvider value={setter}>
|
|
21
|
+
<ValueProvider value={preview}>{children}</ValueProvider>
|
|
22
|
+
</SetterProvider>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const useSetEmojiPreview = useSetterCtx;
|
|
27
|
+
export const useEmojiPreview = useValueCtx;
|
|
@@ -7,6 +7,7 @@ import {EmojiSkinProvider} from "./skin-provider";
|
|
|
7
7
|
import {add as addToFrequentlyUsed} from "../utils/frequently";
|
|
8
8
|
import {isApple} from "../utils/emoji-set";
|
|
9
9
|
import {useCallbackRef} from "@fibery/react/src/use-callback-ref";
|
|
10
|
+
import {EmojiPreviewProvider} from "./preview-provider";
|
|
10
11
|
|
|
11
12
|
type EmojiPickerCtx = {
|
|
12
13
|
version: number;
|
|
@@ -76,11 +77,13 @@ export const EmojiPickerRoot: React.FC<
|
|
|
76
77
|
|
|
77
78
|
return (
|
|
78
79
|
<Provider value={emojiPickerCtx}>
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
<EmojiPreviewProvider>
|
|
81
|
+
<EmojiSkinProvider>
|
|
82
|
+
<EmojiPickerSearchProvider>
|
|
83
|
+
<I18NProvider value={mergedI18N}>{children}</I18NProvider>
|
|
84
|
+
</EmojiPickerSearchProvider>
|
|
85
|
+
</EmojiSkinProvider>
|
|
86
|
+
</EmojiPreviewProvider>
|
|
84
87
|
</Provider>
|
|
85
88
|
);
|
|
86
89
|
};
|
|
@@ -10,6 +10,8 @@ import {useEmojiDataStore} from "../stores/emoji-data-store";
|
|
|
10
10
|
import {useEmojiPickerCtx, useEmojiPickerI18N} from "./root";
|
|
11
11
|
import {useFoundEmojis, useSetFoundEmojis} from "./search-provider";
|
|
12
12
|
import {useEmojiSkin} from "./skin-provider";
|
|
13
|
+
import {useEmojiPreview, useSetEmojiPreview} from "./preview-provider";
|
|
14
|
+
import type {EmojiPickerGridHandle} from "./grid";
|
|
13
15
|
|
|
14
16
|
const searchCss = css`
|
|
15
17
|
${textStyles.regular}
|
|
@@ -72,10 +74,19 @@ export type EmojiPickerSearchProps = {
|
|
|
72
74
|
|
|
73
75
|
searchRef?: React.RefObject<HTMLInputElement>;
|
|
74
76
|
|
|
77
|
+
// TODO: it's better to pass it via context
|
|
78
|
+
gridRef?: React.RefObject<EmojiPickerGridHandle>;
|
|
79
|
+
|
|
75
80
|
extraAction?: React.ReactNode;
|
|
76
81
|
};
|
|
77
82
|
|
|
78
|
-
export const EmojiPickerSearch: React.FC<EmojiPickerSearchProps> = ({
|
|
83
|
+
export const EmojiPickerSearch: React.FC<EmojiPickerSearchProps> = ({
|
|
84
|
+
autoFocus,
|
|
85
|
+
className,
|
|
86
|
+
searchRef,
|
|
87
|
+
gridRef,
|
|
88
|
+
extraAction,
|
|
89
|
+
}) => {
|
|
79
90
|
const ref = useRef<HTMLInputElement>(null);
|
|
80
91
|
|
|
81
92
|
const setRefs = useComposedRefs(ref, searchRef);
|
|
@@ -86,11 +97,16 @@ export const EmojiPickerSearch: React.FC<EmojiPickerSearchProps> = ({autoFocus,
|
|
|
86
97
|
const foundEmojis = useFoundEmojis();
|
|
87
98
|
const skin = useEmojiSkin();
|
|
88
99
|
const _setFoundEmojis = useSetFoundEmojis();
|
|
100
|
+
const setEmojiPreview = useSetEmojiPreview();
|
|
101
|
+
const emojiPreview = useEmojiPreview();
|
|
89
102
|
|
|
90
103
|
const setFoundEmojis = (emojis: EmojiItem[] | null) => {
|
|
104
|
+
gridRef?.current?.scrollToTop();
|
|
91
105
|
// improves responsiveness of input.
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
startTransition(() => {
|
|
107
|
+
_setFoundEmojis(emojis);
|
|
108
|
+
setEmojiPreview(emojis?.[0] ? {emoji: emojis[0], categoryId: "search"} : null);
|
|
109
|
+
});
|
|
94
110
|
};
|
|
95
111
|
|
|
96
112
|
const inputId = `emoji-picker-search-${useId()}`;
|
|
@@ -104,10 +120,38 @@ export const EmojiPickerSearch: React.FC<EmojiPickerSearchProps> = ({autoFocus,
|
|
|
104
120
|
};
|
|
105
121
|
|
|
106
122
|
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
|
|
107
|
-
if (e.key === "Enter" &&
|
|
108
|
-
emojiPicker.onEmojiSelect(foundEmojis[0]);
|
|
109
|
-
|
|
123
|
+
if (e.key === "Enter" && emojiPreview) {
|
|
110
124
|
e.preventDefault();
|
|
125
|
+
emojiPicker.onEmojiSelect(emojiPreview.emoji);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cursorAtEnd = ref.current && ref.current.selectionStart === ref.current.value.length;
|
|
129
|
+
|
|
130
|
+
if (gridRef?.current && cursorAtEnd) {
|
|
131
|
+
let handled = false;
|
|
132
|
+
if (e.key === "ArrowDown") {
|
|
133
|
+
handled = gridRef.current.navigate("down");
|
|
134
|
+
}
|
|
135
|
+
if (e.key === "ArrowUp" && emojiPreview) {
|
|
136
|
+
handled = gridRef.current.navigate("up");
|
|
137
|
+
if (!handled) {
|
|
138
|
+
setEmojiPreview(null);
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (e.key === "ArrowLeft" && emojiPreview) {
|
|
143
|
+
handled = gridRef.current.navigate("left");
|
|
144
|
+
if (!handled) {
|
|
145
|
+
setEmojiPreview(null);
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (e.key === "ArrowRight") {
|
|
150
|
+
handled = gridRef.current.navigate("right");
|
|
151
|
+
}
|
|
152
|
+
if (handled) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
}
|
|
111
155
|
}
|
|
112
156
|
};
|
|
113
157
|
|
|
@@ -117,18 +117,13 @@ type Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = {
|
|
|
117
117
|
autoFocus?: boolean;
|
|
118
118
|
defaultMenuIsOpen?: boolean;
|
|
119
119
|
placeholder?: string;
|
|
120
|
-
onInputChange?: (s: string, actionType: {action: string}) => void;
|
|
121
120
|
noOptionsMessage: () => string | JSX.Element | null;
|
|
122
121
|
options: OptionsOrGroups<Option, Group>;
|
|
123
122
|
filterOption?: SelectProps<Option>["filterOption"];
|
|
124
123
|
inputValue?: string;
|
|
125
124
|
disabled?: boolean;
|
|
126
|
-
onFocus?: () => void;
|
|
127
125
|
isCollectionMode?: IsMulti;
|
|
128
126
|
isClearable?: boolean;
|
|
129
|
-
onChange: (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => void;
|
|
130
|
-
onMenuOpen: () => void;
|
|
131
|
-
onMenuClose: () => void | boolean;
|
|
132
127
|
renderValue?: () => ReactNode;
|
|
133
128
|
title?: string;
|
|
134
129
|
maxMenuHeight?: number;
|
|
@@ -139,6 +134,11 @@ type Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = {
|
|
|
139
134
|
formatGroupLabel?: (group: Group) => ReactNode;
|
|
140
135
|
formatOptionLabel?: (option: Option) => ReactNode;
|
|
141
136
|
components?: ComponentsConfig;
|
|
137
|
+
onInputChange?: (s: string, actionType: {action: string}) => void;
|
|
138
|
+
onFocus?: () => void;
|
|
139
|
+
onChange: (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => void;
|
|
140
|
+
onMenuOpen?: () => void;
|
|
141
|
+
onMenuClose?: () => void | boolean;
|
|
142
142
|
};
|
|
143
143
|
|
|
144
144
|
function SelectInPopoverInner<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
|
|
@@ -151,18 +151,13 @@ function SelectInPopoverInner<Option, IsMulti extends boolean, Group extends Gro
|
|
|
151
151
|
autoFocus,
|
|
152
152
|
defaultMenuIsOpen,
|
|
153
153
|
placeholder,
|
|
154
|
-
onInputChange,
|
|
155
154
|
noOptionsMessage,
|
|
156
155
|
options,
|
|
157
156
|
filterOption,
|
|
158
157
|
inputValue,
|
|
159
158
|
disabled,
|
|
160
|
-
onFocus,
|
|
161
159
|
isCollectionMode,
|
|
162
160
|
isClearable = true,
|
|
163
|
-
onChange,
|
|
164
|
-
onMenuOpen,
|
|
165
|
-
onMenuClose,
|
|
166
161
|
renderValue,
|
|
167
162
|
title,
|
|
168
163
|
maxMenuHeight,
|
|
@@ -173,6 +168,11 @@ function SelectInPopoverInner<Option, IsMulti extends boolean, Group extends Gro
|
|
|
173
168
|
formatGroupLabel,
|
|
174
169
|
formatOptionLabel,
|
|
175
170
|
components = {},
|
|
171
|
+
onInputChange,
|
|
172
|
+
onFocus,
|
|
173
|
+
onChange,
|
|
174
|
+
onMenuOpen,
|
|
175
|
+
onMenuClose,
|
|
176
176
|
} = props;
|
|
177
177
|
const [triggerElement, setTriggerElement] = useState<HTMLDivElement | null>(null);
|
|
178
178
|
const [visible, setVisible] = useState(menuIsOpen);
|
|
@@ -198,7 +198,7 @@ function SelectInPopoverInner<Option, IsMulti extends boolean, Group extends Gro
|
|
|
198
198
|
|
|
199
199
|
const onHide = useCallback(
|
|
200
200
|
(restoreFocus = true) => {
|
|
201
|
-
const wasClosed = onMenuClose();
|
|
201
|
+
const wasClosed = onMenuClose && onMenuClose();
|
|
202
202
|
if (wasClosed === false) {
|
|
203
203
|
return;
|
|
204
204
|
}
|
|
@@ -231,7 +231,9 @@ function SelectInPopoverInner<Option, IsMulti extends boolean, Group extends Gro
|
|
|
231
231
|
clearTimeout(blurTimeoutRef.current);
|
|
232
232
|
}
|
|
233
233
|
setVisible(true);
|
|
234
|
-
onMenuOpen
|
|
234
|
+
if (onMenuOpen) {
|
|
235
|
+
onMenuOpen();
|
|
236
|
+
}
|
|
235
237
|
}, [onMenuOpen]);
|
|
236
238
|
|
|
237
239
|
const styles = useMemo(() => {
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import {Category, EmojiItem} from "@fibery/emoji-data";
|
|
2
|
-
import {css} from "@linaria/core";
|
|
3
|
-
import {memo} from "react";
|
|
4
|
-
import {useInView} from "react-intersection-observer";
|
|
5
|
-
import {IconButton} from "../../button/icon-button";
|
|
6
|
-
import {space, textStyles, themeVars} from "../../design-system";
|
|
7
|
-
import Settings from "../../icons/react/Settings";
|
|
8
|
-
import {useEmojiDataStoreSelector} from "../stores/emoji-data-store";
|
|
9
|
-
import {useEmojiPickerContentSettings} from "./content";
|
|
10
|
-
import {Emoji} from "./emoji";
|
|
11
|
-
import {emojiPadding} from "./layout";
|
|
12
|
-
import {useEmojiPickerCtx, useEmojiPickerI18N} from "./root";
|
|
13
|
-
import {useEmojiSkin} from "./skin-provider";
|
|
14
|
-
|
|
15
|
-
const categoryCss = css``;
|
|
16
|
-
|
|
17
|
-
const categoryLabelCss = css`
|
|
18
|
-
${textStyles.heading6}
|
|
19
|
-
color: ${themeVars.accentTextColor};
|
|
20
|
-
background-color: ${themeVars.actionMenuBg};
|
|
21
|
-
padding: ${space.s12}px ${space.s6}px ${space.s4}px ${space.s6}px;
|
|
22
|
-
position: sticky;
|
|
23
|
-
top: 0;
|
|
24
|
-
|
|
25
|
-
display: flex;
|
|
26
|
-
align-items: center;
|
|
27
|
-
gap: ${space.s4}px;
|
|
28
|
-
|
|
29
|
-
z-index: 1;
|
|
30
|
-
`;
|
|
31
|
-
|
|
32
|
-
const categoryListCss = css`
|
|
33
|
-
display: flex;
|
|
34
|
-
flex-wrap: wrap;
|
|
35
|
-
margin: 0;
|
|
36
|
-
padding: 0;
|
|
37
|
-
|
|
38
|
-
& li {
|
|
39
|
-
list-style: none;
|
|
40
|
-
margin: 0;
|
|
41
|
-
padding: 0;
|
|
42
|
-
|
|
43
|
-
line-height: 1;
|
|
44
|
-
}
|
|
45
|
-
`;
|
|
46
|
-
|
|
47
|
-
const notFoundCss = css`
|
|
48
|
-
padding-top: 70px;
|
|
49
|
-
text-align: center;
|
|
50
|
-
|
|
51
|
-
${textStyles.regular};
|
|
52
|
-
color: ${themeVars.accentTextColor};
|
|
53
|
-
`;
|
|
54
|
-
|
|
55
|
-
const NotFound = () => {
|
|
56
|
-
const i18n = useEmojiPickerI18N();
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div className={notFoundCss}>
|
|
60
|
-
<Emoji emoji="sleuth_or_spy" size={38} />
|
|
61
|
-
<div>{i18n.notfound}</div>
|
|
62
|
-
</div>
|
|
63
|
-
);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
type CategoryItemProps = {
|
|
67
|
-
id: string;
|
|
68
|
-
/** Custom renderer for emoji items */
|
|
69
|
-
renderEmoji?: (args: {emoji: EmojiItem}) => React.ReactNode;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const CategoryItem: React.FC<CategoryItemProps> = ({id, renderEmoji}) => {
|
|
73
|
-
const skin = useEmojiSkin();
|
|
74
|
-
const emojiPickerCtx = useEmojiPickerCtx();
|
|
75
|
-
const emojiContentSettings = useEmojiPickerContentSettings();
|
|
76
|
-
|
|
77
|
-
const emoji = useEmojiDataStoreSelector((store) => store.get(id, {skin}));
|
|
78
|
-
|
|
79
|
-
if (!emoji) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// TODO: title should be shown in preview in footer
|
|
84
|
-
const title = emoji.shortNames ? emoji.shortNames[0] : undefined;
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<IconButton
|
|
88
|
-
size="big"
|
|
89
|
-
onClick={() => emojiPickerCtx.onEmojiSelect(emoji)}
|
|
90
|
-
className={css`
|
|
91
|
-
&:hover:not(:disabled),
|
|
92
|
-
&:focus-visible {
|
|
93
|
-
background-color: var(--fibery-emoji-hover-color, ${themeVars.badgeBgColor});
|
|
94
|
-
}
|
|
95
|
-
`}
|
|
96
|
-
title={title}
|
|
97
|
-
aria-label={emoji.name}
|
|
98
|
-
>
|
|
99
|
-
{renderEmoji ? (
|
|
100
|
-
renderEmoji({emoji})
|
|
101
|
-
) : (
|
|
102
|
-
<Emoji
|
|
103
|
-
emoji={id}
|
|
104
|
-
size={emojiContentSettings.emojiSize}
|
|
105
|
-
version={emojiPickerCtx.version}
|
|
106
|
-
skin={skin}
|
|
107
|
-
native={emojiPickerCtx.native}
|
|
108
|
-
/>
|
|
109
|
-
)}
|
|
110
|
-
</IconButton>
|
|
111
|
-
);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
export type EmojiPickerCategoryProps = {
|
|
115
|
-
category: Category;
|
|
116
|
-
|
|
117
|
-
preRender?: boolean;
|
|
118
|
-
scrollParent: Element | null;
|
|
119
|
-
} & Pick<CategoryItemProps, "renderEmoji">;
|
|
120
|
-
|
|
121
|
-
export const EmojiPickerCategory = memo<EmojiPickerCategoryProps>(function EmojiPickerCategoryComponent({
|
|
122
|
-
category,
|
|
123
|
-
renderEmoji,
|
|
124
|
-
scrollParent,
|
|
125
|
-
preRender = false,
|
|
126
|
-
}) {
|
|
127
|
-
const i18n = useEmojiPickerI18N();
|
|
128
|
-
const emojiPickerCtx = useEmojiPickerCtx();
|
|
129
|
-
const emojiContentSettings = useEmojiPickerContentSettings();
|
|
130
|
-
|
|
131
|
-
const {ref, inView} = useInView({
|
|
132
|
-
root: scrollParent,
|
|
133
|
-
rootMargin: `129px 0px`, // grid height / 2
|
|
134
|
-
initialInView: preRender,
|
|
135
|
-
fallbackInView: true,
|
|
136
|
-
triggerOnce: true,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const label = i18n.categories[category.id as keyof (typeof i18n)["categories"]] || category.name;
|
|
140
|
-
|
|
141
|
-
const emojiSize = emojiContentSettings.emojiSize + emojiPadding * 2;
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<section className={categoryCss} aria-label={label}>
|
|
145
|
-
<div className={categoryLabelCss}>
|
|
146
|
-
{/* already labeled by the section aria-label */}
|
|
147
|
-
<span aria-hidden={true}>{label}</span>
|
|
148
|
-
|
|
149
|
-
{category.id === "custom" && emojiPickerCtx.onCustomCategorySettings && (
|
|
150
|
-
<IconButton onClick={emojiPickerCtx.onCustomCategorySettings}>
|
|
151
|
-
<Settings iconSize={16} />
|
|
152
|
-
</IconButton>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
{category.emojis.length > 0 ? (
|
|
157
|
-
<ul ref={ref} className={categoryListCss}>
|
|
158
|
-
{category.emojis.map((id) => {
|
|
159
|
-
return (
|
|
160
|
-
<li
|
|
161
|
-
key={id}
|
|
162
|
-
style={{
|
|
163
|
-
width: emojiSize,
|
|
164
|
-
height: emojiSize,
|
|
165
|
-
}}
|
|
166
|
-
>
|
|
167
|
-
{inView ? <CategoryItem id={id} renderEmoji={renderEmoji} /> : null}
|
|
168
|
-
</li>
|
|
169
|
-
);
|
|
170
|
-
})}
|
|
171
|
-
</ul>
|
|
172
|
-
) : (
|
|
173
|
-
<NotFound />
|
|
174
|
-
)}
|
|
175
|
-
</section>
|
|
176
|
-
);
|
|
177
|
-
});
|