emoji-views: use lit-html for templating

* declare picker events in lit-html
* init intersection observer only once
* don't set value manually
* don't manually add classes
* avoid x-scrollbar and 'undefined' in search input
This commit is contained in:
JC Brand 2020-01-25 08:51:58 +01:00
parent 11e219dd41
commit d310f1e3e4
7 changed files with 154 additions and 121 deletions

View File

@ -26,12 +26,19 @@
height: 8em; height: 8em;
overflow-y: auto; overflow-y: auto;
.emoji-category__heading { .emoji-category__heading {
cursor: auto; clear: both;
color: var(--subdued-color); color: var(--subdued-color);
cursor: auto;
display: block;
font-size: var(--font-size); font-size: var(--font-size);
margin: 0; margin: 0;
padding: 0.75em 0 0 0.5em; padding: 0.75em 0 0 0.5em;
} }
.emoji-lists__container {
overflow-x: hidden;
}
.emoji-picker { .emoji-picker {
li { li {
float: left; float: left;

View File

@ -448,7 +448,7 @@
toolbar.querySelector('a.toggle-smiley').click(); toolbar.querySelector('a.toggle-smiley').click();
const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__lists')); const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__lists'));
const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji')); const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
item.click() item.click()
expect(counter.textContent).toBe('179'); expect(counter.textContent).toBe('179');

View File

@ -28,7 +28,7 @@
toolbar.querySelector('a.toggle-smiley').click(); toolbar.querySelector('a.toggle-smiley').click();
await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists'))); await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container')); const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji')); const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
item.click() item.click()
expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
toolbar.querySelector('a.toggle-smiley').click(); // Close the panel again toolbar.querySelector('a.toggle-smiley').click(); // Close the panel again
@ -60,7 +60,7 @@
let picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container')); let picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
const input = picker.querySelector('.emoji-search'); const input = picker.querySelector('.emoji-search');
expect(input.value).toBe(':gri'); expect(input.value).toBe(':gri');
let visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
expect(visible_emojis.length).toBe(3); expect(visible_emojis.length).toBe(3);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:'); expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
@ -68,7 +68,7 @@
// Test that TAB autocompletes the to first match // Test that TAB autocompletes the to first match
view.emoji_picker_view.onKeyDown(tab_event); view.emoji_picker_view.onKeyDown(tab_event);
visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
expect(visible_emojis.length).toBe(1); expect(visible_emojis.length).toBe(1);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
expect(input.value).toBe(':grimacing:'); expect(input.value).toBe(':grimacing:');
@ -130,7 +130,7 @@
}; };
view.emoji_picker_view.onKeyDown(event); view.emoji_picker_view.onKeyDown(event);
await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley'); await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley');
let visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
expect(visible_emojis.length).toBe(2); expect(visible_emojis.length).toBe(2);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:'); expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
@ -144,7 +144,7 @@
const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'}); const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
view.emoji_picker_view.onKeyDown(tab_event); view.emoji_picker_view.onKeyDown(tab_event);
expect(input.value).toBe(':smiley:'); expect(input.value).toBe(':smiley:');
visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
expect(visible_emojis.length).toBe(1); expect(visible_emojis.length).toBe(1);
expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');

View File

