FEATURE: Select emojis in picker via keyboard (#18163)
* DEV: Make emoji elements focusable Since emoji elements are of type `<img>` it requires a `tablindex="0"` in order to be focusable. * WIP: Handle emoji focus/selection via arrow keys Near completion, however, need a few fixes/improvements and overall code cleanup * WIP: Testing * DEV: Fixes and cleanup * DEV: Follow conventions * DEV: Improve up/down traversal when recents present * DEV: Emoji markup in tests should include `tabindex` * DEV: Add `tabindex` to topic tests * DEV: Variable name as `searchInput` instead of `searchBar` * DEV: Use appropriate method name (`_setNumEmojiPerRow`) * DEV: Add comments and avoid nested if * WIP: Adding test * Fix first test * DEV: Add assertions for arrow keys and escape key * Some fixes for up/down navigation This does not fix everything, when going from one section to another, there are issues * Fix a small regression * FIX: Ability to focus on search results Fixes regression * Refactor calculating next up/down emoji * Debugging test failure * Skip stubborn CI test, add others Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
parent
e3d7253199
commit
eb987460f2
|
@ -41,6 +41,10 @@ export default Component.extend({
|
|||
usePopper: true,
|
||||
placement: "auto", // one of popper.js' placements, see https://popper.js.org/docs/v2/constructors/#options
|
||||
initialFilter: "",
|
||||
elements: {
|
||||
searchInput: ".emoji-picker-search-container input",
|
||||
picker: ".emoji-picker-emoji-area",
|
||||
},
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
@ -97,7 +101,6 @@ export default Component.extend({
|
|||
if (!emojiPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popperAnchor = this._getPopperAnchor();
|
||||
|
||||
if (!this.site.isMobileDevice && this.usePopper && popperAnchor) {
|
||||
|
@ -245,10 +248,100 @@ export default Component.extend({
|
|||
|
||||
@action
|
||||
keydown(event) {
|
||||
if (event.code === "Escape") {
|
||||
const arrowKeys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"];
|
||||
const emojis = document.querySelectorAll(".emoji-picker-emoji-area .emoji");
|
||||
|
||||
let currentEmoji;
|
||||
|
||||
this.set(
|
||||
"hoveredEmoji",
|
||||
this._codeWithDiversity(event.target.title, this.selectedDiversity)
|
||||
);
|
||||
|
||||
if (
|
||||
event.key === "ArrowDown" &&
|
||||
this._focusedOn(this.elements.searchInput)
|
||||
) {
|
||||
emojis[0].focus();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (arrowKeys.includes(event.key)) {
|
||||
if (!this._focusedOn(this.elements.picker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(emojis).find((e, index) => {
|
||||
currentEmoji = index;
|
||||
return e.isEqualNode(event.target);
|
||||
});
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
let nextEmoji = currentEmoji + 1;
|
||||
|
||||
if (nextEmoji < emojis.length) {
|
||||
emojis[nextEmoji].focus();
|
||||
} else if (nextEmoji >= emojis.length) {
|
||||
emojis[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
const previousEmoji = currentEmoji - 1;
|
||||
if (currentEmoji > 0) {
|
||||
emojis[previousEmoji].focus();
|
||||
}
|
||||
}
|
||||
|
||||
const active = emojis[currentEmoji];
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
// source: https://stackoverflow.com/a/49090383/349424
|
||||
// look for same element type with
|
||||
// - higher offsetTop
|
||||
// - same offsetLeft
|
||||
const emojiBelow = [...emojis]
|
||||
.filter((c) => c.offsetTop > active.offsetTop)
|
||||
.find((c) => c.offsetLeft === active.offsetLeft);
|
||||
|
||||
emojiBelow?.focus();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
// look for same element type with
|
||||
// - lower offsetTop
|
||||
// - same offsetLeft
|
||||
const emojiAbove = [...emojis]
|
||||
.reverse()
|
||||
.filter((c) => c.offsetTop < active.offsetTop)
|
||||
.find((c) => c.offsetLeft === active.offsetLeft);
|
||||
|
||||
if (emojiAbove) {
|
||||
emojiAbove.focus();
|
||||
} else {
|
||||
document.querySelector(this.elements.searchInput).focus();
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (!this._focusedOn(".emoji")) {
|
||||
return;
|
||||
}
|
||||
this.onEmojiSelection(event);
|
||||
this.onClose(event);
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@action
|
||||
|
@ -256,6 +349,11 @@ export default Component.extend({
|
|||
this._applyFilter(event.target.value);
|
||||
},
|
||||
|
||||
_focusedOn(item) {
|
||||
// returns the item currently being focused on
|
||||
return document.activeElement.closest(item) ? document.activeElement : null;
|
||||
},
|
||||
|
||||
_applyFilter(filter) {
|
||||
const emojiPicker = document.querySelector(".emoji-picker");
|
||||
const results = document.querySelector(".emoji-picker-emoji-area .results");
|
||||
|
@ -287,7 +385,7 @@ export default Component.extend({
|
|||
const escaped = emojiUnescape(`:${escapeExpression(code)}:`, {
|
||||
lazy: true,
|
||||
});
|
||||
return htmlSafe(`<span>${escaped}</span>`);
|
||||
return htmlSafe(escaped);
|
||||
},
|
||||
|
||||
_codeWithDiversity(code, selectedDiversity) {
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
<div class="section-group">
|
||||
{{#each this.recentEmojis as |emoji|}}
|
||||
{{replace-emoji (concat ":" emoji ":") (hash lazy=true)}}
|
||||
{{replace-emoji (concat ":" emoji ":") (hash lazy=true class="recent-emoji")}}
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -55,7 +55,9 @@
|
|||
{{#if emojis.length}}
|
||||
<div class="section-group">
|
||||
{{#each emojis as |emoji|}}
|
||||
<img title={{emoji.code}} width="20" height="20" loading="lazy" class="emoji" src={{emoji.src}}>
|
||||
<span>
|
||||
<img title={{emoji.code}} width="20" height="20" loading="lazy" class="emoji" src={{emoji.src}}>
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
query,
|
||||
queryAll,
|
||||
} from "discourse/tests/helpers/qunit-helpers";
|
||||
import { click, fillIn, visit } from "@ember/test-helpers";
|
||||
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
|
||||
acceptance("EmojiPicker", function (needs) {
|
||||
|
@ -179,4 +179,94 @@ acceptance("EmojiPicker", function (needs) {
|
|||
"it stores diversity scale"
|
||||
);
|
||||
});
|
||||
|
||||
test("emoji can be selected with keyboard", async function (assert) {
|
||||
const searchInput = ".emoji-picker-search-container input";
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click(".emoji.btn");
|
||||
|
||||
let emojis = document.querySelectorAll(
|
||||
".emoji-picker-emoji-area img.emoji"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(searchInput),
|
||||
"search input is focused by default"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(searchInput, "keydown", "ArrowDown");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[0],
|
||||
"ArrowDown from search focuses on the first emoji result"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[1],
|
||||
"ArrowRight from first emoji focuses on the second emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowLeft");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[0],
|
||||
"ArrowLeft from second emoji focuses on the first emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
|
||||
assert.strictEqual(
|
||||
document.querySelector(".d-editor-input").value,
|
||||
":smiley:",
|
||||
"Pressing enter inserts the emoji markup in the composer"
|
||||
);
|
||||
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click(".emoji.btn");
|
||||
await triggerKeyEvent(searchInput, "keydown", "ArrowDown");
|
||||
emojis = document.querySelectorAll(".emoji-picker-emoji-area img.emoji");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(".emoji-picker-emoji-area .emoji.recent-emoji"),
|
||||
"ArrowDown focuses on the first emoji result (recent emoji)"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(".emojis-container .emoji[title='grinning']"),
|
||||
"ArrowDown again focuses on the first emoji result in a section"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[4],
|
||||
"ArrowRight moves focus to next right element"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowUp");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(searchInput),
|
||||
"ArrowUp from first row items moves focus to input"
|
||||
);
|
||||
});
|
||||
|
||||
test("emoji picker can be dismissed with escape key", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
assert.notOk(exists(".emoji-picker"));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -32,12 +32,12 @@ discourseModule("Unit | Utility | emoji", function () {
|
|||
);
|
||||
testUnescape(
|
||||
"emoticons :)",
|
||||
`emoticons <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji'>`,
|
||||
`emoticons <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji' tabindex='0'>`,
|
||||
"emoticons are still supported"
|
||||
);
|
||||
testUnescape(
|
||||
"With emoji :O: :frog: :smile:",
|
||||
`With emoji <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/o.png?v=${v}' title='O' alt='O' class='emoji'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/frog.png?v=${v}' title='frog' alt='frog' class='emoji'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'>`,
|
||||
`With emoji <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/o.png?v=${v}' title='O' alt='O' class='emoji' tabindex='0'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/frog.png?v=${v}' title='frog' alt='frog' class='emoji' tabindex='0'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'>`,
|
||||
"title with emoji"
|
||||
);
|
||||
testUnescape(
|
||||
|
@ -47,27 +47,27 @@ discourseModule("Unit | Utility | emoji", function () {
|
|||
);
|
||||
testUnescape(
|
||||
"(:frog:) :)",
|
||||
`(<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/frog.png?v=${v}' title='frog' alt='frog' class='emoji'>) <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji'>`,
|
||||
`(<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/frog.png?v=${v}' title='frog' alt='frog' class='emoji' tabindex='0'>) <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji' tabindex='0'>`,
|
||||
"non-word characters allowed next to emoji"
|
||||
);
|
||||
testUnescape(
|
||||
":smile: hi",
|
||||
`<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'> hi`,
|
||||
`<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'> hi`,
|
||||
"start of line"
|
||||
);
|
||||
testUnescape(
|
||||
"hi :smile:",
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'>`,
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'>`,
|
||||
"end of line"
|
||||
);
|
||||
testUnescape(
|
||||
"hi :blonde_woman:t4:",
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_woman/4.png?v=${v}' title='blonde_woman:t4' alt='blonde_woman:t4' class='emoji'>`,
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_woman/4.png?v=${v}' title='blonde_woman:t4' alt='blonde_woman:t4' class='emoji' tabindex='0'>`,
|
||||
"support for skin tones"
|
||||
);
|
||||
testUnescape(
|
||||
"hi :blonde_woman:t4: :blonde_man:t6:",
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_woman/4.png?v=${v}' title='blonde_woman:t4' alt='blonde_woman:t4' class='emoji'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_man/6.png?v=${v}' title='blonde_man:t6' alt='blonde_man:t6' class='emoji'>`,
|
||||
`hi <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_woman/4.png?v=${v}' title='blonde_woman:t4' alt='blonde_woman:t4' class='emoji' tabindex='0'> <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blonde_man/6.png?v=${v}' title='blonde_man:t6' alt='blonde_man:t6' class='emoji' tabindex='0'>`,
|
||||
"support for multiple skin tones"
|
||||
);
|
||||
testUnescape(
|
||||
|
@ -95,7 +95,7 @@ discourseModule("Unit | Utility | emoji", function () {
|
|||
);
|
||||
testUnescape(
|
||||
"Hello 😊 World",
|
||||
`Hello <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blush.png?v=${v}' title='blush' alt='blush' class='emoji'> World`,
|
||||
`Hello <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blush.png?v=${v}' title='blush' alt='blush' class='emoji' tabindex='0'> World`,
|
||||
"emoji from Unicode emoji"
|
||||
);
|
||||
testUnescape(
|
||||
|
@ -108,7 +108,7 @@ discourseModule("Unit | Utility | emoji", function () {
|
|||
);
|
||||
testUnescape(
|
||||
"Hello😊World",
|
||||
`Hello<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blush.png?v=${v}' title='blush' alt='blush' class='emoji'>World`,
|
||||
`Hello<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/blush.png?v=${v}' title='blush' alt='blush' class='emoji' tabindex='0'>World`,
|
||||
"emoji from Unicode emoji when inline translation enabled",
|
||||
{
|
||||
enable_inline_emoji_translation: true,
|
||||
|
@ -124,7 +124,7 @@ discourseModule("Unit | Utility | emoji", function () {
|
|||
);
|
||||
testUnescape(
|
||||
"hi:smile:",
|
||||
`hi<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'>`,
|
||||
`hi<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'>`,
|
||||
"emoji when inline translation enabled",
|
||||
{ enable_inline_emoji_translation: true }
|
||||
);
|
||||
|
|
|
@ -143,7 +143,7 @@ discourseModule("Unit | Model | topic", function () {
|
|||
|
||||
assert.strictEqual(
|
||||
topic.get("fancyTitle"),
|
||||
`<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'> with all <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji'> the emojis <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/pear.png?v=${v}' title='pear' alt='pear' class='emoji'><img width=\"20\" height=\"20\" src='/images/emoji/google_classic/peach.png?v=${v}' title='peach' alt='peach' class='emoji'>`,
|
||||
`<img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'> with all <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/slight_smile.png?v=${v}' title='slight_smile' alt='slight_smile' class='emoji' tabindex='0'> the emojis <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/pear.png?v=${v}' title='pear' alt='pear' class='emoji' tabindex='0'><img width=\"20\" height=\"20\" src='/images/emoji/google_classic/peach.png?v=${v}' title='peach' alt='peach' class='emoji' tabindex='0'>`,
|
||||
"supports emojis"
|
||||
);
|
||||
});
|
||||
|
@ -173,7 +173,7 @@ discourseModule("Unit | Model | topic", function () {
|
|||
|
||||
assert.strictEqual(
|
||||
topic.get("escapedExcerpt"),
|
||||
`This is a test topic <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji'>`,
|
||||
`This is a test topic <img width=\"20\" height=\"20\" src='/images/emoji/google_classic/smile.png?v=${v}' title='smile' alt='smile' class='emoji' tabindex='0'>`,
|
||||
"supports emojis"
|
||||
);
|
||||
});
|
||||
|
|
|
@ -98,7 +98,7 @@ export function performEmojiUnescape(string, opts) {
|
|||
opts.skipTitle ? "" : `title='${title}'`
|
||||
} ${
|
||||
opts.lazy ? "loading='lazy' " : ""
|
||||
}alt='${title}' class='${classes}'>`
|
||||
}alt='${title}' class='${classes}' tabindex='0'>`
|
||||
: m;
|
||||
};
|
||||
|
||||
|
|
|
@ -87,12 +87,18 @@ sup img.emoji {
|
|||
|
||||
.section-group,
|
||||
.results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
img.emoji {
|
||||
padding: 0.25em;
|
||||
padding: 0.25em 0.28em;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
|
||||
&:hover {
|
||||
background: $tertiary-low;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(--tertiary-low);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -103,10 +109,6 @@ sup img.emoji {
|
|||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img.emoji {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue