Bluesky app fork with some witchin' additions 💫
1import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
2import EmojiPicker from '@emoji-mart/react'
3import {DropdownMenu} from 'radix-ui'
4
5import {useA11y} from '#/state/a11y'
6import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
7import {atoms as a, flatten} from '#/alf'
8import * as Menu from '../Menu'
9import {useWebPreloadEmoji} from './preload'
10import {
11 type Emoji,
12 type PickerProps,
13 type RootProps,
14 type TriggerProps,
15} from './types'
16
17export * from './types'
18
19const EmojiPickerContext = createContext<{
20 onEmojiSelect: (emoji: Emoji) => void
21 nextFocusRef: RootProps['nextFocusRef']
22} | null>(null)
23
24/**
25 * Provides emoji picker context and wraps children in a {@link Menu.Root}.
26 *
27 * On emoji select, fires a `textInputWebEmitter` event (for web text inputs
28 * that listen for emoji insertions) and forwards to the optional
29 * `onEmojiSelect` callback.
30 *
31 * @platform web
32 */
33export function Root({
34 children,
35 control,
36 onEmojiSelect,
37 preloadOnMount = true,
38 nextFocusRef,
39}: RootProps) {
40 useWebPreloadEmoji({immediate: preloadOnMount})
41
42 const value = useMemo(
43 () => ({
44 onEmojiSelect: (emoji: Emoji) => {
45 textInputWebEmitter.emit('emoji-inserted', emoji)
46
47 if (onEmojiSelect) onEmojiSelect(emoji)
48 },
49 nextFocusRef,
50 }),
51 [onEmojiSelect, nextFocusRef],
52 )
53
54 return (
55 <EmojiPickerContext value={value}>
56 <Menu.Root control={control}>{children}</Menu.Root>
57 </EmojiPickerContext>
58 )
59}
60
61/**
62 * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
63 * pattern.
64 *
65 * @platform web
66 */
67export function Trigger(props: TriggerProps) {
68 return <Menu.Trigger {...props} />
69}
70
71/**
72 * Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
73 *
74 * Holding Shift while selecting an emoji keeps the picker open for
75 * multi-select. Otherwise the menu closes after each selection.
76 *
77 * Must be rendered inside a {@link Root}.
78 *
79 * @platform web
80 */
81export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
82 const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
83 const {control} = Menu.useMenuContext()
84 const {reduceMotionEnabled} = useA11y()
85 const isShiftDown = useRef(false)
86
87 useEffect(() => {
88 const onKeyDown = (e: KeyboardEvent) => {
89 if (e.key === 'Shift') {
90 isShiftDown.current = true
91 }
92 }
93 const onKeyUp = (e: KeyboardEvent) => {
94 if (e.key === 'Shift') {
95 isShiftDown.current = false
96 }
97 }
98 window.addEventListener('keydown', onKeyDown, true)
99 window.addEventListener('keyup', onKeyUp, true)
100
101 return () => {
102 window.removeEventListener('keydown', onKeyDown, true)
103 window.removeEventListener('keyup', onKeyUp, true)
104 }
105 }, [])
106
107 return (
108 <DropdownMenu.Portal>
109 <DropdownMenu.Content
110 sideOffset={5}
111 collisionPadding={{left: 5, right: 5, bottom: 5}}
112 className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
113 onCloseAutoFocus={evt => {
114 if (!nextFocusRef) return
115 let element =
116 nextFocusRef instanceof Function
117 ? nextFocusRef()
118 : nextFocusRef.current
119 if (element) {
120 evt.preventDefault()
121 element.focus()
122 }
123 }}>
124 <div
125 onWheel={evt => evt.stopPropagation()}
126 style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
127 <EmojiPicker
128 autoFocus
129 onEmojiSelect={(emoji: Emoji) => {
130 onEmojiSelect(emoji)
131
132 if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
133 control.close()
134 }
135 }}
136 />
137 </div>
138 </DropdownMenu.Content>
139 </DropdownMenu.Portal>
140 )
141}
142
143function useEmojiPickerContext() {
144 const ctx = useContext(EmojiPickerContext)
145 if (!ctx)
146 throw new Error(
147 'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
148 )
149 return ctx
150}