diff --git a/.storybook/main.js b/.storybook/main.js index cf1ac4a9..ef580681 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,8 +1,19 @@ module.exports = { stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + { + name: '@storybook/addon-postcss', + options: { + postcssLoaderOptions: { + implementation: require('postcss') + } + } + } + ], // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration typescript: { - check: true, // type-check stories during Storybook build + check: true // type-check stories during Storybook build } }; diff --git a/README.md b/README.md index 137c0ae0..fe7380c2 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ ![Picker](https://user-images.githubusercontent.com/11255103/192167134-8205eb89-a71d-4463-8f3a-940e844917d5.gif) -An emoji picker component for React applications. - ## What to know before using - This package assumes it runs in the browser. I have taken many steps to prevent it from failing on the server, but still, it is recommended to only render the component on the client. See troubleshooting section for more information. @@ -30,16 +28,15 @@ function App() { ## Shout Outs! - -| Component Design 🎨 | -|:-:| -| ![317751726_1277528579755086_5320360926126813336_n copy](https://user-images.githubusercontent.com/11255103/205937426-a570b4a1-7243-4d3e-a7e5-ea04b61d940a.png) | -| Pavel Bolo | - +| Component Design 🎨 | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| ![317751726_1277528579755086_5320360926126813336_n copy](https://user-images.githubusercontent.com/11255103/205937426-a570b4a1-7243-4d3e-a7e5-ea04b61d940a.png) | +| Pavel Bolo | ## Features - Custom click handler +- Custom Emojis Support - Dark mode - Customizable styles via css variables - Default skin tone selection @@ -52,24 +49,26 @@ function App() { The following props are accepted by them picker: -| Prop | Type | Default | Description | -| ---------------------- | ----------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| onEmojiClick | function | | Callback function that is called when an emoji is clicked. The function receives the emoji object as a parameter. | -| autoFocusSearch | boolean | `true` | Controls the auto focus of the search input. | -| Theme | string | `light` | Controls the theme of the picker. Possible values are `light`, `dark` and `auto`. | -| emojiStyle | string | `apple` | Controls the emoji style. Possible values are `google`, `apple`, `facebook`, `twitter` and `native`. | -| defaultSkinTone | string | `neutral` | Controls the default skin tone. | -| lazyLoadEmojis | boolean | `false` | Controls whether the emojis are loaded lazily or not. | -| previewConfig | object | `{}` | Controls the preview of the emoji. See below for more information. | -| searchPlaceholder | string | `Search` | Controls the placeholder of the search input. | -| suggestedEmojisMode | string | `frequent` | Controls the suggested emojis mode. Possible values are `frequent` and `recent`. | -| skinTonesDisabled | boolean | `false` | Controls whether the skin tones are disabled or not. | -| searchDisabled | boolean | `false` | Controls whether the search is disabled or not. When disabled, the skin tone picker will be shown in the preview component. | -| skinTonePickerLocation | string | `SEARCH` | Controls the location of the skin tone picker. Possible values are `SEARCH` and `PREVIEW`. | -| `width` | `number`/`string` | `350` | Controls the width of the picker. You can provide a number that will be treated as pixel size, or your any accepted css width as string. | -| emojiVersion | `string` | - | Allows displaying emojis up to a certain version for compatibility. | -| `height` | `number`/`string` | `450` | Controls the height of the picker. You can provide a number that will be treated as pixel size, or your any accepted css height as string. | -| getEmojiUrl | `Function` | - | Allows to customize the emoji url and provide your own image host. | +| Prop | Type | Default | Description | +| ---------------------- | ------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| onEmojiClick | function | | Callback function that is called when an emoji is clicked. The function receives the emoji object as a parameter. | +| autoFocusSearch | boolean | `true` | Controls the auto focus of the search input. | +| Theme | string | `light` | Controls the theme of the picker. Possible values are `light`, `dark` and `auto`. | +| emojiStyle | string | `apple` | Controls the emoji style. Possible values are `google`, `apple`, `facebook`, `twitter` and `native`. | +| defaultSkinTone | string | `neutral` | Controls the default skin tone. | +| lazyLoadEmojis | boolean | `false` | Controls whether the emojis are loaded lazily or not. | +| previewConfig | object | `{}` | Controls the preview of the emoji. See below for more information. | +| searchPlaceholder | string | `Search` | Controls the placeholder of the search input. | +| suggestedEmojisMode | string | `frequent` | Controls the suggested emojis mode. Possible values are `frequent` and `recent`. | +| skinTonesDisabled | boolean | `false` | Controls whether the skin tones are disabled or not. | +| searchDisabled | boolean | `false` | Controls whether the search is disabled or not. When disabled, the skin tone picker will be shown in the preview component. | +| skinTonePickerLocation | string | `SEARCH` | Controls the location of the skin tone picker. Possible values are `SEARCH` and `PREVIEW`. | +| `width` | `number`/`string` | `350` | Controls the width of the picker. You can provide a number that will be treated as pixel size, or your any accepted css width as string. | +| emojiVersion | `string` | - | Allows displaying emojis up to a certain version for compatibility. | +| `height` | `number`/`string` | `450` | Controls the height of the picker. You can provide a number that will be treated as pixel size, or your any accepted css height as string. | +| getEmojiUrl | `Function` | - | Allows to customize the emoji url and provide your own image host. | +| categories | `Array` | - | Allows full config over ordering, naming and display of categories. | +| customEmojis | `Array<{names: string[], imgUrl: string, id: string}>` | - | Allows adding custom emojis to the picker. | ## Full details @@ -145,6 +144,7 @@ import { SkinTones } from 'emoji-picker-react'; To only sort/omit categories, you can simply pass an array of category names to display: - 'suggested', + - 'custom', - Hidden by default - 'smileys_people', - 'animals_nature', - 'food_drink', @@ -186,6 +186,45 @@ import { SkinTones } from 'emoji-picker-react'; * `getEmojiUrl`: `(unified: string, emojiStyle: EmojiStyle) => string` - Allows to customize the emoji url and provide your own image host. The function receives the emoji unified and the emoji style as parameters. The function should return the url of the emoji image. +## Custom Emojis + +The customEmojis prop allows you to add custom emojis to the picker. The custom emojis prop takes an array of custom emojis, each custom emoji has three keys: + +id: Unique identifier for each of the custom emojis +names: an array of string identifiers, will be used both for display, search and indexing. +imgUrl: URL for the emoji image + +### Usage Example: + +```jsx + +``` + +Here are some additional things to keep in mind about custom emojis: + +- The custom emojis will be added to the Custom category. +- The location or name of the Custom category can be controlled via the categories prop. +- The custom emojis will be indexed by their id property. This means that you can search for custom emojis by their id or names. + # Customization ## Custom Picker Width and Height diff --git a/package.json b/package.json index 341e0065..60a8f50f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "4.4.12", + "version": "4.5.0", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -77,6 +77,9 @@ "emoji-datasource": "^14.0.0", "eslint-import-resolver-typescript": "^3.3.0", "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jsx-a11y": "^6.6.1", + "eslint-plugin-react": "^7.31.8", + "eslint-plugin-react-hooks": "^4.6.0", "fs-extra": "^10.1.0", "glob": "^8.0.3", "husky": "^8.0.1", @@ -92,10 +95,7 @@ "tiny-invariant": "^1.2.0", "tsdx": "^0.14.1", "tslib": "^2.4.0", - "typescript": "^4.7.4", - "eslint-plugin-jsx-a11y": "^6.6.1", - "eslint-plugin-react": "^7.31.8", - "eslint-plugin-react-hooks": "^4.6.0" + "typescript": "^4.7.4" }, "dependencies": { "clsx": "^1.2.1" diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..91e21272 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,9 @@ +module.exports = { + plugins: [ + require('postcss-inline-svg'), + require('postcss-svgo'), + require('autoprefixer'), + require('cssnano') + ], + inject: true +}; diff --git a/scripts/prepare.js b/scripts/prepare.js index 00393950..866a4ffc 100644 --- a/scripts/prepare.js +++ b/scripts/prepare.js @@ -16,12 +16,14 @@ const keys = { GROUP_NAME_OBJECTS: 'objects', GROUP_NAME_SYMBOLS: 'symbols', GROUP_NAME_FLAGS: 'flags', - GROUP_NAME_SUGGESTED: 'suggested' + GROUP_NAME_SUGGESTED: 'suggested', + GROUP_NAME_CUSTOM: 'custom' }; const groupConversion = { [keys.GROUP_NAME_PEOPLE]: keys.GROUP_NAME_PEOPLE, smileys_emotion: keys.GROUP_NAME_PEOPLE, + custom: keys.GROUP_NAME_CUSTOM, people_body: keys.GROUP_NAME_PEOPLE, animals_nature: keys.GROUP_NAME_NATURE, food_drink: keys.GROUP_NAME_FOOD, @@ -82,7 +84,7 @@ const { groupedEmojis } = emojis.reduce( groupedEmojis[category].push(cleanEmoji(emoji)); return { groupedEmojis }; }, - { groupedEmojis: {} } + { groupedEmojis: { [keys.GROUP_NAME_CUSTOM]: [] } } ); writeJSONSync('./src/data/emojis.json', groupedEmojis, 'utf8'); diff --git a/src/components/context/PickerConfigContext.tsx b/src/components/context/PickerConfigContext.tsx index c5d60f81..77629f18 100644 --- a/src/components/context/PickerConfigContext.tsx +++ b/src/components/context/PickerConfigContext.tsx @@ -17,8 +17,9 @@ const ConfigContext = React.createContext( ); export function PickerConfigProvider({ children, ...config }: Props) { + const [mergedConfig] = React.useState(() => mergeConfig(config)); return ( - + {children} ); diff --git a/src/components/emoji/BaseEmojiProps.ts b/src/components/emoji/BaseEmojiProps.ts new file mode 100644 index 00000000..3f70ee25 --- /dev/null +++ b/src/components/emoji/BaseEmojiProps.ts @@ -0,0 +1,13 @@ +import { CustomEmoji } from '../../config/customEmojiConfig'; +import { DataEmoji } from '../../dataUtils/DataTypes'; +import { EmojiStyle } from '../../types/exposedTypes'; + +export type BaseEmojiProps = { + emoji?: DataEmoji | CustomEmoji; + emojiStyle: EmojiStyle; + unified: string; + size?: number; + lazyLoad?: boolean; + getEmojiUrl?: GetEmojiUrl; +}; +export type GetEmojiUrl = (unified: string, style: EmojiStyle) => string; diff --git a/src/components/emoji/ClickableEmojiButton.tsx b/src/components/emoji/ClickableEmojiButton.tsx new file mode 100644 index 00000000..2d9b997b --- /dev/null +++ b/src/components/emoji/ClickableEmojiButton.tsx @@ -0,0 +1,42 @@ +import clsx from 'clsx'; +import * as React from 'react'; + +import { ClassNames } from '../../DomUtils/classNames'; +import { Button } from '../atoms/Button'; +import './Emoji.css'; + +type ClickableEmojiButtonProps = Readonly<{ + hidden?: boolean; + showVariations?: boolean; + hiddenOnSearch?: boolean; + emojiNames: string[]; + children: React.ReactNode; + hasVariations: boolean; + unified?: string; +}>; + +export function ClickableEmojiButton({ + emojiNames, + unified, + hidden, + hiddenOnSearch, + showVariations = true, + hasVariations, + children +}: ClickableEmojiButtonProps) { + return ( + + ); +} diff --git a/src/components/emoji/Emoji.css b/src/components/emoji/Emoji.css index f3469dba..1777c700 100644 --- a/src/components/emoji/Emoji.css +++ b/src/components/emoji/Emoji.css @@ -30,6 +30,8 @@ .EmojiPickerReact button.epr-emoji .epr-emoji-img { max-width: var(--epr-emoji-fullsize); max-height: var(--epr-emoji-fullsize); + min-width: var(--epr-emoji-fullsize); + min-height: var(--epr-emoji-fullsize); padding: var(--epr-emoji-padding); } diff --git a/src/components/emoji/Emoji.tsx b/src/components/emoji/Emoji.tsx index f88ddd49..37234730 100644 --- a/src/components/emoji/Emoji.tsx +++ b/src/components/emoji/Emoji.tsx @@ -1,23 +1,15 @@ -import clsx from 'clsx'; import * as React from 'react'; -import { ClassNames } from '../../DomUtils/classNames'; import { DataEmoji } from '../../dataUtils/DataTypes'; -import { - emojiByUnified, - emojiHasVariations, - emojiName, - emojiNames, - emojiUrlByUnified, -} from '../../dataUtils/emojiSelectors'; -import { parseNativeEmoji } from '../../dataUtils/parseNativeEmoji'; -import { EmojiStyle } from '../../types/exposedTypes'; -import { Button } from '../atoms/Button'; -import { useEmojisThatFailedToLoadState } from '../context/PickerContext'; +import { emojiHasVariations, emojiNames } from '../../dataUtils/emojiSelectors'; + import './Emoji.css'; +import { BaseEmojiProps } from './BaseEmojiProps'; +import { ClickableEmojiButton } from './ClickableEmojiButton'; +import { ViewOnlyEmoji } from './ViewOnlyEmoji'; type ClickableEmojiProps = Readonly< - BaseProps & { + BaseEmojiProps & { hidden?: boolean; showVariations?: boolean; hiddenOnSearch?: boolean; @@ -25,15 +17,6 @@ type ClickableEmojiProps = Readonly< } >; -type BaseProps = { - emoji?: DataEmoji; - emojiStyle: EmojiStyle; - unified: string; - size?: number; - lazyLoad?: boolean; - getEmojiUrl?: GetEmojiUrl; -}; - export function ClickableEmoji({ emoji, unified, @@ -43,22 +26,18 @@ export function ClickableEmoji({ showVariations = true, size, lazyLoad, - getEmojiUrl, + getEmojiUrl }: ClickableEmojiProps) { const hasVariations = emojiHasVariations(emoji); return ( - - ); -} - -export function ViewOnlyEmoji({ - emoji, - unified, - emojiStyle, - size, - lazyLoad, - getEmojiUrl = emojiUrlByUnified, -}: BaseProps) { - const style = {} as React.CSSProperties; - if (size) { - style.width = style.height = style.fontSize = `${size}px`; - } - - const emojiToRender = emoji ? emoji : emojiByUnified(unified); - if(!emojiToRender) { - return null - } - - return ( - <> - {emojiStyle === EmojiStyle.NATIVE ? ( - - ) : ( - - )} - - ); -} - -function NativeEmoji({ - unified, - style, -}: { - unified: string; - style: React.CSSProperties; -}) { - return ( - - {parseNativeEmoji(unified)} - - ); -} - -function EmojiImg({ - emoji, - unified, - emojiStyle, - style, - lazyLoad = false, - getEmojiUrl, -}: { - emoji: DataEmoji; - unified: string; - emojiStyle: EmojiStyle; - style: React.CSSProperties; - lazyLoad?: boolean; - getEmojiUrl: GetEmojiUrl; -}) { - const [, setEmojisThatFailedToLoad] = useEmojisThatFailedToLoadState(); - - return ( - {emojiName(emoji)} + ); - - function onError() { - setEmojisThatFailedToLoad((prev) => new Set(prev).add(unified)); - } } - -export type GetEmojiUrl = (unified: string, style: EmojiStyle) => string; diff --git a/src/components/emoji/EmojiImg.tsx b/src/components/emoji/EmojiImg.tsx new file mode 100644 index 00000000..22249339 --- /dev/null +++ b/src/components/emoji/EmojiImg.tsx @@ -0,0 +1,31 @@ +import clsx from 'clsx'; +import * as React from 'react'; + +import { ClassNames } from '../../DomUtils/classNames'; +import { EmojiStyle } from '../../types/exposedTypes'; + +export function EmojiImg({ + emojiName, + style, + lazyLoad = false, + imgUrl, + onError +}: { + emojiName: string; + emojiStyle: EmojiStyle; + style: React.CSSProperties; + lazyLoad?: boolean; + imgUrl: string; + onError: () => void; +}) { + return ( + {emojiName} + ); +} diff --git a/src/components/emoji/ExportedEmoji.tsx b/src/components/emoji/ExportedEmoji.tsx index 31c45a0e..45f9e34a 100644 --- a/src/components/emoji/ExportedEmoji.tsx +++ b/src/components/emoji/ExportedEmoji.tsx @@ -2,14 +2,15 @@ import * as React from 'react'; import { EmojiStyle } from '../../types/exposedTypes'; -import { GetEmojiUrl, ViewOnlyEmoji } from './Emoji'; +import { GetEmojiUrl } from './BaseEmojiProps'; +import { ViewOnlyEmoji } from './ViewOnlyEmoji'; export function ExportedEmoji({ unified, size = 32, emojiStyle = EmojiStyle.APPLE, lazyLoad = false, - getEmojiUrl, + getEmojiUrl }: { unified: string; emojiStyle?: EmojiStyle; diff --git a/src/components/emoji/NativeEmoji.tsx b/src/components/emoji/NativeEmoji.tsx new file mode 100644 index 00000000..f682fb2e --- /dev/null +++ b/src/components/emoji/NativeEmoji.tsx @@ -0,0 +1,23 @@ +import clsx from 'clsx'; +import * as React from 'react'; + +import { ClassNames } from '../../DomUtils/classNames'; +import { parseNativeEmoji } from '../../dataUtils/parseNativeEmoji'; + +export function NativeEmoji({ + unified, + style +}: { + unified: string; + style: React.CSSProperties; +}) { + return ( + + {parseNativeEmoji(unified)} + + ); +} diff --git a/src/components/emoji/ViewOnlyEmoji.tsx b/src/components/emoji/ViewOnlyEmoji.tsx new file mode 100644 index 00000000..2685465a --- /dev/null +++ b/src/components/emoji/ViewOnlyEmoji.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; + +import { + emojiByUnified, + emojiName, + emojiUrlByUnified +} from '../../dataUtils/emojiSelectors'; +import { isCustomEmoji } from '../../typeRefinements/typeRefinements'; +import { EmojiStyle } from '../../types/exposedTypes'; +import { useEmojisThatFailedToLoadState } from '../context/PickerContext'; + +import { BaseEmojiProps } from './BaseEmojiProps'; +import { EmojiImg } from './EmojiImg'; +import { NativeEmoji } from './NativeEmoji'; + +export function ViewOnlyEmoji({ + emoji, + unified, + emojiStyle, + size, + lazyLoad, + getEmojiUrl = emojiUrlByUnified +}: BaseEmojiProps) { + const [, setEmojisThatFailedToLoad] = useEmojisThatFailedToLoadState(); + + const style = {} as React.CSSProperties; + if (size) { + style.width = style.height = style.fontSize = `${size}px`; + } + + const emojiToRender = emoji ? emoji : emojiByUnified(unified); + + if (!emojiToRender) { + return null; + } + + if (isCustomEmoji(emojiToRender)) { + return ( + + ); + } + + return ( + <> + {emojiStyle === EmojiStyle.NATIVE ? ( + + ) : ( + + )} + + ); + + function onError() { + setEmojisThatFailedToLoad(prev => new Set(prev).add(unified)); + } +} diff --git a/src/components/footer/Preview.tsx b/src/components/footer/Preview.tsx index 26676bf2..0067e3d8 100644 --- a/src/components/footer/Preview.tsx +++ b/src/components/footer/Preview.tsx @@ -16,9 +16,9 @@ import { useIsSkinToneInPreview } from '../../hooks/useShouldShowSkinTonePicker' import Flex from '../Layout/Flex'; import Space from '../Layout/Space'; import { useEmojiVariationPickerState } from '../context/PickerContext'; -import { ViewOnlyEmoji } from '../emoji/Emoji'; import './Preview.css'; import { SkinTonePickerMenu } from '../header/SkinTonePicker'; +import { ViewOnlyEmoji } from '../emoji/ViewOnlyEmoji'; export function Preview() { const previewConfig = usePreviewConfig(); diff --git a/src/components/navigation/CategoryNavigation.css b/src/components/navigation/CategoryNavigation.css index b2381842..9f436e49 100644 --- a/src/components/navigation/CategoryNavigation.css +++ b/src/components/navigation/CategoryNavigation.css @@ -87,6 +87,9 @@ aside.EmojiPickerReact.epr-main:has(input:placeholder-shown) .epr-category-nav { .EmojiPickerReact button.epr-cat-btn.epr-icn-suggested { background-position-x: calc(var(--epr-category-navigation-button-size) * -8); } +.EmojiPickerReact button.epr-cat-btn.epr-icn-custom { + background-position-x: calc(var(--epr-category-navigation-button-size) * -9); +} .EmojiPickerReact button.epr-cat-btn.epr-icn-activities { background-position-x: calc(var(--epr-category-navigation-button-size) * -4); } diff --git a/src/components/navigation/CategoryNavigation.tsx b/src/components/navigation/CategoryNavigation.tsx index 95e1fe3d..5ec5aab4 100644 --- a/src/components/navigation/CategoryNavigation.tsx +++ b/src/components/navigation/CategoryNavigation.tsx @@ -12,6 +12,8 @@ import { useCategoriesConfig } from '../../config/useConfig'; import { useActiveCategoryScrollDetection } from '../../hooks/useActiveCategoryScrollDetection'; import useIsSearchMode from '../../hooks/useIsSearchMode'; import { useScrollCategoryIntoView } from '../../hooks/useScrollCategoryIntoView'; +import { useShouldHideCustomEmojis } from '../../hooks/useShouldHideCustomEmojis'; +import { isCustomCategory } from '../../typeRefinements/typeRefinements'; import { Button } from '../atoms/Button'; import { useCategoryNavigationRef } from '../context/ElementRefContext'; @@ -23,11 +25,17 @@ export function CategoryNavigation() { const categoriesConfig = useCategoriesConfig(); const CategoryNavigationRef = useCategoryNavigationRef(); + const hideCustomCategory = useShouldHideCustomEmojis(); return (
{categoriesConfig.map(categoryConfig => { const category = categoryFromCategoryConfig(categoryConfig); + + if (isCustomCategory(categoryConfig) && hideCustomCategory) { + return null; + } + return (