diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js
index 8f3aad5fa64..b36e5e55d4d 100644
--- a/app/assets/javascripts/discourse/app/components/emoji-picker.js
+++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js
@@ -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(`${escaped}`);
+ return htmlSafe(escaped);
},
_codeWithDiversity(code, selectedDiversity) {
diff --git a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs b/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs
index d9c2156911a..5dec8ae3feb 100644
--- a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs
@@ -37,7 +37,7 @@
{{#each this.recentEmojis as |emoji|}}
- {{replace-emoji (concat ":" emoji ":") (hash lazy=true)}}
+ {{replace-emoji (concat ":" emoji ":") (hash lazy=true class="recent-emoji")}}
{{/each}}
@@ -55,7 +55,9 @@
{{#if emojis.length}}
{{#each emojis as |emoji|}}
-

+
+
+
{{/each}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/emoji-picker-test.js b/app/assets/javascripts/discourse/tests/acceptance/emoji-picker-test.js
index 5faac2f7dc1..97c368335be 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/emoji-picker-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/emoji-picker-test.js
@@ -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"));
+ });
});
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js b/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js
index 8be1a2a50ac..1924526937a 100644
--- a/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js
+++ b/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js
@@ -32,12 +32,12 @@ discourseModule("Unit | Utility | emoji", function () {
);
testUnescape(
"emoticons :)",
- `emoticons
`,
+ `emoticons
`,
"emoticons are still supported"
);
testUnescape(
"With emoji :O: :frog: :smile:",
- `With emoji
`,
+ `With emoji
`,
"title with emoji"
);
testUnescape(
@@ -47,27 +47,27 @@ discourseModule("Unit | Utility | emoji", function () {
);
testUnescape(
"(:frog:) :)",
- `(
)
`,
+ `(
)
`,
"non-word characters allowed next to emoji"
);
testUnescape(
":smile: hi",
- `
hi`,
+ `
hi`,
"start of line"
);
testUnescape(
"hi :smile:",
- `hi
`,
+ `hi
`,
"end of line"
);
testUnescape(
"hi :blonde_woman:t4:",
- `hi
`,
+ `hi
`,
"support for skin tones"
);
testUnescape(
"hi :blonde_woman:t4: :blonde_man:t6:",
- `hi
`,
+ `hi
`,
"support for multiple skin tones"
);
testUnescape(
@@ -95,7 +95,7 @@ discourseModule("Unit | Utility | emoji", function () {
);
testUnescape(
"Hello 😊 World",
- `Hello
World`,
+ `Hello
World`,
"emoji from Unicode emoji"
);
testUnescape(
@@ -108,7 +108,7 @@ discourseModule("Unit | Utility | emoji", function () {
);
testUnescape(
"Hello😊World",
- `Hello
World`,
+ `Hello
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
`,
+ `hi
`,
"emoji when inline translation enabled",
{ enable_inline_emoji_translation: true }
);
diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js
index 5b5446eb3c9..1dc3997a5fb 100644
--- a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js
+++ b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js
@@ -143,7 +143,7 @@ discourseModule("Unit | Model | topic", function () {
assert.strictEqual(
topic.get("fancyTitle"),
- `
with all
the emojis 
`,
+ `
with all
the emojis 
`,
"supports emojis"
);
});
@@ -173,7 +173,7 @@ discourseModule("Unit | Model | topic", function () {
assert.strictEqual(
topic.get("escapedExcerpt"),
- `This is a test topic
`,
+ `This is a test topic
`,
"supports emojis"
);
});
diff --git a/app/assets/javascripts/pretty-text/addon/emoji.js b/app/assets/javascripts/pretty-text/addon/emoji.js
index 2c2028e5668..ba3782a79ae 100644
--- a/app/assets/javascripts/pretty-text/addon/emoji.js
+++ b/app/assets/javascripts/pretty-text/addon/emoji.js
@@ -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;
};
diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss
index fb4717e9dd2..4dd77506fd8 100644
--- a/app/assets/stylesheets/common/base/emoji.scss
+++ b/app/assets/stylesheets/common/base/emoji.scss
@@ -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;
- }
}
}