@fibery/ui-kit 1.23.0 → 1.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fibery/ui-kit",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
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>
@@ -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
 
@@ -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
- });