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 slight_smile`, + `emoticons slight_smile`, "emoticons are still supported" ); testUnescape( "With emoji :O: :frog: :smile:", - `With emoji O frog smile`, + `With emoji O frog smile`, "title with emoji" ); testUnescape( @@ -47,27 +47,27 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "(:frog:) :)", - `(frog) slight_smile`, + `(frog) slight_smile`, "non-word characters allowed next to emoji" ); testUnescape( ":smile: hi", - `smile hi`, + `smile hi`, "start of line" ); testUnescape( "hi :smile:", - `hi smile`, + `hi smile`, "end of line" ); testUnescape( "hi :blonde_woman:t4:", - `hi blonde_woman:t4`, + `hi blonde_woman:t4`, "support for skin tones" ); testUnescape( "hi :blonde_woman:t4: :blonde_man:t6:", - `hi blonde_woman:t4 blonde_man:t6`, + `hi blonde_woman:t4 blonde_man:t6`, "support for multiple skin tones" ); testUnescape( @@ -95,7 +95,7 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "Hello 😊 World", - `Hello blush World`, + `Hello blush World`, "emoji from Unicode emoji" ); testUnescape( @@ -108,7 +108,7 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "Hello😊World", - `HelloblushWorld`, + `HelloblushWorld`, "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:", - `hismile`, + `hismile`, "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"), - `smile with all slight_smile the emojis pearpeach`, + `smile with all slight_smile the emojis pearpeach`, "supports emojis" ); }); @@ -173,7 +173,7 @@ discourseModule("Unit | Model | topic", function () { assert.strictEqual( topic.get("escapedExcerpt"), - `This is a test topic smile`, + `This is a test topic smile`, "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; - } } }