@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fibery/ui-kit",
3
- "version": "1.23.0",
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 {useThemeMode} from "../theme-provider";
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={({emoji}) => (
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, cx} from "@linaria/core";
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: 302px; // search + emoji grid
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 className={cx(colorOpened && disableScrollCss)} renderEmoji={renderEmoji} />
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 {css} from "@linaria/core";
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
- <EmojiPickerFooter>
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 style={{"--fibery-emoji-hover-color": emojiHoverColor, width} as CSSProperties} className={emojiPickerCss}>
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.s8}px ${space.s12}px;
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 {useLayoutEffect, useMemo, useState} from "react";
3
- import {useEmojiDataStore, useEmojiDataStoreSelector} from "../stores/emoji-data-store";
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 {EmojiPickerCategory, EmojiPickerCategoryProps} from "./category";
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
- type AllCategoriesProps = Pick<EmojiPickerCategoryProps, "renderEmoji" | "scrollParent">;
19
- const AllCategories: React.FC<AllCategoriesProps> = ({renderEmoji, scrollParent}) => {
20
- const contentSettings = useEmojiPickerContentSettings();
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
- // TODO: accessing emojiData inside useMemo is not reactive so it won't be re-rendered when emojis are modified
23
- const emojiData = useEmojiDataStore();
43
+ ${textStyles.regular};
44
+ color: ${themeVars.accentTextColor};
45
+ `;
24
46
 
25
- const categories = useEmojiDataStoreSelector((store) => store.getCategories());
47
+ const NotFound = () => {
48
+ const i18n = useEmojiPickerI18N();
26
49
 
27
- const recentCategory = useMemo(
28
- () => ({
29
- id: "recent",
30
- name: "Recent",
31
- emojis: getFrequentlyUsed(contentSettings.perLine).filter((id) => {
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
- return (
41
- <>
42
- {recentCategory.emojis.length > 0 ? (
43
- <EmojiPickerCategory
44
- preRender
45
- category={recentCategory}
46
- scrollParent={scrollParent}
47
- renderEmoji={renderEmoji}
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
- useLayoutEffect(() => {
87
- if (gridElement) {
88
- gridElement.scrollTop = 0;
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
- return (
93
- <div ref={setGridElement} className={cx(gridWrapperCss, className)}>
94
- {searchCategory ? (
95
- <EmojiPickerCategory preRender scrollParent={gridElement} category={searchCategory} renderEmoji={renderEmoji} />
96
- ) : (
97
- <AllCategories scrollParent={gridElement} renderEmoji={renderEmoji} />
98
- )}
99
- </div>
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
- <EmojiSkinProvider>
80
- <EmojiPickerSearchProvider>
81
- <I18NProvider value={mergedI18N}>{children}</I18NProvider>
82
- </EmojiPickerSearchProvider>
83
- </EmojiSkinProvider>
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> = ({autoFocus, className, searchRef, extraAction}) => {
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
- // TODO: better just implement a proper virtualization
93
- startTransition(() => _setFoundEmojis(emojis));
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" && foundEmojis && foundEmojis.length > 0) {
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
- });