|
|
@ -14,6 +14,7 @@ import { |
|
|
|
HIDE_VARIANT_POPUP, |
|
|
|
PICKER_HIDDEN |
|
|
|
} from './events'; |
|
|
|
import { lazyLoadEmoji } from './lazyLoad'; |
|
|
|
import { EmojiPreview } from './preview'; |
|
|
|
import { Search } from './search'; |
|
|
|
import { createElement, empty } from './util'; |
|
|
@ -26,10 +27,8 @@ import { |
|
|
|
CLASS_PICKER_CONTENT, |
|
|
|
CLASS_EMOJI, |
|
|
|
CLASS_SEARCH_FIELD, |
|
|
|
CLASS_VARIANT_OVERLAY, |
|
|
|
CLASS_WRAPPER, |
|
|
|
CLASS_OVERLAY, |
|
|
|
CLASS_CUSTOM_EMOJI, |
|
|
|
CLASS_PLUGIN_CONTAINER |
|
|
|
} from './classes'; |
|
|
|
|
|
|
@ -37,14 +36,15 @@ import { |
|
|
|
EmojiButtonOptions, |
|
|
|
I18NStrings, |
|
|
|
EmojiRecord, |
|
|
|
EmojiTheme |
|
|
|
EmojiSelection, |
|
|
|
EmojiTheme, |
|
|
|
FixedPosition |
|
|
|
} from './types'; |
|
|
|
import { EmojiArea } from './emojiArea'; |
|
|
|
|
|
|
|
const twemojiOptions = { |
|
|
|
ext: '.svg', |
|
|
|
folder: 'svg' |
|
|
|
}; |
|
|
|
const MOBILE_BREAKPOINT = 450; |
|
|
|
|
|
|
|
const STYLE_TWEMOJI = 'twemoji'; |
|
|
|
|
|
|
|
const DEFAULT_OPTIONS: EmojiButtonOptions = { |
|
|
|
position: 'auto', |
|
|
@ -72,6 +72,10 @@ const DEFAULT_OPTIONS: EmojiButtonOptions = { |
|
|
|
'flags' |
|
|
|
], |
|
|
|
style: 'native', |
|
|
|
twemojiOptions: { |
|
|
|
ext: '.svg', |
|
|
|
folder: 'svg' |
|
|
|
}, |
|
|
|
emojisPerRow: 8, |
|
|
|
rows: 6, |
|
|
|
emojiSize: '1.8em', |
|
|
@ -94,6 +98,7 @@ export class EmojiButton { |
|
|
|
private focusTrap: FocusTrap; |
|
|
|
|
|
|
|
private emojiArea: EmojiArea; |
|
|
|
private search: Search; |
|
|
|
|
|
|
|
private overlay?: HTMLElement; |
|
|
|
|
|
|
@ -124,18 +129,30 @@ export class EmojiButton { |
|
|
|
this.buildPicker(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Adds an event listener to the picker. |
|
|
|
* |
|
|
|
* @param event The name of the event to listen for |
|
|
|
* @param callback The function to call when the event is fired |
|
|
|
*/ |
|
|
|
on(event: string, callback: (arg: string) => void): void { |
|
|
|
this.publicEvents.on(event, callback); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Removes an event listener from the picker. |
|
|
|
* |
|
|
|
* @param event The name of the event |
|
|
|
* @param callback The callback to remove |
|
|
|
*/ |
|
|
|
off(event: string, callback: (arg: string) => void): void { |
|
|
|
this.publicEvents.off(event, callback); |
|
|
|
} |
|
|
|
|
|
|
|
private buildPicker(): void { |
|
|
|
this.pickerEl = createElement('div', CLASS_PICKER); |
|
|
|
this.updateTheme(this.theme); |
|
|
|
|
|
|
|
/** |
|
|
|
* Sets any CSS variable values that need to be set. |
|
|
|
*/ |
|
|
|
private setStyleProperties(): void { |
|
|
|
if (!this.options.showAnimation) { |
|
|
|
this.pickerEl.style.setProperty('--animation-duration', '0s'); |
|
|
|
} |
|
|
@ -145,11 +162,13 @@ export class EmojiButton { |
|
|
|
'--emoji-per-row', |
|
|
|
this.options.emojisPerRow.toString() |
|
|
|
); |
|
|
|
|
|
|
|
this.options.rows && |
|
|
|
this.pickerEl.style.setProperty( |
|
|
|
'--row-count', |
|
|
|
this.options.rows.toString() |
|
|
|
); |
|
|
|
|
|
|
|
this.options.emojiSize && |
|
|
|
this.pickerEl.style.setProperty('--emoji-size', this.options.emojiSize); |
|
|
|
|
|
|
@ -167,17 +186,147 @@ export class EmojiButton { |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
this.focusTrap = createFocusTrap(this.pickerEl as HTMLElement, { |
|
|
|
clickOutsideDeactivates: true, |
|
|
|
initialFocus: |
|
|
|
this.options.showSearch && this.options.autoFocusSearch |
|
|
|
? '.emoji-picker__search' |
|
|
|
: '.emoji-picker__emoji[tabindex="0"]' |
|
|
|
/** |
|
|
|
* Shows the search results in the main emoji area. |
|
|
|
* |
|
|
|
* @param searchResults The element containing the search results. |
|
|
|
*/ |
|
|
|
private showSearchResults(searchResults: HTMLElement): void { |
|
|
|
empty(this.pickerContent); |
|
|
|
searchResults.classList.add('search-results'); |
|
|
|
this.pickerContent.appendChild(searchResults); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Hides the search results and resets the picker. |
|
|
|
*/ |
|
|
|
private hideSearchResults(): void { |
|
|
|
if (this.pickerContent.firstChild !== this.emojiArea.container) { |
|
|
|
empty(this.pickerContent); |
|
|
|
this.pickerContent.appendChild(this.emojiArea.container); |
|
|
|
} |
|
|
|
|
|
|
|
this.emojiArea.reset(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Emits a selected emoji event. |
|
|
|
* @param param0 The selected emoji and show variants flag |
|
|
|
*/ |
|
|
|
private async emitEmoji({ |
|
|
|
emoji, |
|
|
|
showVariants |
|
|
|
}: { |
|
|
|
emoji: EmojiRecord; |
|
|
|
showVariants: boolean; |
|
|
|
}): Promise<void> { |
|
|
|
if ( |
|
|
|
(emoji as EmojiRecord).variations && |
|
|
|
showVariants && |
|
|
|
this.options.showVariants |
|
|
|
) { |
|
|
|
this.showVariantPopup(emoji as EmojiRecord); |
|
|
|
} else { |
|
|
|
setTimeout(() => this.emojiArea.updateRecents()); |
|
|
|
|
|
|
|
let eventData: EmojiSelection; |
|
|
|
if (emoji.custom) { |
|
|
|
eventData = this.emitCustomEmoji(emoji); |
|
|
|
} else if (this.options.style === STYLE_TWEMOJI) { |
|
|
|
eventData = await this.emitTwemoji(emoji); |
|
|
|
} else { |
|
|
|
eventData = this.emitNativeEmoji(emoji); |
|
|
|
} |
|
|
|
|
|
|
|
this.publicEvents.emit(EMOJI, eventData); |
|
|
|
|
|
|
|
if (this.options.autoHide) { |
|
|
|
this.hidePicker(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Emits a native emoji record. |
|
|
|
* @param emoji The selected emoji |
|
|
|
*/ |
|
|
|
private emitNativeEmoji(emoji: EmojiRecord): EmojiSelection { |
|
|
|
return { |
|
|
|
emoji: emoji.emoji, |
|
|
|
name: emoji.name |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Emits a custom emoji record. |
|
|
|
* @param emoji The selected emoji |
|
|
|
*/ |
|
|
|
private emitCustomEmoji(emoji: EmojiRecord): EmojiSelection { |
|
|
|
return { |
|
|
|
url: emoji.emoji, |
|
|
|
name: emoji.name, |
|
|
|
custom: true |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Emits a Twemoji emoji record. |
|
|
|
* @param emoji The selected emoji |
|
|
|
*/ |
|
|
|
private emitTwemoji(emoji: EmojiRecord): Promise<EmojiSelection> { |
|
|
|
return new Promise(resolve => { |
|
|
|
twemoji.parse(emoji.emoji, { |
|
|
|
...this.options.twemojiOptions, |
|
|
|
callback: (icon, { base, size, ext }: any) => { |
|
|
|
const imageUrl = `${base}${size}/${icon}${ext}`; |
|
|
|
resolve({ |
|
|
|
url: imageUrl, |
|
|
|
emoji: emoji.emoji, |
|
|
|
name: emoji.name |
|
|
|
}); |
|
|
|
|
|
|
|
return imageUrl; |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
this.pickerContent = createElement('div', CLASS_PICKER_CONTENT); |
|
|
|
/** |
|
|
|
* Builds the search UI. |
|
|
|
*/ |
|
|
|
private buildSearch(): void { |
|
|
|
if (this.options.showSearch) { |
|
|
|
this.search = new Search( |
|
|
|
this.events, |
|
|
|
this.i18n, |
|
|
|
this.options, |
|
|
|
this.options.emojiData.emoji, |
|
|
|
(this.options.categories || []).map(category => |
|
|
|
this.options.emojiData.categories.indexOf(category) |
|
|
|
) |
|
|
|
); |
|
|
|
|
|
|
|
this.pickerEl.appendChild(this.search.render()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Builds the emoji preview area. |
|
|
|
*/ |
|
|
|
private buildPreview(): void { |
|
|
|
if (this.options.showPreview) { |
|
|
|
this.pickerEl.appendChild( |
|
|
|
new EmojiPreview(this.events, this.options).render() |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Initializes any plugins that were specified. |
|
|
|
*/ |
|
|
|
private initPlugins(): void { |
|
|
|
if (this.options.plugins) { |
|
|
|
const pluginContainer = createElement('div', CLASS_PLUGIN_CONTAINER); |
|
|
|
|
|
|
@ -192,99 +341,46 @@ export class EmojiButton { |
|
|
|
|
|
|
|
this.pickerEl.appendChild(pluginContainer); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (this.options.showSearch) { |
|
|
|
const searchContainer = new Search( |
|
|
|
this.events, |
|
|
|
this.i18n, |
|
|
|
this.options, |
|
|
|
this.options.emojiData.emoji, |
|
|
|
(this.options.categories || []).map(category => |
|
|
|
this.options.emojiData.categories.indexOf(category) |
|
|
|
) |
|
|
|
).render(); |
|
|
|
this.pickerEl.appendChild(searchContainer); |
|
|
|
} |
|
|
|
/** |
|
|
|
* Initializes the emoji picker's focus trap. |
|
|
|
*/ |
|
|
|
private initFocusTrap(): void { |
|
|
|
this.focusTrap = createFocusTrap(this.pickerEl as HTMLElement, { |
|
|
|
clickOutsideDeactivates: true, |
|
|
|
initialFocus: |
|
|
|
this.options.showSearch && this.options.autoFocusSearch |
|
|
|
? '.emoji-picker__search' |
|
|
|
: '.emoji-picker__emoji[tabindex="0"]' |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
this.pickerEl.appendChild(this.pickerContent); |
|
|
|
/** |
|
|
|
* Builds the emoji picker. |
|
|
|
*/ |
|
|
|
private buildPicker(): void { |
|
|
|
this.pickerEl = createElement('div', CLASS_PICKER); |
|
|
|
this.pickerEl.classList.add(this.theme); |
|
|
|
|
|
|
|
this.emojiArea = new EmojiArea(this.events, this.i18n, this.options); |
|
|
|
this.pickerContent.appendChild(this.emojiArea.render()); |
|
|
|
this.setStyleProperties(); |
|
|
|
this.initFocusTrap(); |
|
|
|
|
|
|
|
this.events.on(SHOW_SEARCH_RESULTS, (searchResults: HTMLElement) => { |
|
|
|
empty(this.pickerContent); |
|
|
|
searchResults.classList.add('search-results'); |
|
|
|
this.pickerContent.appendChild(searchResults); |
|
|
|
}); |
|
|
|
this.pickerContent = createElement('div', CLASS_PICKER_CONTENT); |
|
|
|
|
|
|
|
this.events.on(HIDE_SEARCH_RESULTS, () => { |
|
|
|
if (this.pickerContent.firstChild !== this.emojiArea.container) { |
|
|
|
empty(this.pickerContent); |
|
|
|
this.pickerContent.appendChild(this.emojiArea.container); |
|
|
|
} |
|
|
|
this.initPlugins(); |
|
|
|
this.buildSearch(); |
|
|
|
|
|
|
|
this.emojiArea.reset(); |
|
|
|
}); |
|
|
|
this.pickerEl.appendChild(this.pickerContent); |
|
|
|
|
|
|
|
if (this.options.showPreview) { |
|
|
|
this.pickerEl.appendChild( |
|
|
|
new EmojiPreview(this.events, this.options).render() |
|
|
|
); |
|
|
|
} |
|
|
|
this.emojiArea = new EmojiArea(this.events, this.i18n, this.options); |
|
|
|
this.pickerContent.appendChild(this.emojiArea.render()); |
|
|
|
|
|
|
|
let variantPopup: HTMLElement | null; |
|
|
|
|
|
|
|
this.events.on( |
|
|
|
EMOJI, |
|
|
|
({ |
|
|
|
emoji, |
|
|
|
showVariants |
|
|
|
}: { |
|
|
|
emoji: EmojiRecord; |
|
|
|
showVariants: boolean; |
|
|
|
}) => { |
|
|
|
if ( |
|
|
|
(emoji as EmojiRecord).variations && |
|
|
|
showVariants && |
|
|
|
this.options.showVariants |
|
|
|
) { |
|
|
|
this.showVariantPopup(emoji as EmojiRecord); |
|
|
|
} else { |
|
|
|
if (variantPopup && variantPopup.parentNode === this.pickerEl) { |
|
|
|
this.events.emit(HIDE_VARIANT_POPUP); |
|
|
|
} |
|
|
|
|
|
|
|
setTimeout(() => this.emojiArea.updateRecents()); |
|
|
|
|
|
|
|
if (emoji.custom) { |
|
|
|
this.publicEvents.emit(EMOJI, { |
|
|
|
url: emoji.emoji, |
|
|
|
name: emoji.name, |
|
|
|
custom: true |
|
|
|
}); |
|
|
|
} else if (this.options.style === 'twemoji') { |
|
|
|
twemoji.parse(emoji.emoji, { |
|
|
|
...twemojiOptions, |
|
|
|
callback: (icon, options) => { |
|
|
|
this.publicEvents.emit(EMOJI, { |
|
|
|
url: `${options.base}${options.size}/${icon}${options.ext}`, |
|
|
|
emoji: emoji.emoji, |
|
|
|
name: emoji.name |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
} else { |
|
|
|
this.publicEvents.emit(EMOJI, { |
|
|
|
emoji: emoji.emoji, |
|
|
|
name: emoji.name |
|
|
|
}); |
|
|
|
} |
|
|
|
if (this.options.autoHide) { |
|
|
|
this.hidePicker(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
); |
|
|
|
this.events.on(SHOW_SEARCH_RESULTS, this.showSearchResults.bind(this)); |
|
|
|
this.events.on(HIDE_SEARCH_RESULTS, this.hideSearchResults.bind(this)); |
|
|
|
this.events.on(EMOJI, this.emitEmoji.bind(this)); |
|
|
|
|
|
|
|
this.buildPreview(); |
|
|
|
|
|
|
|
this.wrapper = createElement('div', CLASS_WRAPPER); |
|
|
|
this.wrapper.appendChild(this.pickerEl); |
|
|
@ -301,7 +397,12 @@ export class EmojiButton { |
|
|
|
this.observeForLazyLoad(); |
|
|
|
} |
|
|
|
|
|
|
|
private showVariantPopup(emoji: EmojiRecord) { |
|
|
|
/** |
|
|
|
* Shows the variant popup for an emoji. |
|
|
|
* |
|
|
|
* @param emoji The emoji whose variants are to be shown. |
|
|
|
*/ |
|
|
|
private showVariantPopup(emoji: EmojiRecord): void { |
|
|
|
const variantPopup = new VariantPopup( |
|
|
|
this.events, |
|
|
|
emoji, |
|
|
@ -324,62 +425,73 @@ export class EmojiButton { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
private observeForLazyLoad() { |
|
|
|
const onChange = changes => { |
|
|
|
const visibleElements = Array.prototype.filter |
|
|
|
.call(changes, change => { |
|
|
|
return change.intersectionRatio > 0; |
|
|
|
}) |
|
|
|
.map(entry => entry.target); |
|
|
|
|
|
|
|
visibleElements.forEach(element => { |
|
|
|
if (!element.dataset.loaded) { |
|
|
|
if (element.dataset.custom) { |
|
|
|
const img = createElement( |
|
|
|
'img', |
|
|
|
CLASS_CUSTOM_EMOJI |
|
|
|
) as HTMLImageElement; |
|
|
|
img.src = element.dataset.emoji; |
|
|
|
element.innerText = ''; |
|
|
|
element.appendChild(img); |
|
|
|
element.dataset.loaded = true; |
|
|
|
element.style.opacity = 1; |
|
|
|
} else if (this.options.style === 'twemoji') { |
|
|
|
element.innerHTML = twemoji.parse( |
|
|
|
element.dataset.emoji, |
|
|
|
twemojiOptions |
|
|
|
); |
|
|
|
element.dataset.loaded = true; |
|
|
|
element.style.opacity = '1'; |
|
|
|
} |
|
|
|
/** |
|
|
|
* Initializes the IntersectionObserver for lazy loading emoji images |
|
|
|
* as they are scrolled into view. |
|
|
|
*/ |
|
|
|
private observeForLazyLoad(): void { |
|
|
|
this.observer = new IntersectionObserver( |
|
|
|
this.handleIntersectionChange.bind(this), |
|
|
|
{ |
|
|
|
root: this.emojiArea.emojis |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
this.emojiArea.emojis |
|
|
|
.querySelectorAll(`.${CLASS_EMOJI}`) |
|
|
|
.forEach((element: Element) => { |
|
|
|
if (this.shouldLazyLoad(element as HTMLElement)) { |
|
|
|
this.observer.observe(element); |
|
|
|
} |
|
|
|
}); |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
this.observer = new IntersectionObserver(onChange, { |
|
|
|
root: this.emojiArea.emojis |
|
|
|
}); |
|
|
|
/** |
|
|
|
* IntersectionObserver callback that triggers lazy loading of emojis |
|
|
|
* that need it. |
|
|
|
* |
|
|
|
* @param entries The entries observed by the IntersectionObserver. |
|
|
|
*/ |
|
|
|
private handleIntersectionChange(entries: IntersectionObserverEntry[]): void { |
|
|
|
Array.prototype.filter |
|
|
|
.call( |
|
|
|
entries, |
|
|
|
(entry: IntersectionObserverEntry) => entry.intersectionRatio > 0 |
|
|
|
) |
|
|
|
.map((entry: IntersectionObserverEntry) => entry.target) |
|
|
|
.forEach((element: Element) => { |
|
|
|
lazyLoadEmoji(element as HTMLElement, this.options); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
const emojiElements = this.emojiArea.emojis.querySelectorAll( |
|
|
|
`.${CLASS_EMOJI}` |
|
|
|
/** |
|
|
|
* Determines whether or not an emoji should be lazily loaded. |
|
|
|
* |
|
|
|
* @param element The element containing the emoji. |
|
|
|
* @return true if the emoji should be lazily loaded, false if not. |
|
|
|
*/ |
|
|
|
private shouldLazyLoad(element: HTMLElement): boolean { |
|
|
|
return ( |
|
|
|
this.options.style === STYLE_TWEMOJI || element.dataset.custom === 'true' |
|
|
|
); |
|
|
|
|
|
|
|
emojiElements.forEach(element => { |
|
|
|
if ( |
|
|
|
this.options.style === 'twemoji' || |
|
|
|
(element as HTMLElement).dataset.custom === 'true' |
|
|
|
) { |
|
|
|
this.observer.observe(element); |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handles a click on the document, so that the picker is hidden |
|
|
|
* if the mouse is clicked outside of it. |
|
|
|
* |
|
|
|
* @param event The MouseEvent that was dispatched. |
|
|
|
*/ |
|
|
|
private onDocumentClick(event: MouseEvent): void { |
|
|
|
if (!this.pickerEl.contains(event.target as Node)) { |
|
|
|
this.hidePicker(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Destroys the picker. Once this is called, the picker can no longer |
|
|
|
* be shown. |
|
|
|
*/ |
|
|
|
destroyPicker(): void { |
|
|
|
this.events.off(EMOJI); |
|
|
|
this.events.off(HIDE_VARIANT_POPUP); |
|
|
@ -399,6 +511,9 @@ export class EmojiButton { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Hides, but does not destroy, the picker. |
|
|
|
*/ |
|
|
|
hidePicker(): void { |
|
|
|
this.hideInProgress = true; |
|
|
|
this.focusTrap.deactivate(); |
|
|
@ -417,6 +532,9 @@ export class EmojiButton { |
|
|
|
); |
|
|
|
|
|
|
|
this.pickerEl.classList.add('hiding'); |
|
|
|
|
|
|
|
// Let the transition finish before actually hiding the picker so that
|
|
|
|
// the user sees the hide animation.
|
|
|
|
setTimeout( |
|
|
|
() => { |
|
|
|
this.wrapper.style.display = 'none'; |
|
|
@ -427,19 +545,11 @@ export class EmojiButton { |
|
|
|
this.pickerContent.appendChild(this.emojiArea.container); |
|
|
|
} |
|
|
|
|
|
|
|
const searchField = this.pickerEl.querySelector( |
|
|
|
`.${CLASS_SEARCH_FIELD}` |
|
|
|
) as HTMLInputElement; |
|
|
|
if (searchField) { |
|
|
|
searchField.value = ''; |
|
|
|
if (this.search) { |
|
|
|
this.search.clear(); |
|
|
|
} |
|
|
|
|
|
|
|
const variantOverlay = this.pickerEl.querySelector( |
|
|
|
`.${CLASS_VARIANT_OVERLAY}` |
|
|
|
); |
|
|
|
if (variantOverlay) { |
|
|
|
this.events.emit(HIDE_VARIANT_POPUP); |
|
|
|
} |
|
|
|
this.events.emit(HIDE_VARIANT_POPUP); |
|
|
|
|
|
|
|
this.hideInProgress = false; |
|
|
|
this.popper && this.popper.destroy(); |
|
|
@ -455,6 +565,11 @@ export class EmojiButton { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Shows the picker. |
|
|
|
* |
|
|
|
* @param referenceEl The element to position relative to if relative positioning is used. |
|
|
|
*/ |
|
|
|
showPicker(referenceEl: HTMLElement): void { |
|
|
|
if (this.hideInProgress) { |
|
|
|
setTimeout(() => this.showPicker(referenceEl), 100); |
|
|
@ -464,99 +579,152 @@ export class EmojiButton { |
|
|
|
this.pickerVisible = true; |
|
|
|
this.wrapper.style.display = 'block'; |
|
|
|
|
|
|
|
if (window.matchMedia('screen and (max-width: 450px)').matches) { |
|
|
|
const style = window.getComputedStyle(this.pickerEl); |
|
|
|
const htmlEl = document.querySelector('html'); |
|
|
|
const viewportHeight = htmlEl && htmlEl.clientHeight; |
|
|
|
const viewportWidth = htmlEl && htmlEl.clientWidth; |
|
|
|
this.determineDisplay(referenceEl); |
|
|
|
|
|
|
|
const height = parseInt(style.height); |
|
|
|
const newTop = viewportHeight ? viewportHeight / 2 - height / 2 : 0; |
|
|
|
this.focusTrap.activate(); |
|
|
|
|
|
|
|
const width = parseInt(style.width); |
|
|
|
const newLeft = viewportWidth ? viewportWidth / 2 - width / 2 : 0; |
|
|
|
setTimeout(() => { |
|
|
|
this.addEventListeners(); |
|
|
|
this.setInitialFocus(); |
|
|
|
}); |
|
|
|
|
|
|
|
this.wrapper.style.position = 'fixed'; |
|
|
|
this.wrapper.style.top = `${newTop}px`; |
|
|
|
this.wrapper.style.left = `${newLeft}px`; |
|
|
|
this.wrapper.style.zIndex = '5000'; |
|
|
|
this.emojiArea.reset(); |
|
|
|
} |
|
|
|
|
|
|
|
this.overlay = createElement('div', CLASS_OVERLAY); |
|
|
|
document.body.appendChild(this.overlay); |
|
|
|
} else if (typeof this.options.position === 'string') { |
|
|
|
this.popper = createPopper(referenceEl, this.wrapper, { |
|
|
|
placement: this.options.position as Placement |
|
|
|
}); |
|
|
|
} else if ( |
|
|
|
this.options.position && |
|
|
|
(this.options.position.top || this.options.position.left) |
|
|
|
/** |
|
|
|
* Determines which display and position are used for the picker, based on |
|
|
|
* the viewport size and specified options. |
|
|
|
* |
|
|
|
* @param referenceEl The element to position relative to if relative positioning is used. |
|
|
|
*/ |
|
|
|
determineDisplay(referenceEl: HTMLElement): void { |
|
|
|
if ( |
|
|
|
window.matchMedia(`screen and (max-width: ${MOBILE_BREAKPOINT}px)`) |
|
|
|
.matches |
|
|
|
) { |
|
|
|
this.wrapper.style.position = 'fixed'; |
|
|
|
this.showMobileView(); |
|
|
|
} else if (typeof this.options.position === 'string') { |
|
|
|
this.setRelativePosition(referenceEl); |
|
|
|
} else { |
|
|
|
this.setFixedPosition(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (this.options.position.top) { |
|
|
|
this.wrapper.style.top = this.options.position.top; |
|
|
|
} |
|
|
|
/** |
|
|
|
* Sets the initial focus to the appropriate element, depending on the specified |
|
|
|
* options. |
|
|
|
*/ |
|
|
|
setInitialFocus(): void { |
|
|
|
// If the search field is visible and should be auto-focused, set the focus on
|
|
|
|
// the search field. Otherwise, the initial focus will be on the first focusable emoji.
|
|
|
|
const initialFocusElement = this.pickerEl.querySelector( |
|
|
|
this.options.showSearch && this.options.autoFocusSearch |
|
|
|
? `.${CLASS_SEARCH_FIELD}` |
|
|
|
: `.${CLASS_EMOJI}[tabindex="0"]` |
|
|
|
) as HTMLElement; |
|
|
|
initialFocusElement.focus(); |
|
|
|
} |
|
|
|
|
|
|
|
if (this.options.position.bottom) { |
|
|
|
this.wrapper.style.bottom = this.options.position.bottom; |
|
|
|
} |
|
|
|
/** |
|
|
|
* Adds the event listeners that will close the picker without selecting an emoji. |
|
|
|
*/ |
|
|
|
private addEventListeners(): void { |
|
|
|
document.addEventListener('click', this.onDocumentClick); |
|
|
|
document.addEventListener('keydown', this.onDocumentKeydown); |
|
|
|
} |
|
|
|
|
|
|
|
if (this.options.position.left) { |
|
|
|
this.wrapper.style.left = this.options.position.left; |
|
|
|
} |
|
|
|
/** |
|
|
|
* Sets relative positioning with Popper.js. |
|
|
|
* |
|
|
|
* @param referenceEl The element to position relative to. |
|
|
|
*/ |
|
|
|
private setRelativePosition(referenceEl: HTMLElement): void { |
|
|
|
this.popper = createPopper(referenceEl, this.wrapper, { |
|
|
|
placement: this.options.position as Placement |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
if (this.options.position.right) { |
|
|
|
this.wrapper.style.right = this.options.position.right; |
|
|
|
} |
|
|
|
/** |
|
|
|
* Sets fixed positioning. |
|
|
|
*/ |
|
|
|
private setFixedPosition(): void { |
|
|
|
if (this.options?.position) { |
|
|
|
this.wrapper.style.position = 'fixed'; |
|
|
|
|
|
|
|
const fixedPosition = this.options.position as FixedPosition; |
|
|
|
|
|
|
|
Object.keys(fixedPosition).forEach(key => { |
|
|
|
this.wrapper.style[key] = fixedPosition[key]; |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
this.focusTrap.activate(); |
|
|
|
/** |
|
|
|
* Shows the picker in a mobile view. |
|
|
|
*/ |
|
|
|
private showMobileView(): void { |
|
|
|
const style = window.getComputedStyle(this.pickerEl); |
|
|
|
const htmlEl = document.querySelector('html'); |
|
|
|
const viewportHeight = htmlEl && htmlEl.clientHeight; |
|
|
|
const viewportWidth = htmlEl && htmlEl.clientWidth; |
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
|
document.addEventListener('click', this.onDocumentClick); |
|
|
|
document.addEventListener('keydown', this.onDocumentKeydown); |
|
|
|
const height = parseInt(style.height); |
|
|
|
const newTop = viewportHeight ? viewportHeight / 2 - height / 2 : 0; |
|
|
|
|
|
|
|
const initialFocusElement = this.pickerEl.querySelector( |
|
|
|
this.options.showSearch && this.options.autoFocusSearch |
|
|
|
? `.${CLASS_SEARCH_FIELD}` |
|
|
|
: `.${CLASS_EMOJI}[tabindex="0"]` |
|
|
|
) as HTMLElement; |
|
|
|
initialFocusElement.focus(); |
|
|
|
}); |
|
|
|
const width = parseInt(style.width); |
|
|
|
const newLeft = viewportWidth ? viewportWidth / 2 - width / 2 : 0; |
|
|
|
|
|
|
|
this.emojiArea.reset(); |
|
|
|
this.wrapper.style.position = 'fixed'; |
|
|
|
this.wrapper.style.top = `${newTop}px`; |
|
|
|
this.wrapper.style.left = `${newLeft}px`; |
|
|
|
this.wrapper.style.zIndex = '5000'; |
|
|
|
|
|
|
|
this.overlay = createElement('div', CLASS_OVERLAY); |
|
|
|
document.body.appendChild(this.overlay); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Toggles the picker's visibility. |
|
|
|
* |
|
|
|
* @param referenceEl The element to position relative to if relative positioning is used. |
|
|
|
*/ |
|
|
|
togglePicker(referenceEl: HTMLElement): void { |
|
|
|
this.pickerVisible ? this.hidePicker() : this.showPicker(referenceEl); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Determines whether or not the picker is currently visible. |
|
|
|
* @return true if the picker is visible, false if not. |
|
|
|
*/ |
|
|
|
isPickerVisible(): boolean { |
|
|
|
return this.pickerVisible; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handles a keydown event on the document. |
|
|
|
* @param event The keyboard event that was dispatched. |
|
|
|
*/ |
|
|
|
private onDocumentKeydown(event: KeyboardEvent): void { |
|
|
|
if (event.key === 'Escape') { |
|
|
|
// Escape closes the picker.
|
|
|
|
this.hidePicker(); |
|
|
|
} else if (event.key === 'Tab') { |
|
|
|
// The `keyboard` class adds some extra styling to indicate keyboard focus.
|
|
|
|
this.pickerEl.classList.add('keyboard'); |
|
|
|
} else if (event.key.match(/^[\w]$/)) { |
|
|
|
const searchField = this.pickerEl.querySelector( |
|
|
|
`.${CLASS_SEARCH_FIELD}` |
|
|
|
) as HTMLInputElement; |
|
|
|
searchField && searchField.focus(); |
|
|
|
} else if (event.key.match(/^[\w]$/) && this.search) { |
|
|
|
// If a search term is entered, move the focus to the search field.
|
|
|
|
this.search.focus(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Sets the theme to use for the picker. |
|
|
|
*/ |
|
|
|
setTheme(theme: EmojiTheme): void { |
|
|
|
if (theme === this.theme) return; |
|
|
|
|
|
|
|
this.pickerEl.classList.remove(this.theme); |
|
|
|
this.theme = theme; |
|
|
|
this.updateTheme(this.theme); |
|
|
|
} |
|
|
|
|
|
|
|
private updateTheme(theme: EmojiTheme): void { |
|
|
|
this.pickerEl.classList.add(theme); |
|
|
|
if (theme !== this.theme) { |
|
|
|
this.pickerEl.classList.remove(this.theme); |
|
|
|
this.theme = theme; |
|
|
|
this.pickerEl.classList.add(theme); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|