@ -3,15 +3,15 @@
* @copyright 2020, the Converse.js contributors * @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2) * @license Mozilla Public License (MPLv2)
*/ */
import "@converse/headless/converse-emoji"; import "@converse/headless/converse-emoji";
import { HTMLView } from "skeletor.js/src/htmlview";
import { debounce, find, get } from "lodash"; import { debounce, find, get } from "lodash";
import DOMNavigator from "./dom-navigator"; import DOMNavigator from "./dom-navigator";
import bootstrap from "bootstrap.native"; import bootstrap from "bootstrap.native";
import emoji_picker from "templates/emoji_picker.js";
import sizzle from 'sizzle';
import tpl_emoji_button from "templates/emoji_button.html"; import tpl_emoji_button from "templates/emoji_button.html";
import tpl_emojis from "templates/emojis.html";
const { Backbone, sizzle } = converse.env;
const u = converse.env.utils; const u = converse.env.utils;
@ -126,15 +126,8 @@ converse.plugins.add('converse-emoji-views', {
Object.assign(_converse.ChatBoxView.prototype, emoji_aware_chat_view); Object.assign(_converse.ChatBoxView.prototype, emoji_aware_chat_view);
_converse.EmojiPickerView = Backbone.VDOMView.extend({ _converse.EmojiPickerView = HTMLView.extend({
className: 'emoji-picker', className: 'emoji-picker dropdown-menu toolbar-menu',
events: {
'click .emoji-picker__header li.emoji-category .pick-category': 'chooseCategory',
'click .emoji-skintone-picker li.emoji-skintone': 'chooseSkinTone',
'click .insert-emoji': 'insertEmoji',
'focus .emoji-search': 'disableArrowNavigation',
'keydown .emoji-search': 'onKeyDown'
},
initialize (config) { initialize (config) {
this.chatview = config.chatview; this.chatview = config.chatview;
@ -144,50 +137,47 @@ converse.plugins.add('converse-emoji-views', {
body.addEventListener('keydown', this.onGlobalKeyDown); body.addEventListener('keydown', this.onGlobalKeyDown);
this.search_results = []; this.search_results = [];
this.debouncedFilter = debounce(input => this.filter(input.value), 150); this.debouncedFilter = debounce(input => this.filter(input.value), 250);
this.listenTo(this.model, 'change:query', this.render) this.listenTo(this.model, 'change:query', this.render);
this.listenTo(this.model, 'change:current_skintone', this.render) this.listenTo(this.model, 'change:current_skintone', this.render);
this.listenTo(this.model, 'change:current_category', () => { this.listenTo(this.model, 'change:current_category', this.render);
this.render();
const category = this.model.get('current_category');
const el = this.el.querySelector(`.emoji-category[data-category="${category}"]`);
this.navigator.select(el);
!this.navigator.enabled && this.navigator.enable();
});
this.render(); this.render();
this.initArrowNavigation();
this.initIntersectionObserver();
}, },
toHTML () { toHTML () {
return tpl_emojis( return emoji_picker(
Object.assign( Object.assign(
this.model.toJSON(), { this.model.toJSON(), {
'__': __,
'_converse': _converse, '_converse': _converse,
'emoji_categories': _converse.emoji_categories, 'emoji_categories': _converse.emoji_categories,
'emojis_by_category': _converse.emojis.json, 'emojis_by_category': _converse.emojis.json,
'onSkintonePicked': ev => this.chooseSkinTone(ev),
'onEmojiPicked': ev => this.insertEmoji(ev),
'onCategoryPicked': ev => this.chooseCategory(ev),
'onSearchInputBlurred': ev => this.chatview.emitFocused(ev),
'onSearchInputKeyDown': ev => this.onKeyDown(ev),
'onSearchInputFocus': ev => this.onSearchInputFocus(),
'search_results': this.search_results,
'shouldBeHidden': shortname => this.shouldBeHidden(shortname), 'shouldBeHidden': shortname => this.shouldBeHidden(shortname),
'skintones': ['tone1', 'tone2', 'tone3', 'tone4', 'tone5'],
'toned_emojis': _converse.emojis.toned, 'toned_emojis': _converse.emojis.toned,
'transform': u.getEmojiRenderer(), 'transform': u.getEmojiRenderer(),
'transformCategory': shortname => u.getEmojiRenderer()(this.getTonedShortname(shortname)), 'transformCategory': shortname => u.getEmojiRenderer()(this.getTonedShortname(shortname))
'search_results': this.search_results
} }
) )
); );
}, },
onSearchInputFocus (ev) {
this.chatview.emitBlurred(ev);
this.disableArrowNavigation();
},
remove () { remove () {
const body = document.querySelector('body'); const body = document.querySelector('body');
body.removeEventListener('keydown', this.onGlobalKeyDown); body.removeEventListener('keydown', this.onGlobalKeyDown);
Backbone.VDOMView.prototype.remove.call(this); HTMLView.prototype.remove.call(this);
},
afterRender () {
this.initIntersectionObserver();
const textarea = this.el.querySelector('.emoji-search');
textarea.addEventListener('focus', ev => this.chatview.emitFocused(ev));
textarea.addEventListener('blur', ev => this.chatview.emitBlurred(ev));
this.initArrowNavigation();
}, },
initArrowNavigation () { initArrowNavigation () {
@ -242,28 +232,13 @@ converse.plugins.add('converse-emoji-views', {
this.search_results = _converse.emojis_list.filter(e => _converse.FILTER_CONTAINS(e.sn, value)); this.search_results = _converse.emojis_list.filter(e => _converse.FILTER_CONTAINS(e.sn, value));
} }
this.model.set({'query': value}); this.model.set({'query': value});
if (set_property) {
// XXX: Ideally we would set `query` on the model and
// then let the view re-render, instead of doing it
// manually here. Snabbdom supports setting properties,
// Backbone.VDOMView doesn't.
const input = this.el.querySelector('.emoji-search');
input.value = value;
}
}, },
setCategoryForElement (el) { setCategoryForElement (el) {
const category = el.getAttribute('data-category'); const category = el.getAttribute('data-category');
const old_category = this.model.get('current_category'); const old_category = this.model.get('current_category');
if (old_category !== category) { if (old_category !== category) {
this.model.save( this.model.save({'current_category': category});
{'current_category': category},
{'silent': true}
);
const category_els = sizzle('.emoji-picker__header .emoji-category', this.el);
category_els.forEach(el => u.removeClass('picked', el));
const new_el = category_els.filter(el => el.getAttribute('data-category') === category).pop();
new_el && u.addClass('picked', new_el);
} }
}, },
@ -341,11 +316,13 @@ converse.plugins.add('converse-emoji-views', {
onKeyDown (ev) { onKeyDown (ev) {
if (ev.keyCode === converse.keycodes.RIGHT_ARROW) { if (ev.keyCode === converse.keycodes.RIGHT_ARROW) {
ev.preventDefault(); if (this.navigator.enabled) {
ev.stopPropagation(); ev.preventDefault();
ev.target.blur(); ev.stopPropagation();
const first_el = this.el.querySelector('.pick-category'); ev.target.blur();
this.navigator.select(first_el, 'right'); const first_el = this.el.querySelector('.pick-category');
this.navigator.select(first_el, 'right');
}
} else if (ev.keyCode === converse.keycodes.TAB) { } else if (ev.keyCode === converse.keycodes.TAB) {
if (ev.target.value) { if (ev.target.value) {
ev.preventDefault(); ev.preventDefault();
@ -410,11 +387,10 @@ converse.plugins.add('converse-emoji-views', {
chooseCategory (ev) { chooseCategory (ev) {
ev.preventDefault && ev.preventDefault(); ev.preventDefault && ev.preventDefault();
ev.stopPropagation && ev.stopPropagation(); ev.stopPropagation && ev.stopPropagation();
const input = this.el.querySelector('.emoji-search');
input.value = '';
const el = ev.target.matches('li') ? ev.target : u.ancestor(ev.target, 'li'); const el = ev.target.matches('li') ? ev.target : u.ancestor(ev.target, 'li');
this.setCategoryForElement(el); this.setCategoryForElement(el);
this.navigator.select(el); this.navigator.select(el);
!this.navigator.enabled && this.navigator.enable();
this.setScrollPosition(); this.setScrollPosition();
}, },

View File

@ -8,7 +8,6 @@ import xss from "xss/dist/xss";
const i18n_address = __('Groupchat address (JID)'); const i18n_address = __('Groupchat address (JID)');
const i18n_archiving = __('Message archiving'); const i18n_archiving = __('Message archiving');
const i18n_archiving_help = __('Messages are archived on the server'); const i18n_archiving_help = __('Messages are archived on the server');
const i18n_close = __('Close');
const i18n_desc = __('Description'); const i18n_desc = __('Description');
const i18n_features = __('Features'); const i18n_features = __('Features');
const i18n_hidden = __('Hidden'); const i18n_hidden = __('Hidden');

View File

@ -0,0 +1,105 @@
import { html } from "lit-html";
import { __ } from '@converse/headless/i18n';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import xss from "xss/dist/xss";
const i18n_search = __('Search');
const i18n_search_results = __('Search results');
const skintones = ['tone1', 'tone2', 'tone3', 'tone4', 'tone5'];
const emoji_category = (o) => {
const category_emoji = xss.filterXSS(o.transformCategory(o.emoji_categories[o.category]), {'whitelist': {'img': []}});
return html`
<li data-category="${o.category}"
class="emoji-category ${o.current_category} ${o.category} ${(o.current_category === o.category) ? 'picked' : ''}"
title="${__(o._converse.emoji_category_labels[o.category])}">
<a class="pick-category"
@click=${o.onCategoryPicked}
href="#emoji-picker-${o.category}"
data-category="${o.category}">${unsafeHTML(category_emoji)} </a>
</li>
`;
}
const emoji_picker_header = (o) => html`
<ul>
${ Object.keys(o.emoji_categories).map(category => (o.emoji_categories[category] ? emoji_category(Object.assign({category}, o)) : '')) }
</ul>
`;
const emoji_item = (o) => {
let emoji;
if (o._converse.use_system_emojis) {
emoji = o.transform(o.emoji.sn);
} else {
emoji = unsafeHTML(xss.filterXSS(o.transform(o.emoji.sn), {'whitelist': {'img': []}}));
}
return html`
<li class="emoji insert-emoji ${o.shouldBeHidden(o.emoji.sn) ? 'hidden' : ''}" data-emoji="${o.emoji.sn}" title="${o.emoji.sn}">
<a href="#" @click=${o.onEmojiPicked} data-emoji="${o.emoji.sn}">${emoji}</a>
</li>
`;
}
const search_results = (o) => html`
<span ?hidden=${!o.query} class="emoji-lists__container emojis-lists__container--search">
<a id="emoji-picker-search-results" class="emoji-category__heading">${i18n_search_results}</a>
<ul class="emoji-picker">
${ o.search_results.map(emoji => emoji_item(Object.assign({emoji}, o))) }
</ul>
</span>
`;
const emojis_for_category = (o) => html`
<a id="emoji-picker-${o.category}" class="emoji-category__heading" data-category="${o.category}">${ __(o._converse.emoji_category_labels[o.category]) }</a>
<ul class="emoji-picker" data-category="${o.category}">
${ Object.values(o.emojis_by_category[o.category]).map(emoji => emoji_item(Object.assign({emoji}, o))) }
</ul>
`;
const skintone_emoji = (o) => {
const shortname = ':'+o.skintone+':';
let emoji;
if (o._converse.use_system_emojis) {
emoji = o.transform(shortname);
} else {
emoji = unsafeHTML(xss.filterXSS(o.transform(shortname), {'whitelist': {'img': []}}));
}
return html`
<li data-skintone="${o.skintone}" class="emoji-skintone ${(o.current_skintone === o.skintone) ? 'picked' : ''}">
<a class="pick-skintone" href="#" data-skintone="${o.skintone}" @click=${o.onSkintonePicked}>${emoji}</a>
</li>
`;
}
const all_emojis = (o) => html`
<span ?hidden=${o.query} class="emoji-lists__container">
${Object.keys(o.emoji_categories).map(category => (o.emoji_categories[category] ? emojis_for_category(Object.assign({category}, o)) : ''))}
</span>
`;
export default (o) => html`
<div class="emoji-picker__header">
<input class="form-control emoji-search" name="emoji-search" placeholder="${i18n_search}"
.value=${o.query || ''}
@keydown=${o.onSearchInputKeyDown}
@blur=${o.onSearchInputBlurred}
@focus=${o.onSearchInputFocus}>
${ o.query ? '' : emoji_picker_header(o) }
</div>
<div class="emoji-picker__lists">
${search_results(o)}
${all_emojis(o)}
</div>
<div class="emoji-skintone-picker">
<label>Skin tone</label>
<ul>${ skintones.map(skintone => skintone_emoji(Object.assign({skintone}, o))) }</ul>
</div>
`;

View File

@ -1,54 +0,0 @@
<div class="emoji-picker dropdown-menu toolbar-menu">
<div class="emoji-picker__header">
<input class="form-control emoji-search" name="emoji-search" placeholder="{{{o.__('Search')}}}"/>
{[ if (!o.query) { ]}
<ul>
{[ Object.keys(o.emoji_categories).forEach(function (category) { ]}
{[ if (o.emoji_categories[category]) { ]}
<li data-category="{{{category}}}" class="emoji-category {{{o.current_category}}} {{{ category}}} {[ if (o.current_category === category) { ]} picked {[ } ]}"
title="{{{ o.__(o._converse.emoji_category_labels[category]) }}}">
<a class="pick-category" href="#emoji-picker-{{{category}}}" data-category="{{{category}}}"> {{ o.transformCategory(o.emoji_categories[category]) }} </a>
</li>
{[ } ]}
{[ }); ]}
</ul>
{[ } ]}
</div>
<div class="emoji-picker__lists">
{[ if (o.query) { ]}
<a id="emoji-picker-search-results" class="emoji-category__heading">{{{o.__('Search results')}}}</a>
<ul class="emoji-picker">
{[ o.search_results.forEach(function (emoji) { ]}
<li class="emoji insert-emoji {[ if (o.shouldBeHidden(emoji.sn)) { ]} hidden {[ }; ]}"
data-emoji="{{{emoji.sn}}}" title="{{{emoji.sn}}}">
<a href="#" data-emoji="{{{emoji.sn}}}"> {{ o.transform(emoji.sn) }} </a>
</li>
{[ }); ]}
</ul>
{[ } else { ]}
{[ Object.keys(o.emoji_categories).forEach(function (category) { ]}
{[ if (o.emoji_categories[category]) { ]}
<a id="emoji-picker-{{{category}}}" class="emoji-category__heading" data-category="{{{category}}}">{{{ o.__(o._converse.emoji_category_labels[category]) }}} </a>
<ul class="emoji-picker" data-category="{{{category}}}">
{[ Object.values(o.emojis_by_category[category]).forEach(function (emoji) { ]}
<li class="emoji insert-emoji {[ if (o.shouldBeHidden(emoji.sn)) { ]} hidden {[ }; ]}"
data-emoji="{{{emoji.sn}}}" title="{{{emoji.sn}}}">
<a href="#" data-emoji="{{{emoji.sn}}}"> {{ o.transform(emoji.sn) }} </a>
</li>
{[ }); ]}
</ul>
{[ } ]}
{[ }); ]}
{[ } ]}
</div>
<div class="emoji-skintone-picker">
<label>Skin tone</label>
<ul>
{[ o.skintones.forEach(function (skintone) { ]}
<li data-skintone="{{{skintone}}}" class="emoji-skintone {[ if (o.current_skintone === skintone) { ]} picked {[ } ]}">
<a class="pick-skintone" href="#" data-skintone="{{{skintone}}}"> {{ o.transform(':'+skintone+':') }} </a>
</li>
{[ }); ]}
</ul>
</div>
</div>