[IMP] mail: single unified Picker (Gif & Emoji)

Before this commit, emoji and gif pickers were handled as 2 different
pickers. They had dedicated buttons. This made composer quite
bloated, especially in chat windows were size is narrow.
Also the pickers were always displayed as popover, which is not the
best way to show content in mobile.

This commit improves those pickers in discuss so that only a single
button is present (emoji button). Clicking on this button shows a
picker that groups the emoji and gif picker, both of which can be
selected in the header of the grouped picker.

In mobile, instead of showing a popover, it now shows the grouped
picker below the composer. This makes it show where the soft keyboard
is present in mobile, making it easier to input emojis or gifs on
those devices.

This commit also added slight style changes to both pickers. Notably,
the search bar has an improved style that makes it look good when
next to the composer text input, especially in mobile layout.

task-3015707

closes odoo/odoo#123671

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Zelong Lin
2023-09-08 12:24:45 +00:00
committed by Alexandre Kühn
parent 6765084b42
commit adc5448adf
30 changed files with 531 additions and 252 deletions
+22 -43
View File
@@ -3,7 +3,7 @@
import { AttachmentList } from "@mail/core/common/attachment_list";
import { useAttachmentUploader } from "@mail/core/common/attachment_uploader_hook";
import { useDropzone } from "@mail/core/common/dropzone_hook";
import { EmojiPicker, useEmojiPicker } from "@web/core/emoji_picker/emoji_picker";
import { Picker, usePicker } from "@mail/core/common/picker";
import { MessageConfirmDialog } from "@mail/core/common/message_confirm_dialog";
import { NavigableList } from "@mail/core/common/navigable_list";
import { useSuggestion } from "@mail/core/common/suggestion_hook";
@@ -18,12 +18,10 @@ import {
onMounted,
useChildSubEnv,
useEffect,
useExternalListener,
useRef,
useState,
} from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { FileUploader } from "@web/views/fields/file_handler";
@@ -52,7 +50,7 @@ const EDIT_CLICK_TYPE = {
export class Composer extends Component {
static components = {
AttachmentList,
EmojiPicker,
Picker,
FileUploader,
NavigableList,
};
@@ -82,10 +80,6 @@ export class Composer extends Component {
this.SEND_KEYBIND_TO_SEND = markup(
_t("<samp>%(send_keybind)s</samp><i> to send</i>", { send_keybind: this.sendKeybind })
);
this.KEYBOARD = {
NONE: "None",
EMOJI: "Emoji",
};
this.store = useState(useService("mail.store"));
if (this.allowUpload) {
this.attachmentUploader = useAttachmentUploader(
@@ -97,12 +91,13 @@ export class Composer extends Component {
this.threadService = useService("mail.thread");
this.ui = useState(useService("ui"));
this.rpc = useService("rpc");
this.mainActionsRef = useRef("main-actions");
this.ref = useRef("textarea");
this.fakeTextarea = useRef("fakeTextarea");
this.emojiButton = useRef("emoji-button");
this.state = useState({
autofocus: 0,
active: true,
keyboard: this.KEYBOARD.NONE,
});
this.selection = useSelection({
refName: "textarea",
@@ -132,26 +127,7 @@ export class Composer extends Component {
useChildSubEnv({
inComposer: true,
});
if (!this.ui.isSmall) {
useEmojiPicker(useRef("emoji-button"), {
onSelect: (str) => this.addEmoji(str),
onClose: () => this.state.autofocus++,
});
}
useExternalListener(
browser,
"click",
async (ev) => {
if (this.state.keyboard === this.KEYBOARD.NONE) {
return;
}
await new Promise(setTimeout); // let bubbling to catch marked event handled
if (!this.isEventHandledByPicker(ev)) {
this.state.keyboard = this.KEYBOARD.NONE;
}
},
true
);
this.picker = usePicker(this.pickerSettings);
useEffect(
(focus) => {
if (focus && this.ref.el) {
@@ -190,6 +166,20 @@ export class Composer extends Component {
});
}
get pickerSettings() {
return {
anchor: this.props.mode === "extended" ? undefined : this.mainActionsRef,
buttons: [this.emojiButton],
close: () => {
if (!this.ui.isSmall) {
this.state.autofocus++;
}
},
pickers: { emoji: (emoji) => this.addEmoji(emoji) },
position: this.props.mode === "extended" ? "bottom-start" : "top-end",
};
}
get placeholder() {
if (this.props.placeholder) {
return this.props.placeholder;
@@ -234,17 +224,6 @@ export class Composer extends Component {
return this.props.mode === "extended" ? _t("CTRL-Enter") : _t("Enter");
}
/**
* @param {Event} ev
* @returns {boolean}
*/
isEventHandledByPicker(ev) {
return (
isEventHandled(ev, "Composer.onClickAddEmoji") ||
isEventHandled(ev, "EmojiPicker.onClick")
);
}
get thread() {
return (
this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread ?? null
@@ -492,8 +471,6 @@ export class Composer extends Component {
onClickAddEmoji(ev) {
markEventHandled(ev, "Composer.onClickAddEmoji");
this.state.keyboard =
this.state.keyboard === this.KEYBOARD.EMOJI ? this.KEYBOARD.NONE : this.KEYBOARD.EMOJI;
}
isEventTrusted(ev) {
@@ -593,7 +570,9 @@ export class Composer extends Component {
const secondPart = textContent.slice(this.props.composer.selection.end, textContent.length);
this.props.composer.textInputContent = firstPart + str + secondPart;
this.selection.moveCursor((firstPart + str).length);
this.state.autofocus++;
if (!this.ui.isSmall) {
this.state.autofocus++;
}
}
onFocusin() {
@@ -40,10 +40,6 @@
}
}
.o-mail-Composer-bottomPicker {
height: 60vh;
}
.o-mail-Composer-input {
padding-top: 10px; // carefully crafted to have the text in the middle in chat window
padding-bottom: 10px;
@@ -70,11 +70,11 @@
'mx-3 border-top p-1': extended,
}"
>
<div class="d-flex flex-grow-1 align-items-center" >
<button class="btn border-0 fa fa-smile-o rounded-pill" aria-label="Emojis" t-on-click="onClickAddEmoji" t-ref="emoji-button"/>
<div class="d-flex flex-grow-1 align-items-center" t-ref="main-actions">
<button class="btn border-0 rounded-pill" t-att-class="{'bg-300': this.picker.state.picker === this.picker.PICKERS.EMOJI}" aria-label="Emojis" t-on-click="onClickAddEmoji" t-ref="emoji-button"><i class="fa fa-smile-o"/></button>
<FileUploader t-if="allowUpload" multiUpload="true" onUploaded="(data) => { attachmentUploader.uploadData(data) }">
<t t-set-slot="toggler">
<button t-att-disabled="!state.active" class="o-mail-Composer-attachFiles btn fa fa-paperclip border-0 rounded-pill" title="Attach files" aria-label="Attach files" type="button" t-on-click="onClickAddAttachment"/>
<button t-att-disabled="!state.active" class="o-mail-Composer-attachFiles btn border-0 rounded-pill" title="Attach files" aria-label="Attach files" type="button" t-on-click="onClickAddAttachment"><i class="fa fa-paperclip"/></button>
</t>
</FileUploader>
</div>
@@ -93,7 +93,7 @@
attachments="props.composer.attachments"
unlinkAttachment.bind="(...args) => attachmentUploader.unlink(...args)"
imagesHeight="50"/>
<EmojiPicker t-if="ui.isSmall and state.keyboard === KEYBOARD.EMOJI" className="'o-mail-Composer-bottomPicker w-100'" onSelect="(str) => this.addEmoji(str)"/>
<Picker t-props="picker"/>
</div>
</div>
<span t-if="props.composer.message" class="text-muted" t-out="CANCEL_OR_SAVE_EDIT_TEXT" t-on-click="onClickCancelOrSaveEditText"/>
@@ -22,6 +22,10 @@
font-size: 0.7em !important;
}
.o-min-height-0 {
min-height: 0;
}
.o-min-width-0 {
min-width: 0;
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.MessageReactionButton" owl="1">
<t t-name="mail.MessageReactionButton">
<button class="btn px-1 py-0 rounded-0" tabindex="1" title="Add a Reaction" aria-label="Add a Reaction" t-ref="emoji-picker"><i class="fa fa-lg fa-smile-o"/></button>
</t>
@@ -0,0 +1,157 @@
/* @odoo-module */
import { Component, useExternalListener, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { isEventHandled } from "@web/core/utils/misc";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { PickerContent } from "@mail/core/common/picker_content";
import { useLazyExternalListener } from "@mail/utils/common/hooks";
export function usePicker(setting) {
const storeScroll = {
scrollValue: 0,
set: (value) => (storeScroll.scrollValue = value),
get: () => storeScroll.scrollValue,
};
const PICKERS = {
NONE: "none",
EMOJI: "emoji",
GIF: "gif",
};
return useState({
PICKERS,
anchor: setting.anchor,
buttons: setting.buttons,
close: setting.close,
pickers: setting.pickers,
position: setting.position,
state: {
picker: PICKERS.NONE,
searchTerm: "",
},
storeScroll,
});
}
/**
* Picker/usePicker is a component hook that can be used to display the emoji picker/gif picker (if it is enabled).
* It can be used in two ways:
* - with a popover when in large screen: the picker will be displayed in a popover triggered by provided buttons.
* - with a keyboard when in mobile view: the picker will be displayed in place where the Picker component is placed.
* The switch between the two modes is done automatically based on the screen size.
*/
export class Picker extends Component {
static components = {
PickerContent,
};
static props = [
"PICKERS",
"anchor?",
"buttons",
"close?",
"state",
"pickers",
"position?",
"storeScroll",
];
static template = "mail.Picker";
setup() {
this.ui = useState(useService("ui"));
this.popover = usePopover(PickerContent, {
position: this.props.position,
fixedPosition: true,
onClose: () => this.close(),
closeOnClickAway: false,
popoverClass: "o-fast-popover",
});
useExternalListener(
browser,
"click",
async (ev) => {
if (this.props.state.picker === this.props.PICKERS.NONE) {
return;
}
await new Promise(setTimeout); // let bubbling to catch marked event handled
if (!this.isEventHandledByPicker(ev)) {
this.close();
}
},
true
);
for (const button of this.props.buttons) {
useLazyExternalListener(
() => button.el,
"click",
async (ev) => this.toggle(this.props.anchor?.el ?? button.el, ev)
);
}
}
get contentProps() {
const pickers = {};
for (const [name, fn] of Object.entries(this.props.pickers)) {
pickers[name] = (str, resetOnSelect) => {
fn(str);
if (resetOnSelect) {
this.close();
}
};
}
return {
PICKERS: this.props.PICKERS,
close: () => this.close(),
pickers,
state: this.props.state,
storeScroll: this.props.storeScroll,
};
}
/**
* @param {Event} ev
* @returns {boolean}
*/
isEventHandledByPicker(ev) {
return (
isEventHandled(ev, "Composer.onClickAddEmoji") ||
isEventHandled(ev, "PickerContent.onClick")
);
}
async toggle(el, ev) {
// Let event be handled by bubbling handlers first.
await new Promise(setTimeout);
// In small screen, we toggle keyboard picker.
if (this.ui.isSmall) {
if (this.props.state.picker === this.props.PICKERS.NONE) {
this.props.state.picker = this.props.PICKERS.EMOJI;
} else {
this.props.state.picker = this.props.PICKERS.NONE;
}
return;
}
// In large screen, we toggle popover.
if (isEventHandled(ev, "Composer.onClickAddEmoji")) {
if (this.popover.isOpen) {
if (this.props.state.picker === this.props.PICKERS.EMOJI) {
this.props.state.picker = this.props.PICKERS.NONE;
this.popover.close();
return;
}
this.props.state.picker = this.props.PICKERS.EMOJI;
} else {
this.props.state.picker = this.props.PICKERS.EMOJI;
this.popover.open(el, this.contentProps);
}
}
}
close() {
this.props.close?.();
this.popover.close();
this.props.state.picker = this.props.PICKERS.NONE;
this.props.state.searchTerm = "";
}
}
@@ -0,0 +1,17 @@
// o-mail-Picker handles the styling of picker in mobile view only
.o-mail-Picker {
height: 55vh ;
.o-EmojiPicker {
width: 100% !important;
.o-Emoji {
width: 40px;
font-size: 1.5rem !important;
}
}
.o-EmojiPicker-navbar {
gap: 0.5rem !important;
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.Picker">
<div t-if="ui.isSmall" class="o-mail-Picker d-flex flex-column w-100" t-att-class="{'d-none': props.state.picker === props.PICKERS.NONE}">
<PickerContent t-props="contentProps"/>
</div>
</t>
</templates>
@@ -0,0 +1,19 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
import { markEventHandled } from "@web/core/utils/misc";
import { EmojiPicker } from "@web/core/emoji_picker/emoji_picker";
/**
* PickerContent is the content displayed in the popover/Picker.
* It is used to display the emoji picker/gif picker (if it is enabled).
*/
export class PickerContent extends Component {
static components = { EmojiPicker };
static props = ["PICKERS", "close", "pickers", "state", "storeScroll"];
static template = "mail.PickerContent";
onClick(ev) {
markEventHandled(ev, "PickerContent.onClick");
}
}
@@ -0,0 +1,9 @@
.popover .o-mail-PickerContent {
width: 300px;
height: 365px;
.o-EmojiPicker {
width: 100% !important;
height: 100% !important;
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.PickerContent">
<div class="o-mail-PickerContent d-flex flex-column flex-grow-1 o-min-height-0" t-on-click="onClick">
<div class="o-mail-PickerContent-picker d-flex flex-grow-1 rounded overflow-auto">
<div t-if="props.state.picker === props.PICKERS.EMOJI" class="o-mail-PickerContent-emojiPicker d-flex flex-grow-1">
<EmojiPicker close="props.close" onSelect="props.pickers.emoji" state="props.state" storeScroll="props.storeScroll"/>
</div>
</div>
</div>
</t>
</templates>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.DiscussCommand" owl="1">
<t t-name="mail.DiscussCommand">
<div class="o_command_default d-flex align-items-center px-4 py-2">
<img class="rounded me-2" t-if="props.imgUrl" t-att-src="props.imgUrl" style="width: 25px; height: 25px"/>
<ImStatus t-if="props.persona" className="'me-1'" persona="props.persona"/>
@@ -1,50 +1,43 @@
/* @odoo-module */
import { GifPicker, useGifPicker } from "@mail/discuss/gif_picker/common/gif_picker";
import { Composer } from "@mail/core/common/composer";
import { onExternalClick } from "@mail/utils/common/hooks";
import { isEventHandled, markEventHandled } from "@web/core/utils/misc";
import { markEventHandled } from "@web/core/utils/misc";
import { useState } from "@odoo/owl";
import { useRef, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
Object.assign(Composer.components, { GifPicker });
/** @type {Composer} */
const composerPatch = {
setup() {
super.setup();
Object.assign(this.KEYBOARD, { GIF: "Gif" });
onExternalClick("gif-picker", () => (this.state.keyboard = this.KEYBOARD.NONE));
this.ui = useState(useService("ui"));
this.gifPickerService = useState(useService("discuss.gifPicker"));
this.gifPicker = useGifPicker("gif-button", {
onSelected: this.sendGifMessage.bind(this),
});
this.gifButton = useRef("gif-button");
super.setup();
this.ui = useState(useService("ui"));
},
/**
* @param {Event} ev
* @returns {boolean}
*/
isEventHandledByPicker(ev) {
get pickerSettings() {
const setting = super.pickerSettings;
if (this.hasGifPicker) {
setting.pickers.gif = (gif) => this.sendGifMessage(gif);
if (this.hasGifPickerButton) {
setting.buttons.push(this.gifButton);
}
}
return setting;
},
get hasGifPicker() {
return (
super.isEventHandledByPicker(ev) ||
isEventHandled(ev, "Composer.onClickAddGif") ||
isEventHandled(ev, "GifPicker.onClick")
(this.gifPickerService.hasGifPickerFeature || this.store.user?.isAdmin) &&
!this.env.inChatter &&
!this.props.composer.message
);
},
get hasGifPickerButton() {
return this.hasGifPicker && !this.ui.isSmall && !this.env.inChatWindow;
},
onClickAddGif(ev) {
markEventHandled(ev, "Composer.onClickAddGif");
if (!this.ui.isSmall) {
this.gifPicker.toggle();
} else {
if (this.state.keyboard !== this.KEYBOARD.GIF) {
this.state.keyboard = this.KEYBOARD.GIF;
} else {
this.state.keyboard = this.KEYBOARD.NONE;
}
}
},
async sendGifMessage(gif) {
await this._sendMessage(gif.url, {
@@ -1,11 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//*[@t-ref='emoji-button']" position="after">
<button t-if="gifPickerService.hasGifPickerFeature and !env.inChatter and !props.composer.message" class="btn border-0 oi oi-gif-picker rounded-pill" aria-label="GIFs" t-on-click="onClickAddGif" t-ref="gif-button"/>
<xpath expr="//*[@t-ref='emoji-button']" position="attributes">
<attribute name="t-att-class">
{'bg-300': this.picker.state.picker === this.picker.PICKERS.EMOJI or (this.picker.state.picker === this.picker.PICKERS.GIF and this.ui.isSmall)}
</attribute>
</xpath>
<xpath expr="//div[hasclass('o-mail-Composer-footer')]" position="inside">
<GifPicker t-if="ui.isSmall and state.keyboard === KEYBOARD.GIF" className="'o-mail-Composer-bottomPicker w-100'" onSelected.bind="sendGifMessage" close="() => this.state.keyboard = KEYBOARD.NONE"/>
<xpath expr="//*[@t-ref='emoji-button']" position="after">
<button t-if="hasGifPickerButton" class="btn border-0 rounded-pill" t-att-class="{'bg-300': this.picker.state.picker === this.picker.PICKERS.GIF}" aria-label="GIFs" t-on-click="onClickAddGif" t-ref="gif-button"><i class="oi oi-gif-picker"/></button>
</xpath>
</t>
</templates>
@@ -0,0 +1,3 @@
.o-discuss-GifPicker {
--o-gif-picker-active: #{$white};
}
@@ -1,12 +1,9 @@
/* @odoo-module */
import { Component, onWillStart, useState, useEffect } from "@odoo/owl";
import { removeFromArrayWithPredicate } from "@mail/utils/common/arrays";
import { useOnBottomScrolled, useSequential } from "@mail/utils/common/hooks";
import { markEventHandled } from "@web/core/utils/misc";
import { Component, onWillStart, useRef, useState } from "@odoo/owl";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService, useAutofocus } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
@@ -41,36 +38,17 @@ import { useDebounced } from "@web/core/utils/timing";
* @property {{ tinygif: TenorMediaFormat }} media_formats
*/
/**
* @param {import("@web/core/utils/common/hooks").Ref} ref
* @param {{ onSelected: function, className: String }} options
*/
export function useGifPicker(refName, options) {
const ref = useRef(refName);
const popover = usePopover(GifPicker, {
position: "top",
popoverClass: "o-fast-popover",
});
function toggle() {
if (popover.isOpen) {
popover.close();
} else {
popover.open(ref.el, options);
}
}
return { toggle };
}
/**
* @typedef {Object} Props
* @property {function} onSelected Callback to use when the gif is selected
* @property {function} onSelect Callback to use when the gif is selected
* @property {string} [className]
* @property {function} [close]
* @property {Object} [state]
* @extends {Component<Props, Env>}
*/
export class GifPicker extends Component {
static template = "discuss.GifPicker";
static props = ["onSelected", "className?", "close?"];
static props = ["PICKERS?", "className?", "close?", "onSelect", "state?"];
setup() {
this.rpc = useService("rpc");
@@ -130,6 +108,34 @@ export class GifPicker extends Component {
this.loadFavorites();
});
}
useEffect(
() => {
if (this.props.state?.picker !== this.props.PICKERS?.GIF) {
return;
}
this.clear();
this.state.loadingGif = true;
this.search();
if (this.searchTerm) {
this.closeCategories();
} else {
this.openCategories();
}
},
() => [this.searchTerm, this.props.state?.picker]
);
}
get searchTerm() {
return this.props.state ? this.props.state.searchTerm : this.state.searchTerm;
}
set searchTerm(value) {
if (this.props.state) {
this.props.state.searchTerm = value;
} else {
this.state.searchTerm = value;
}
}
async loadCategories() {
@@ -152,7 +158,7 @@ export class GifPicker extends Component {
openCategories() {
this.state.showCategories = true;
this.state.searchTerm = "";
this.searchTerm = "";
this.clear();
}
@@ -161,14 +167,14 @@ export class GifPicker extends Component {
}
async search() {
if (!this.state.searchTerm) {
if (!this.searchTerm) {
return;
}
try {
const params = {
country: this.userService.lang.slice(3, 5),
locale: this.userService.lang,
search_term: this.state.searchTerm,
search_term: this.searchTerm,
};
if (this.next) {
params.position = this.next;
@@ -207,27 +213,12 @@ export class GifPicker extends Component {
}
}
onInput() {
this.clear();
this.state.loadingGif = true;
this.search();
if (this.state.searchTerm) {
this.closeCategories();
} else {
this.openCategories();
}
}
onClick(ev) {
markEventHandled(ev, "GifPicker.onClick");
}
/**
* @param {TenorGif} gif
*/
onClickGif(gif) {
this.props.onSelected(gif);
this.props.close();
this.props.onSelect(gif, true);
this.props.close?.();
}
clear() {
@@ -242,8 +233,7 @@ export class GifPicker extends Component {
*/
async onClickCategory(category) {
this.clear();
this.state.searchTerm = category.searchterm;
await this.search();
this.props.state.searchTerm = category.searchterm;
this.closeCategories();
}
@@ -4,33 +4,12 @@
}
.o-discuss-GifPicker {
display: grid;
grid-template-areas:
"search"
"content";
grid-template-rows: 40px 1fr;
}
.o-discuss-GifPicker-search {
grid-area: search;
background-color: $input-bg;
input {
&:focus, &:hover {
& + .oi-search {
@extend .bg-view !optional;
}
}
&:not(:focus) + .oi-search {
opacity: 60%;
}
--o-gif-picker-active: #{$gray-200};
.o-active {
background-color: var(--o-gif-picker-active) !important;
}
}
.o-discuss-GifPicker-content {
grid-area: content;
}
.o-discuss-GifPickerCategory-img {
object-fit: cover;
}
@@ -1,20 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="discuss.GifPicker">
<div class="o-discuss-GifPicker bg-view d-flex flex-column" t-attf-class="{{ props.className }}" t-on-click="onClick">
<div class="o-discuss-GifPicker-search d-flex align-items-center m-2 rounded">
<div class="o-discuss-GifPicker bg-view d-flex flex-column" t-attf-class="{{ props.className }}">
<div class="o-EmojiPicker-search d-flex align-items-center m-2 rounded">
<i t-if="!state.showCategories" aria-label="back" t-on-click="openCategories" class="oi oi-arrow-left cursor-pointer p-2 fs-5"/>
<input class="form-control border-0 flex-grow-1 rounded-end-0" placeholder="Search for a gif" t-on-input="onInput" t-model="state.searchTerm" t-ref="autofocus" />
<i class="oi oi-search p-2 fs-5 rounded-end" title="Search..." role="img" aria-label="Search..."/>
<span class="d-flex mx-1 w-100 rounded o-active">
<t t-call="discuss.GifPicker.searchInput">
<t t-if="props.state" t-set="localState" t-value="props.state"/>
<t t-else="" t-set="localState" t-value="state"/>
</t>
<i class="oi oi-search p-2 fs-5 rounded-start-0 rounded-3 o-active" title="Search..." role="img" aria-label="Search..."/>
</span>
</div>
<div class="o-discuss-GifPicker-content overflow-y-auto overflow-x-hidden px-2 pb-2 h-100" t-ref="scroller">
<div class="o-discuss-GifPicker-content overflow-y-auto overflow-x-hidden ps-2 pe-3 pb-2 h-100" t-ref="scroller">
<div t-if="state.loadingError" class="text-center">
<span class="o-discuss-GifPicker-error d-block">😵‍💫</span>
<span class="fs-5 text-muted d-block">Failed to load gifs...</span>
</div>
<div t-if="state.showCategories and !state.loadingError" class="row row-cols-2 gy-2 gx-2" aria-label="list">
<div t-if="!store.guest" class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" t-on-click="onClickFavoritesCategory" aria-label="list-item">
<div class="position-relative ratio ratio-16x9 cursor-pointer rounded overflow-hidden" t-on-click="onClickFavoritesCategory" aria-label="list-item">
<img
t-if="state.favorites.gifs.length > 0"
class="o-discuss-GifPicker-category"
@@ -30,7 +35,7 @@
<t t-if="state.categories.length === 0">
<t t-foreach="Array(10).keys()" t-as="category" t-key="category_index">
<div class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" aria-label="list-item">
<div class="position-relative ratio ratio-16x9 cursor-pointer rounded overflow-hidden" aria-label="list-item">
<div class="position-absolute w-100 h-100 top-0 start-0 text-center o-text-white align-middle fs-2 o-bg-black bg-opacity-50"/>
</div>
</div>
@@ -39,7 +44,7 @@
<t t-else="">
<t t-foreach="state.categories" t-as="category" t-key="category.name">
<div class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" t-on-click="() => this.onClickCategory(category)" aria-label="list-item">
<div class="position-relative ratio ratio-16x9 cursor-pointer rounded overflow-hidden" t-on-click="() => this.onClickCategory(category)" aria-label="list-item">
<img class="o-discuss-GifPickerCategory-img img-fluid" t-att-src="category.image" loading="lazy" alt="GIF Category"/>
<div class="position-absolute w-100 h-100 top-0 start-0 text-center o-text-white align-middle fs-2 o-bg-black bg-opacity-50">
<t t-out="category.name"/>
@@ -75,7 +80,7 @@
</t>
<t t-name="discuss.Gif">
<div class="o-discuss-Gif position-relative mt-1 fs-5 cursor-pointer">
<div class="o-discuss-Gif position-relative mt-1 fs-5 cursor-pointer rounded overflow-hidden">
<i
t-if="!store.guest"
class="position-absolute top-0 end-0 me-3 mt-3 p-2 o-bg-black bg-opacity-75 fa cursor-pointer opacity-0"
@@ -91,4 +96,8 @@
/>
</div>
</t>
<t t-name="discuss.GifPicker.searchInput">
<input class="form-control border-0 flex-grow-1 rounded-3 rounded-end-0 o-active" placeholder="Search for a GIF" t-model="localState.searchTerm" t-ref="autofocus" />
</t>
</templates>
@@ -0,0 +1,17 @@
/* @odoo-module */
import { useState } from "@odoo/owl";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import { GifPicker } from "@mail/discuss/gif_picker/common/gif_picker";
import { PickerContent } from "@mail/core/common/picker_content";
Object.assign(PickerContent.components, { GifPicker });
patch(PickerContent.prototype, {
setup() {
super.setup();
this.store = useState(useService("mail.store"));
this.gifPickerService = useState(useService("discuss.gifPicker"));
},
});
@@ -0,0 +1,12 @@
// o-mail-PickerContent handles the picker in popover/mobile view
.o-mail-PickerContent {
.o-discuss-GifPicker {
width: 100% !important;
height: 100% !important;
}
.o-mail-PickerContent-bigEmoji {
font-size: 5rem !important;
filter: grayscale(0.25);
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.PickerContent" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-mail-PickerContent-picker')]" position="before">
<div t-if="Object.keys(props.pickers).length > 1" class="o-mail-PickerContent-tab d-flex m-1">
<nav class="btn-group w-100">
<button t-if="props.pickers.emoji" class="btn btn-secondary" t-on-click="() => this.props.state.picker = this.props.PICKERS.EMOJI" t-att-class="{ 'active': props.state.picker === this.props.PICKERS.EMOJI }">
Emoji
</button>
<button t-if="props.pickers.gif" class="btn btn-secondary" t-on-click="() => this.props.state.picker = this.props.PICKERS.GIF" t-att-class="{ 'active': props.state.picker === this.props.PICKERS.GIF }">
GIFs
</button>
</nav>
</div>
</xpath>
<xpath expr="//div[hasclass('o-mail-PickerContent-emojiPicker')]" position="after">
<div t-if="props.state.picker === this.props.PICKERS.GIF" class="d-flex align-items-center w-100">
<GifPicker t-if="gifPickerService.hasGifPickerFeature" PICKERS="props.PICKERS" onSelect="props.pickers.gif" close="props.close" state="props.state"/>
<div t-elif="store.user.isAdmin" class="d-flex flex-column align-items-center justify-content-center m-3">
<span class="o-mail-PickerContent-bigEmoji">🧑‍🍳🌶️</span>
<span class="fs-5 text-muted">Want to spice up your conversations with GIFs? Activate the feature in the settings!</span>
</div>
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,31 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Picker } from "@mail/core/common/picker";
import { isEventHandled } from "@web/core/utils/misc";
patch(Picker.prototype, {
/**
* @param {Event} ev
* @returns {boolean}
*/
isEventHandledByPicker(ev) {
return super.isEventHandledByPicker(ev) || isEventHandled(ev, "Composer.onClickAddGif");
},
async toggle(el, ev) {
// Let event be handled by bubbling handlers first.
await super.toggle(el, ev);
if (isEventHandled(ev, "Composer.onClickAddGif")) {
if (this.popover.isOpen) {
if (this.props.state.picker === this.props.PICKERS.GIF) {
this.close();
return;
}
this.props.state.picker = this.props.PICKERS.GIF;
} else {
this.props.state.picker = this.props.PICKERS.GIF;
this.popover.open(el, this.contentProps);
}
}
},
});
+9 -4
View File
@@ -12,7 +12,7 @@ import {
import { useService } from "@web/core/utils/hooks";
function useExternalListener(target, eventName, handler, eventParams) {
export function useLazyExternalListener(target, eventName, handler, eventParams) {
const boundHandler = handler.bind(useComponent());
let t;
onMounted(() => {
@@ -75,7 +75,7 @@ export function useHover(refName, callback = () => {}) {
state.isHover = hovered;
callback(hovered);
}
useExternalListener(
useLazyExternalListener(
() => ref.el,
"mouseenter",
(ev) => {
@@ -86,7 +86,7 @@ export function useHover(refName, callback = () => {}) {
},
true
);
useExternalListener(
useLazyExternalListener(
() => ref.el,
"mouseleave",
(ev) => {
@@ -276,6 +276,7 @@ export function useMessageHighlight(duration = 2000) {
}
export function useSelection({ refName, model, preserveOnClickAwayPredicate = () => false }) {
const ui = useState(useService("ui"));
const ref = useRef(refName);
function onSelectionChange() {
if (document.activeElement && document.activeElement === ref.el) {
@@ -313,7 +314,11 @@ export function useSelection({ refName, model, preserveOnClickAwayPredicate = ()
},
moveCursor(position) {
model.start = model.end = position;
ref.el.selectionStart = ref.el.selectionEnd = position;
if (!ui.isSmall) {
// In mobile, selection seems to adjust correctly.
// Don't programmatically adjust, otherwise it shows soft keyboard!
ref.el.selectionStart = ref.el.selectionEnd = position;
}
},
};
}
@@ -237,16 +237,18 @@ QUnit.test(
});
openDiscuss(channelId);
await triggerEvent(getFixture(), null, "mousedown");
await click("button[aria-label='Emojis']");
await click("[title='Add a Reaction']");
await contains(".o-EmojiPicker-content", { scroll: 0 });
await scroll(".o-EmojiPicker-content", 150);
await triggerEvent(getFixture(), null, "mousedown");
await click("[title='Add a Reaction']");
await click("button[aria-label='Emojis']");
await contains(".o-mail-PickerContent .o-EmojiPicker-content", { scroll: 0 });
await scroll(".o-EmojiPicker-content", 200);
await triggerEvent(getFixture(), null, "mousedown");
await click("button[aria-label='Emojis']");
await click("[title='Add a Reaction']");
await contains(".o-EmojiPicker-content", { scroll: 150 });
await triggerEvent(getFixture(), null, "mousedown");
await click("[title='Add a Reaction']");
await click("button[aria-label='Emojis']");
await contains(".o-EmojiPicker-content", { scroll: 200 });
}
);
+10 -8
View File
@@ -65,16 +65,18 @@ QUnit.test("Basic keyboard navigation", async () => {
const { openDiscuss } = await start();
openDiscuss(channelId);
await click("button[aria-label='Emojis']");
await contains(".o-EmojiPicker-content .o-Emoji[data-index=0].bg-200"); // bg-200 means active
await contains(".o-EmojiPicker-content .o-Emoji[data-index=0].o-active");
triggerHotkey("ArrowRight");
await contains(".o-EmojiPicker-content .o-Emoji[data-index=1].bg-200");
await contains(".o-EmojiPicker-content .o-Emoji[data-index=1].o-active");
triggerHotkey("ArrowDown");
await contains(`.o-EmojiPicker-content .o-Emoji[data-index=${EMOJI_PER_ROW + 1}].bg-200`);
await contains(`.o-EmojiPicker-content .o-Emoji[data-index=${EMOJI_PER_ROW + 1}].o-active`);
triggerHotkey("ArrowLeft");
await contains(`.o-EmojiPicker-content .o-Emoji[data-index=${EMOJI_PER_ROW}].bg-200`);
await contains(`.o-EmojiPicker-content .o-Emoji[data-index=${EMOJI_PER_ROW}].o-active`);
triggerHotkey("ArrowUp");
await contains(".o-EmojiPicker-content .o-Emoji[data-index=0].bg-200");
const codepoints = $(".o-EmojiPicker-content .o-Emoji[data-index=0].bg-200").data("codepoints");
await contains(".o-EmojiPicker-content .o-Emoji[data-index=0].o-active");
const codepoints = $(".o-EmojiPicker-content .o-Emoji[data-index=0].o-active").data(
"codepoints"
);
triggerHotkey("Enter");
await contains(".o-EmojiPicker", { count: 0 });
await contains(".o-mail-Composer-input", { value: codepoints });
@@ -151,7 +153,7 @@ QUnit.test("first category should be highlighted by default", async () => {
const { openDiscuss } = await start();
openDiscuss(channelId);
await click("button[aria-label='Emojis']");
await contains(".o-EmojiPicker-navbar .o-Emoji:eq(0).bg-300");
await contains(".o-EmojiPicker-navbar .o-Emoji:eq(0).o-active");
});
QUnit.test(
@@ -163,7 +165,7 @@ QUnit.test(
openDiscuss(channelId);
await click("button[aria-label='Emojis']");
(await contains(".o-EmojiPicker-content .o-Emoji:contains(👺)"))[0].dispatchEvent(
new MouseEvent("click", { shiftKey: true })
new MouseEvent("click", { bubbles: true, shiftKey: true })
);
await contains(".o-EmojiPicker-navbar [title='Frequently used']");
await contains(".o-EmojiPicker");
@@ -111,7 +111,7 @@ QUnit.test("Searching for a GIF", async () => {
});
openDiscuss(channelId);
await click("button[aria-label='GIFs']");
insertText((await contains("input[placeholder='Search for a gif']"))[0], "search");
insertText((await contains("input[placeholder='Search for a GIF']"))[0], "search");
await contains("i[aria-label='back']");
await contains(".o-discuss-Gif", { count: 2 });
});
@@ -133,7 +133,7 @@ QUnit.test("Open a GIF category trigger the search for the category", async () =
await click("button[aria-label='GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await contains(".o-discuss-Gif", { count: 2 });
await contains("input[placeholder='Search for a gif']", { value: "cry" });
await contains("input[placeholder='Search for a GIF']", { value: "cry" });
});
QUnit.test("Reopen GIF category list when going back", async () => {
@@ -196,7 +196,9 @@ QUnit.test(
const { openDiscuss } = await start();
openDiscuss(channelId);
await click("span", { text: "General" });
await click("button[aria-label='GIFs']");
await click("button[aria-label='Emojis']");
await contains(".o-mail-PickerContent-picker .o-mail-PickerContent-emojiPicker");
await click("button:contains('GIFs')");
await contains(".popover .o-discuss-GifPicker", { count: 0 });
await contains(".o-mail-Composer-footer .o-discuss-GifPicker");
}
@@ -217,6 +219,6 @@ QUnit.test("Searching for a GIF with a failling RPC should display an error", as
});
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
await insertText("input[placeholder='Search for a gif']", "search");
await insertText("input[placeholder='Search for a GIF']", "search");
await contains(".o-discuss-GifPicker-error");
});
@@ -0,0 +1,3 @@
.o-EmojiPicker {
--o-emoji-picker-active: #{$white};
}
@@ -20,7 +20,7 @@ import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
import { fuzzyLookup } from "@web/core/utils/search";
import { useService } from "@web/core/utils/hooks";
import { useAutofocus, useService } from "@web/core/utils/hooks";
/**
*
@@ -125,23 +125,22 @@ export async function loadEmoji() {
export const EMOJI_PER_ROW = 9;
export class EmojiPicker extends Component {
static props = ["onSelect", "className?", "close?", "onClose?", "storeScroll?"];
static props = ["close?", "onClose?", "onSelect", "state?", "storeScroll?"];
static template = "web.EmojiPicker";
categories = null;
emojis = null;
shouldScrollElem = null;
lastSearchStr;
lastSearchTerm;
setup() {
this.inputRef = useRef("input");
this.gridRef = useRef("emoji-grid");
this.ui = useState(useService("ui"));
this.state = useState({
activeEmojiIndex: 0,
categoryId: null,
recent: JSON.parse(browser.localStorage.getItem("web.emoji.frequent") || "{}"),
searchStr: "",
searchTerm: "",
});
const onStorage = (ev) => {
if (ev.key === "web.emoji.frequent") {
@@ -154,6 +153,7 @@ export class EmojiPicker extends Component {
onWillDestroy(() => {
browser.removeEventListener("storage", onStorage);
});
useAutofocus();
onWillStart(async () => {
const { categories, emojis } = await loadEmoji();
this.categories = categories;
@@ -173,7 +173,6 @@ export class EmojiPicker extends Component {
if (this.emojis.length === 0) {
return;
}
this.inputRef.el.focus();
this.highlightActiveCategory();
if (this.props.storeScroll) {
this.gridRef.el.scrollTop = this.props.storeScroll.get();
@@ -199,18 +198,18 @@ export class EmojiPicker extends Component {
});
useEffect(
() => {
if (this.state.searchStr) {
if (this.searchTerm) {
this.gridRef.el.scrollTop = 0;
this.state.categoryId = null;
} else {
if (this.lastSearchStr) {
if (this.lastSearchTerm) {
this.gridRef.el.scrollTop = 0;
}
this.highlightActiveCategory();
}
this.lastSearchStr = this.state.searchStr;
this.lastSearchTerm = this.searchTerm;
},
() => [this.state.searchStr]
() => [this.searchTerm]
);
onWillUnmount(() => {
if (this.emojis.length === 0) {
@@ -222,6 +221,18 @@ export class EmojiPicker extends Component {
});
}
get searchTerm() {
return this.props.state ? this.props.state.searchTerm : this.state.searchTerm;
}
set searchTerm(value) {
if (this.props.state) {
this.props.state.searchTerm = value;
} else {
this.state.searchTerm = value;
}
}
get itemsNumber() {
return this.recentEmojis.length + this.getEmojis().length;
}
@@ -234,7 +245,6 @@ export class EmojiPicker extends Component {
}
onClick(ev) {
markEventHandled(ev, "EmojiPicker.onClick");
markEventHandled(ev, "emoji.selectEmoji");
}
@@ -286,9 +296,8 @@ export class EmojiPicker extends Component {
}
getEmojis() {
const search = this.state.searchStr;
if (search.length > 1) {
return fuzzyLookup(this.state.searchStr, this.emojis, (emoji) =>
if (this.searchTerm.length > 1) {
return fuzzyLookup(this.searchTerm, this.emojis, (emoji) =>
[emoji.name, ...emoji.keywords, ...emoji.emoticons, ...emoji.shortcodes].join(" ")
);
}
@@ -297,18 +306,19 @@ export class EmojiPicker extends Component {
selectCategory(ev) {
const id = Number(ev.currentTarget.dataset.id);
this.state.searchStr = "";
this.searchTerm = "";
this.state.categoryId = id;
this.shouldScrollElem = true;
}
selectEmoji(ev) {
const codepoints = ev.currentTarget.dataset.codepoints;
this.props.onSelect(codepoints);
const resetOnSelect = !ev.shiftKey && !this.ui.isSmall;
this.props.onSelect(codepoints, resetOnSelect);
this.state.recent[codepoints] ??= 0;
this.state.recent[codepoints]++;
browser.localStorage.setItem("web.emoji.frequent", JSON.stringify(this.state.recent));
if (!ev.shiftKey) {
if (resetOnSelect) {
this.gridRef.el.scrollTop = 0;
this.props.close?.();
this.props.onClose?.();
@@ -4,36 +4,21 @@
}
.o-EmojiPicker {
display: grid;
grid-template-areas:
"search"
"content"
"navbar";
&:not(.o-small) {
grid-template-rows: 40px 1fr 32px;
--o-emoji-picker-active: #{$gray-200};
.o-active {
background-color: var(--o-emoji-picker-active) !important;
}
.o-Emoji {
width: 30px;
font-size: 1rem;
font-size: 0.8rem;
&:hover {
background-color: $gray-200;
background-color: var(--o-emoji-picker-active) !important;
}
}
&.o-small .o-Emoji {
width: 40px;
font-size: 1.5rem;
}
.o-EmojiPicker-content {
grid-area: content;
}
.o-EmojiPicker-navbar {
grid-area: navbar;
.o-Emoji {
filter: grayscale(1);
}
@@ -43,22 +28,6 @@
filter: grayscale(1);
}
.o-EmojiPicker-search {
grid-area: search;
background-color: $input-bg;
input {
&:focus, &:hover {
& + .oi-search {
@extend .bg-white !optional;
}
}
&:not(:focus) + i.oi-search {
opacity: 60%;
}
}
}
.o-EmojiPicker-empty {
font-size: 5rem !important;
filter: grayscale(0.25);
@@ -69,3 +38,13 @@
// invisible character so that category has constant height, regardless of text content.
content: "\200b"; /* unicode zero width space character */
}
.o-EmojiPicker-search {
input {
&:not(:focus) {
& + .oi-search {
@extend .text-muted !optional;
}
}
}
}
@@ -2,23 +2,28 @@
<templates xml:space="preserve">
<t t-name="web.EmojiPicker">
<div class="o-EmojiPicker bg-view" t-att-class="{ 'd-flex flex-column align-items-center justify-content-center': emojis.length === 0, 'o-small': ui.isSmall }" t-attf-class="{{ props.className }}" t-on-click="onClick" t-on-keydown="onKeydown">
<div class="o-EmojiPicker bg-view d-flex flex-column justify-content-center" t-on-click="onClick" t-on-keydown="onKeydown">
<t t-if="emojis.length === 0">
<span class="o-EmojiPicker-empty">😵‍💫</span>
<span class="fs-5 text-muted">Failed to load emojis...</span>
</t>
<t t-else="">
<div class="o-EmojiPicker-search d-flex align-items-center mt-2 mx-2 rounded">
<input type="text" class="form-control border-0 flex-grow-1 rounded-end-0" t-ref="input" placeholder="Search for an emoji" t-model="state.searchStr" t-on-input="() => this.state.activeEmojiIndex = 0"/>
<i class="oi oi-search p-2 fs-5 rounded-end" title="Search..." role="img" aria-label="Search..."/>
<div class="o-EmojiPicker-search d-flex align-items-center mx-2 mt-2 rounded">
<span class="d-flex mx-1 w-100 rounded o-active">
<t t-call="web.EmojiPicker.searchInput">
<t t-if="props.state" t-set="localState" t-value="props.state"/>
<t t-else="" t-set="localState" t-value="state"/>
</t>
<i class="oi oi-search p-2 fs-5 rounded-start-0 rounded-3 o-active" title="Search..." role="img" aria-label="Search..."/>
</span>
</div>
<t t-set="itemIndex" t-value="0"/>
<div class="o-EmojiPicker-content overflow-auto d-flex flex-wrap align-items-center user-select-none mt-1" t-att-class="getEmojis().length === 0 ? 'flex-column justify-content-center' : 'align-content-start'" t-ref="emoji-grid" t-on-scroll="highlightActiveCategory">
<t t-if="state.searchStr and getEmojis().length === 0" class="d-flex flex-column">
<div class="o-EmojiPicker-content overflow-auto d-flex flex-grow-1 w-100 flex-wrap align-items-center user-select-none mt-1" t-att-class="getEmojis().length === 0 ? 'flex-column justify-content-center' : 'align-content-start'" t-ref="emoji-grid" t-on-scroll="highlightActiveCategory">
<t t-if="searchTerm and getEmojis().length === 0" class="d-flex flex-column">
<span class="o-EmojiPicker-empty">😢</span>
<span class="fs-5 text-muted">No emoji match your search</span>
</t>
<t t-if="recentEmojis.length > 0 and !state.searchStr">
<t t-if="recentEmojis.length > 0 and !searchTerm">
<t t-call="web.EmojiPicker.section">
<t t-set="category" t-value="recentCategory"/>
</t>
@@ -31,21 +36,21 @@
</t>
<t t-set="current" t-value=""/>
<t t-foreach="getEmojis()" t-as="emoji" t-key="emoji_index">
<t t-if="!state.searchStr and current !== emoji.category">
<t t-if="!searchTerm and current !== emoji.category">
<t t-set="current" t-value="emoji.category"/>
<t t-set="category" t-value="categories.find(c => c.name === current)"/>
<t t-call="web.EmojiPicker.section">
<t t-set="category" t-value="category"/>
</t>
</t>
<t t-elif="state.searchStr" t-set="categorySortId" t-value="null"/>
<t t-elif="searchTerm" t-set="categorySortId" t-value="null"/>
<t t-call="web.EmojiPicker.emoji">
<t t-set="emoji" t-value="emoji"/>
</t>
<t t-set="itemIndex" t-value="itemIndex + 1"/>
</t>
</div>
<div class="o-EmojiPicker-navbar d-flex align-items-center bg-100 overflow-auto">
<div class="o-EmojiPicker-navbar d-flex flex-shrink-0 w-100 align-items-center bg-100 overflow-auto border-top">
<t t-if="recentEmojis.length > 0" t-call="web.EmojiPicker.tab">
<t t-set="category" t-value="recentCategory"/>
</t>
@@ -60,7 +65,7 @@
</t>
<t t-name="web.EmojiPicker.tab">
<span class="o-Emoji text-center p-1 cursor-pointer" t-att-class="{'bg-300': category.sortId === state.categoryId}" t-att-title="category.name" t-att-data-id="category.sortId" t-on-click="selectCategory">
<span class="o-Emoji text-center p-1 cursor-pointer" t-att-class="{'o-active': category.sortId === state.categoryId}" t-att-title="category.name" t-att-data-id="category.sortId" t-on-click="selectCategory">
<t t-esc="category.title"/>
</span>
</t>
@@ -71,9 +76,13 @@
</t>
<t t-name="web.EmojiPicker.emoji">
<span class="o-Emoji cursor-pointer d-flex justify-content-center" t-att-class="{ 'bg-200': state.activeEmojiIndex === itemIndex, 'fs-2': !ui.isSmall, 'fs-1': ui.isSmall }" t-att-title="emoji.name" t-att-data-codepoints="emoji.codepoints" t-att-data-index="itemIndex" t-att-data-category="recentCategory.sortId" t-on-click="selectEmoji">
<span class="o-Emoji cursor-pointer d-flex justify-content-center rounded" t-att-class="{ 'o-active': state.activeEmojiIndex === itemIndex, 'fs-2': !ui.isSmall, 'fs-1': ui.isSmall }" t-att-title="emoji.name" t-att-data-codepoints="emoji.codepoints" t-att-data-index="itemIndex" t-att-data-category="recentCategory.sortId" t-on-click="selectEmoji">
<span t-esc="emoji.codepoints"/>
</span>
</t>
<t t-name="web.EmojiPicker.searchInput">
<input class="form-control border-0 flex-grow-1 rounded-3 rounded-end-0 o-active" placeholder="Search for an emoji" t-model="localState.searchTerm" t-ref="autofocus" t-att-model="localState.searchTerm" t-on-input="() => this.state.activeEmojiIndex = 0"/>
</t>
</templates>