Skip to content

Commit 476dbc3

Browse files
authored
feat: add emoji-click-sync event as Safari workaround (#500)
1 parent 0f3d347 commit 476dbc3

6 files changed

Lines changed: 181 additions & 108 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ A lightweight emoji picker, distributed as a web component.
3939
+ [Picker](#picker)
4040
- [Events](#events)
4141
* [`emoji-click`](#emoji-click)
42+
* [`emoji-click-sync`](#emoji-click-sync)
4243
* [`skin-tone-change`](#skin-tone-change)
4344
- [Internationalization](#internationalization)
4445
* [Built-in translations](#built-in-translations)
@@ -406,6 +407,44 @@ Note that `unicode` will represent whatever the emoji should look like
406407
with the given `skinTone`. If the `skinTone` is 0, or if the emoji has
407408
no skin tones, then no skin tone is applied to `unicode`.
408409

410+
##### `emoji-click-sync`
411+
412+
> [!NOTE]
413+
> Most likely, you should only use this event if you need to copy an emoji to the clipboard,
414+
> due to [a Safari bug](https://github.com/nolanlawson/emoji-picker-element/issues/281#issuecomment-3256832247).
415+
416+
The `emoji-click-sync` event is exactly the same as `emoji-click`, except that the event is fired
417+
synchronously relative to the original `click` event, and the `event.detail` is a `Promise` that must be `await`ed:
418+
419+
```js
420+
picker.addEventListener('emoji-click-sync', async event => {
421+
console.log(await event.detail); // same as above
422+
});
423+
```
424+
425+
This is useful to work around [a Safari bug](https://github.com/nolanlawson/emoji-picker-element/issues/281#issuecomment-3256832247)
426+
when using the [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard), which causes
427+
the error `NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.`
428+
This error occurs due to Safari not recognizing that the `emoji-click` event is user-initiated due to the presence of
429+
`await`s for IndexedDB data.
430+
431+
Example of correct usage to copy an emoji to the clipboard:
432+
433+
```js
434+
picker.addEventListener('emoji-click-sync', async event => {
435+
try {
436+
await navigator.clipboard.write([new ClipboardItem({
437+
'text/plain': e.detail.then(({ unicode }) => unicode),
438+
})]);
439+
console.log('Copied to clipboard!');
440+
} catch (err) {
441+
console.log('Failed to copy to clipboard', err);
442+
}
443+
});
444+
```
445+
446+
If you don't need to work around the Safari bug, then you can just use the `emoji-click` event instead.
447+
409448
##### `skin-tone-change`
410449

411450
This event is fired whenever the user selects a new skin tone. Example format:

custom-elements.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
{
9090
"name": "emoji-click",
9191
"description": "The `emoji-click` event is fired when an emoji is selected by the user."
92+
},
93+
{
94+
"name": "emoji-click-sync",
95+
"description": "The `emoji-click-sync` event is fired synchronously when an emoji is selected by the user. Use this when copying data to the clipboard using the Clipboard API to work around a Safari bug."
9296
}
9397
],
9498
"cssProperties": [

docs/index.html

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@
8080
details {
8181
display: flex;
8282
flex-direction: column;
83-
gap: 20px;
83+
}
84+
85+
summary {
86+
padding-bottom: 20px;
8487
}
8588

8689
footer {
@@ -291,22 +294,36 @@ <h1>emoji-picker-element</h1>
291294
const alert = $('[role="alert"]')
292295
const pre = $('pre')
293296
const summary = $('summary')
294-
const onEvent = async e => {
295-
console.log(e)
296-
alert.classList.add('shown')
297-
pre.innerHTML = `Event: ${JSON.stringify(e.type)}\n\nData:\n\n${JSON.stringify(e.detail, null, 2)}`
297+
298+
const copyToClipboard = async e => {
299+
// fun workaround for a safari bug: https://github.com/nolanlawson/emoji-picker-element/issues/281
300+
// use emoji-click-sync and then write the promise to the navigator.clipboard to avoid a NotAllowedError
301+
// normal usage is to use the emoji-click event instead
298302
try {
299303
await navigator.clipboard.write([new ClipboardItem({
300-
'text/plain': e.detail.unicode,
301-
})]);
304+
'text/plain': e.detail.then(({ unicode }) => unicode),
305+
})])
302306
summary.textContent = `Copied to clipboard! Details:`
303307
} catch (err) {
304308
console.log(err)
305309
summary.textContent = `Failed to write to the clipboard! Event details:`
306310
}
307311
}
308-
picker.addEventListener('emoji-click', onEvent)
309-
picker.addEventListener('skin-tone-change', onEvent)
312+
313+
const log = async e => {
314+
const detail = await e.detail
315+
alert.classList.add('shown')
316+
pre.innerHTML = JSON.stringify(detail, null, 2)
317+
}
318+
319+
picker.addEventListener('emoji-click-sync', async e => {
320+
await copyToClipboard(e)
321+
await log(e)
322+
})
323+
picker.addEventListener('skin-tone-change', async e => {
324+
summary.textContent = 'Skin tone changed! Details:'
325+
await log(e)
326+
})
310327

311328
$$('input[name=darkmode]').forEach(input => {
312329
input.addEventListener('change', e => {

shared.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,15 @@ declare type Modify<T, R> = Omit<T, keyof R> & R;
7878
export declare type EmojiClickEvent = Modify<UIEvent, {
7979
detail: EmojiClickEventDetail;
8080
}>;
81+
export declare type EmojiClickSyncEvent = Modify<UIEvent, {
82+
detail: Promise<EmojiClickEventDetail>;
83+
}>;
8184
export declare type SkinToneChangeEvent = Modify<UIEvent, {
8285
detail: SkinToneChangeEventDetail;
8386
}>;
8487
export interface EmojiPickerEventMap {
8588
"emoji-click": EmojiClickEvent;
89+
"emoji-click-sync": EmojiClickSyncEvent;
8690
"skin-tone-change": SkinToneChangeEvent;
8791
}
8892
export interface CustomEmoji {

src/picker/components/Picker/Picker.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -619,25 +619,32 @@ export function createRoot (shadowRoot, props) {
619619
}
620620
}
621621

622-
//
623-
// Handle user input on an emoji
624-
//
625-
626-
async function clickEmoji (unicodeOrName) {
622+
async function getDetailForClickEvent (unicodeOrName) {
627623
const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName)
628624
const emojiSummary = [...state.currentEmojis, ...state.currentFavorites]
629625
.find(_ => (_.id === unicodeOrName))
630626
const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone)
631627
await state.database.incrementFavoriteEmojiCount(unicodeOrName)
632-
fireEvent('emoji-click', {
628+
return {
633629
emoji,
634630
skinTone: state.currentSkinTone,
635631
...(skinTonedUnicode && { unicode: skinTonedUnicode }),
636632
...(emojiSummary.name && { name: emojiSummary.name })
637-
})
633+
}
634+
}
635+
636+
//
637+
// Handle user input on an emoji
638+
//
639+
async function clickEmoji (unicodeOrName) {
640+
const promiseForDetail = getDetailForClickEvent(unicodeOrName)
641+
// sync event to work around a safari bug: https://bugs.webkit.org/show_bug.cgi?id=222262
642+
fireEvent('emoji-click-sync', promiseForDetail)
643+
// async event for most normal use cases that don't need to work around the safari bug
644+
fireEvent('emoji-click', await promiseForDetail)
638645
}
639646

640-
async function onEmojiClick (event) {
647+
function onEmojiClick (event) {
641648
const { target } = event
642649
/* istanbul ignore if */
643650
if (!target.classList.contains('emoji')) {

test/spec/picker/Picker.test.js

Lines changed: 93 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -231,98 +231,100 @@ describe('Picker tests', () => {
231231
.toHaveLength(truncatedEmoji.filter(_ => _.group === 9).length))
232232
})
233233

234-
test('click emoji and get an event', async () => {
235-
let emoji
236-
picker.addEventListener('emoji-click', event => {
237-
emoji = event.detail
238-
})
239-
240-
getByRole('menuitem', { name: /😀/ }).click()
241-
await waitFor(() => checkEmojiEquals(emoji, {
242-
emoji: {
243-
annotation: 'grinning face',
244-
group: 0,
245-
shortcodes: ['grinning', 'grinning_face'],
246-
tags: [
247-
'cheerful',
248-
'cheery',
249-
'face',
250-
'grin',
251-
'grinning',
252-
'happy',
253-
'laugh',
254-
'nice',
255-
'smile',
256-
'smiling',
257-
'teeth'
258-
259-
],
260-
unicode: '😀',
261-
version: 1
262-
},
263-
skinTone: 0,
264-
unicode: '😀'
265-
}))
266-
267-
// choose a skin tone and then click an emoji where it would apply
268-
getByRole('button', { name: /Choose a skin tone/ }).click()
269-
await waitFor(() => expect(getByRole('option', { name: /Medium-Dark/ })).toBeVisible())
270-
getByRole('option', { name: /Medium-Dark/ }).click()
271-
await waitFor(
272-
() => expect(getByRole('button', { name: 'Choose a skin tone (currently Medium-Dark)' })).toBeVisible()
273-
)
274-
getByRole('tab', { name: /People/ }).click()
275-
await waitFor(() => expect(getByRole('menuitem', { name: /👍/ })).toBeVisible())
276-
getByRole('menuitem', { name: /👍/ }).click()
277-
await waitFor(() => checkEmojiEquals(emoji, {
278-
emoji: {
279-
annotation: 'thumbs up',
280-
group: 1,
281-
shortcodes: ['+1', 'thumbsup', 'yes'],
282-
tags: ['+1', 'good', 'hand', 'like', 'thumb', 'up', 'yes'],
283-
unicode: '👍️',
284-
version: 0.6,
285-
skins: [
286-
{ tone: 1, unicode: '👍🏻', version: 1 },
287-
{ tone: 2, unicode: '👍🏼', version: 1 },
288-
{ tone: 3, unicode: '👍🏽', version: 1 },
289-
{ tone: 4, unicode: '👍🏾', version: 1 },
290-
{ tone: 5, unicode: '👍🏿', version: 1 }
291-
]
292-
},
293-
skinTone: 4,
294-
unicode: '👍🏾'
295-
}))
234+
for (const sync of [false, true]) {
235+
test(`click emoji and get an event - ${sync ? 'sync' : 'async'}`, async () => {
236+
let emoji
237+
picker.addEventListener(sync ? 'emoji-click-sync' : 'emoji-click', async event => {
238+
emoji = sync ? await event.detail : event.detail
239+
})
296240

297-
// then click one that has no skins
298-
getByRole('tab', { name: /Smileys/ }).click()
299-
await waitFor(() => expect(getByRole('menuitem', { name: /😀/ })).toBeVisible())
300-
getByRole('menuitem', { name: /😀/ }).click()
301-
await waitFor(() => checkEmojiEquals(emoji, {
302-
emoji: {
303-
annotation: 'grinning face',
304-
group: 0,
305-
shortcodes: ['grinning', 'grinning_face'],
306-
tags: [
307-
'cheerful',
308-
'cheery',
309-
'face',
310-
'grin',
311-
'grinning',
312-
'happy',
313-
'laugh',
314-
'nice',
315-
'smile',
316-
'smiling',
317-
'teeth'
318-
],
319-
unicode: '😀',
320-
version: 1
321-
},
322-
skinTone: 4,
323-
unicode: '😀'
324-
}))
325-
})
241+
getByRole('menuitem', { name: /😀/ }).click()
242+
await waitFor(() => checkEmojiEquals(emoji, {
243+
emoji: {
244+
annotation: 'grinning face',
245+
group: 0,
246+
shortcodes: ['grinning', 'grinning_face'],
247+
tags: [
248+
'cheerful',
249+
'cheery',
250+
'face',
251+
'grin',
252+
'grinning',
253+
'happy',
254+
'laugh',
255+
'nice',
256+
'smile',
257+
'smiling',
258+
'teeth'
259+
260+
],
261+
unicode: '😀',
262+
version: 1
263+
},
264+
skinTone: 0,
265+
unicode: '😀'
266+
}))
267+
268+
// choose a skin tone and then click an emoji where it would apply
269+
getByRole('button', { name: /Choose a skin tone/ }).click()
270+
await waitFor(() => expect(getByRole('option', { name: /Medium-Dark/ })).toBeVisible())
271+
getByRole('option', { name: /Medium-Dark/ }).click()
272+
await waitFor(
273+
() => expect(getByRole('button', { name: 'Choose a skin tone (currently Medium-Dark)' })).toBeVisible()
274+
)
275+
getByRole('tab', { name: /People/ }).click()
276+
await waitFor(() => expect(getByRole('menuitem', { name: /👍/ })).toBeVisible())
277+
getByRole('menuitem', { name: /👍/ }).click()
278+
await waitFor(() => checkEmojiEquals(emoji, {
279+
emoji: {
280+
annotation: 'thumbs up',
281+
group: 1,
282+
shortcodes: ['+1', 'thumbsup', 'yes'],
283+
tags: ['+1', 'good', 'hand', 'like', 'thumb', 'up', 'yes'],
284+
unicode: '👍️',
285+
version: 0.6,
286+
skins: [
287+
{ tone: 1, unicode: '👍🏻', version: 1 },
288+
{ tone: 2, unicode: '👍🏼', version: 1 },
289+
{ tone: 3, unicode: '👍🏽', version: 1 },
290+
{ tone: 4, unicode: '👍🏾', version: 1 },
291+
{ tone: 5, unicode: '👍🏿', version: 1 }
292+
]
293+
},
294+
skinTone: 4,
295+
unicode: '👍🏾'
296+
}))
297+
298+
// then click one that has no skins
299+
getByRole('tab', { name: /Smileys/ }).click()
300+
await waitFor(() => expect(getByRole('menuitem', { name: /😀/ })).toBeVisible())
301+
getByRole('menuitem', { name: /😀/ }).click()
302+
await waitFor(() => checkEmojiEquals(emoji, {
303+
emoji: {
304+
annotation: 'grinning face',
305+
group: 0,
306+
shortcodes: ['grinning', 'grinning_face'],
307+
tags: [
308+
'cheerful',
309+
'cheery',
310+
'face',
311+
'grin',
312+
'grinning',
313+
'happy',
314+
'laugh',
315+
'nice',
316+
'smile',
317+
'smiling',
318+
'teeth'
319+
],
320+
unicode: '😀',
321+
version: 1
322+
},
323+
skinTone: 4,
324+
unicode: '😀'
325+
}))
326+
})
327+
}
326328

327329
test('press up/down on search input', async () => {
328330
type(getByRole('combobox'), 'monk')

0 commit comments

Comments
 (0)