✨ EmojiPicker component (#10249)
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import {type PickerProps, type RootProps, type TriggerProps} from './types'
|
||||
|
||||
export * from './types'
|
||||
|
||||
/**
|
||||
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
|
||||
*
|
||||
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
|
||||
* that listen for emoji insertions) and forwards to the optional
|
||||
* `onEmojiSelect` callback.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Root(_props: RootProps): React.ReactNode {
|
||||
throw new Error('EmojiPopup is not implemented on native')
|
||||
}
|
||||
|
||||
/**
|
||||
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
|
||||
* pattern.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Trigger(_props: TriggerProps): React.ReactNode {
|
||||
throw new Error('EmojiPopup is not implemented on native')
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
|
||||
*
|
||||
* Holding Shift while selecting an emoji keeps the picker open for
|
||||
* multi-select. Otherwise the menu closes after each selection.
|
||||
*
|
||||
* Must be rendered inside a {@link Root}.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Picker(_props: PickerProps): React.ReactNode {
|
||||
throw new Error('EmojiPopup is not implemented on native')
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
|
||||
import EmojiPicker from '@emoji-mart/react'
|
||||
import {DropdownMenu} from 'radix-ui'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import * as Menu from '../Menu'
|
||||
import {useWebPreloadEmoji} from './preload'
|
||||
import {
|
||||
type Emoji,
|
||||
type PickerProps,
|
||||
type RootProps,
|
||||
type TriggerProps,
|
||||
} from './types'
|
||||
|
||||
export * from './types'
|
||||
|
||||
const EmojiPickerContext = createContext<{
|
||||
onEmojiSelect: (emoji: Emoji) => void
|
||||
nextFocusRef: RootProps['nextFocusRef']
|
||||
} | null>(null)
|
||||
|
||||
/**
|
||||
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
|
||||
*
|
||||
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
|
||||
* that listen for emoji insertions) and forwards to the optional
|
||||
* `onEmojiSelect` callback.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Root({
|
||||
children,
|
||||
control,
|
||||
onEmojiSelect,
|
||||
preloadOnMount = true,
|
||||
nextFocusRef,
|
||||
}: RootProps) {
|
||||
useWebPreloadEmoji({immediate: preloadOnMount})
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
onEmojiSelect: (emoji: Emoji) => {
|
||||
textInputWebEmitter.emit('emoji-inserted', emoji)
|
||||
|
||||
if (onEmojiSelect) onEmojiSelect(emoji)
|
||||
},
|
||||
nextFocusRef,
|
||||
}),
|
||||
[onEmojiSelect, nextFocusRef],
|
||||
)
|
||||
|
||||
return (
|
||||
<EmojiPickerContext value={value}>
|
||||
<Menu.Root control={control}>{children}</Menu.Root>
|
||||
</EmojiPickerContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
|
||||
* pattern.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Trigger(props: TriggerProps) {
|
||||
return <Menu.Trigger {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
|
||||
*
|
||||
* Holding Shift while selecting an emoji keeps the picker open for
|
||||
* multi-select. Otherwise the menu closes after each selection.
|
||||
*
|
||||
* Must be rendered inside a {@link Root}.
|
||||
*
|
||||
* @platform web
|
||||
*/
|
||||
export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
|
||||
const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
|
||||
const {control} = Menu.useMenuContext()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const isShiftDown = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
isShiftDown.current = true
|
||||
}
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
isShiftDown.current = false
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('keyup', onKeyUp, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('keyup', onKeyUp, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={5}
|
||||
collisionPadding={{left: 5, right: 5, bottom: 5}}
|
||||
className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
|
||||
onCloseAutoFocus={evt => {
|
||||
if (!nextFocusRef) return
|
||||
let element =
|
||||
nextFocusRef instanceof Function
|
||||
? nextFocusRef()
|
||||
: nextFocusRef.current
|
||||
if (element) {
|
||||
evt.preventDefault()
|
||||
element.focus()
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
onWheel={evt => evt.stopPropagation()}
|
||||
style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
|
||||
<EmojiPicker
|
||||
autoFocus
|
||||
onEmojiSelect={(emoji: Emoji) => {
|
||||
onEmojiSelect(emoji)
|
||||
|
||||
if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function useEmojiPickerContext() {
|
||||
const ctx = useContext(EmojiPickerContext)
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
|
||||
)
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Native no-op. Emoji data preloading is only needed on web where the picker
|
||||
* uses `emoji-mart`.
|
||||
*/
|
||||
export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
|
||||
return () => Promise.resolve()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useCallback} from 'react'
|
||||
import {init} from 'emoji-mart'
|
||||
|
||||
/**
|
||||
* Only load the emoji picker data once per page load.
|
||||
*/
|
||||
let loadRequested = false
|
||||
|
||||
/**
|
||||
* Preloads emoji-mart data so the picker renders instantly when opened.
|
||||
*
|
||||
* Returns a function that can be called manually to trigger preloading (e.g.
|
||||
* on hover). When `immediate` is `true`, preloading starts on mount.
|
||||
*
|
||||
* Data is only fetched once per page load — subsequent calls are no-ops.
|
||||
*
|
||||
* @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
|
||||
*/
|
||||
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
|
||||
const preload = useCallback(async () => {
|
||||
if (loadRequested) return
|
||||
loadRequested = true
|
||||
try {
|
||||
const data = (await import('@emoji-mart/data')).default
|
||||
init({data})
|
||||
} catch (e) {}
|
||||
}, [])
|
||||
if (immediate) preload()
|
||||
return preload
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {type DialogControlProps} from '../Dialog'
|
||||
import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
|
||||
|
||||
/**
|
||||
* Represents an emoji selected from the picker. Sourced from the `emoji-mart`
|
||||
* library's selection data.
|
||||
*/
|
||||
export type Emoji = {
|
||||
aliases?: string[]
|
||||
emoticons: string[]
|
||||
id: string
|
||||
keywords: string[]
|
||||
name: string
|
||||
/** The native unicode character for the emoji, e.g. "😀" */
|
||||
native: string
|
||||
shortcodes?: string
|
||||
/** The unicode codepoint, e.g. "1f600" */
|
||||
unified: string
|
||||
/** Skin tone variant (1–6), if applicable */
|
||||
skin?: number
|
||||
}
|
||||
|
||||
type FocusableElement = {focus: () => void}
|
||||
|
||||
export interface RootProps {
|
||||
children: React.ReactNode
|
||||
control?: DialogControlProps
|
||||
/**
|
||||
* Called when the user selects an emoji. On web this fires in addition to
|
||||
* the `textInputWebEmitter` event, so callers that only need the text
|
||||
* insertion can omit this.
|
||||
*/
|
||||
onEmojiSelect?: (emoji: Emoji) => void
|
||||
/**
|
||||
* When `true` (default), preloads emoji data as soon as the component
|
||||
* mounts so the picker opens instantly. Set to `false` to defer loading
|
||||
* until the picker is actually opened.
|
||||
*/
|
||||
preloadOnMount?: boolean
|
||||
/**
|
||||
* Element to return focus to when the picker closes. Accepts either a ref
|
||||
* or a getter function.
|
||||
*/
|
||||
nextFocusRef?:
|
||||
| React.RefObject<FocusableElement | null>
|
||||
| (() => FocusableElement | null | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the trigger button that opens the emoji picker. Extends
|
||||
* {@link MenuTriggerProps} — accepts the same render-prop children pattern.
|
||||
*/
|
||||
export interface TriggerProps extends MenuTriggerProps {}
|
||||
|
||||
/**
|
||||
* Props for the picker panel itself.
|
||||
*/
|
||||
export interface PickerProps {
|
||||
/**
|
||||
* When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
keepOpenWhenShiftHeld?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user