UX: Hashtag autocomplete styling (#19426)
* UX: added fadeout + hashtag styling UX: add full name to autocomplete UX: autocomplete mentions styling UX: emoji styling user status UX: autocomplete emoji * DEV: Move hashtag tag counts into new secondary_text prop * FIX: Add is-online style to mention users via chat UX: make is-online avatar styling globally available * DEV: Fix specs * DEV: Test fix Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
parent
c5957490df
commit
8db1f1892d
|
@ -1,9 +1,12 @@
|
||||||
<div class='autocomplete hashtag-autocomplete'>
|
<div class='autocomplete hashtag-autocomplete'>
|
||||||
<ul>
|
<div class="hashtag-autocomplete__fadeout">
|
||||||
{{#each options as |option|}}
|
<ul>
|
||||||
<li class="hashtag-autocomplete__option">
|
{{#each options as |option|}}
|
||||||
<a class="hashtag-autocomplete__link" title="{{option.description}}" href>{{d-icon option.icon}}<span class="hashtag-autocomplete__text">{{option.text}}</span></a>
|
<li class="hashtag-autocomplete__option">
|
||||||
</li>
|
<a class="hashtag-autocomplete__link" title="{{option.description}}" href>{{d-icon option.icon}}<span class="hashtag-autocomplete__text">{{option.text}}{{#if option.secondary_text}}<span class="hashtag-autocomplete__meta-text">({{option.secondary_text}}){{/if}}</span></span>
|
||||||
{{/each}}
|
</a>
|
||||||
</ul>
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
<ul>
|
<ul>
|
||||||
{{#each options.users as |user|}}
|
{{#each options.users as |user|}}
|
||||||
<li>
|
<li>
|
||||||
<a href title="{{user.name}}">
|
<a href title="{{user.name}}" class="{{user.cssClasses}}">
|
||||||
{{avatar user imageSize="tiny"}}
|
{{avatar user imageSize="tiny"}}
|
||||||
<span class='username'>{{format-username user.username}}</span>
|
<span class='username'>{{format-username user.username}}</span>
|
||||||
|
{{#if user.name}}
|
||||||
|
<span class='name'>{{user.name}}</span>
|
||||||
|
{{/if}}
|
||||||
{{#if user.status}}
|
{{#if user.status}}
|
||||||
{{emoji user.status.emoji}}
|
{{emoji user.status.emoji}}
|
||||||
<span class='status-description' title='{{user.status.description}}'>
|
<span class='status-description' title='{{user.status.description}}'>
|
||||||
|
|
|
@ -390,9 +390,12 @@ html.composer-open {
|
||||||
.autocomplete {
|
.autocomplete {
|
||||||
z-index: z("composer", "dropdown") + 1;
|
z-index: z("composer", "dropdown") + 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 240px;
|
max-width: 300px;
|
||||||
|
width: 300px;
|
||||||
background-color: var(--secondary);
|
background-color: var(--secondary);
|
||||||
border: 1px solid var(--primary-low);
|
border: 1px solid var(--primary-low);
|
||||||
|
box-shadow: shadow("dropdown-lite");
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -400,16 +403,21 @@ html.composer-open {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
&:not(:last-child) {
|
&:first-of-type a {
|
||||||
border-bottom: 1px solid var(--primary-low);
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
&:last-of-type a {
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--primary-high);
|
color: var(--primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 5px;
|
gap: 0.25em;
|
||||||
|
padding: 0.3em 1em;
|
||||||
|
|
||||||
@include hover {
|
@include hover {
|
||||||
background-color: var(--highlight-low);
|
background-color: var(--highlight-low);
|
||||||
|
@ -417,20 +425,28 @@ html.composer-open {
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background-color: var(--tertiary-low);
|
background-color: var(--highlight);
|
||||||
|
|
||||||
|
.username,
|
||||||
|
.name,
|
||||||
|
.emoji-shortname {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
.avatar {
|
||||||
margin-left: 5px;
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
&.username {
|
.name {
|
||||||
color: var(--primary);
|
font-size: var(--font-down-1);
|
||||||
margin-right: 5px;
|
color: var(--primary-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.name {
|
.status-description {
|
||||||
font-size: var(--font-down-1);
|
@include ellipsis;
|
||||||
}
|
font-size: var(--font-down-2);
|
||||||
|
color: var(--primary-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.relative-date {
|
.relative-date {
|
||||||
|
@ -444,6 +460,25 @@ html.composer-open {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.ac-user {
|
||||||
|
li a {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
height: 0.75em;
|
||||||
|
width: 0.75em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ac-emoji {
|
||||||
|
li:last-of-type a {
|
||||||
|
color: var(--primary-high);
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.ac-wrap.disabled {
|
div.ac-wrap.disabled {
|
||||||
|
|
|
@ -36,31 +36,54 @@ a.hashtag-cooked {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hashtag-autocomplete {
|
.hashtag-autocomplete {
|
||||||
max-height: 210px;
|
max-height: 13.5em;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
box-shadow: shadow("dropdown-lite");
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
.hashtag-autocomplete__option {
|
&__fadeout {
|
||||||
.hashtag-autocomplete__link {
|
height: inherit;
|
||||||
align-items: center;
|
max-height: inherit;
|
||||||
color: var(--primary-medium);
|
overflow-y: auto;
|
||||||
display: flex;
|
-webkit-mask: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 1) calc(100% - 1.5em),
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.d-icon {
|
&__option {
|
||||||
padding-right: 0.5em;
|
&:last-of-type {
|
||||||
}
|
margin-bottom: 0.75em; //used to the fadeout doesn't overlap the last item
|
||||||
|
|
||||||
.hashtag-autocomplete__text {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 0;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.emoji {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-icon {
|
||||||
|
color: var(--primary-medium);
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta-text {
|
||||||
|
color: var(--primary-700);
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,6 +62,9 @@ class HashtagAutocompleteService
|
||||||
# The text to display in the UI autocomplete menu for the item.
|
# The text to display in the UI autocomplete menu for the item.
|
||||||
attr_accessor :text
|
attr_accessor :text
|
||||||
|
|
||||||
|
# Some items may want to display extra text in the UI styled differently, e.g. tag topic counts.
|
||||||
|
attr_accessor :secondary_text
|
||||||
|
|
||||||
# The description text to display in the UI autocomplete menu on hover.
|
# The description text to display in the UI autocomplete menu on hover.
|
||||||
# This will be things like e.g. category description.
|
# This will be things like e.g. category description.
|
||||||
attr_accessor :description
|
attr_accessor :description
|
||||||
|
|
|
@ -12,17 +12,14 @@ class TagHashtagDataSource
|
||||||
"tag"
|
"tag"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.tag_to_hashtag_item(tag, include_count: false)
|
def self.tag_to_hashtag_item(tag)
|
||||||
tag = Tag.new(tag.slice(:id, :name, :description).merge(topic_count: tag[:count])) if tag.is_a?(
|
tag = Tag.new(tag.slice(:id, :name, :description).merge(topic_count: tag[:count])) if tag.is_a?(
|
||||||
Hash,
|
Hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
||||||
if include_count
|
item.text = tag.name
|
||||||
item.text = "#{tag.name} x #{tag.topic_count}"
|
item.secondary_text = "x#{tag.topic_count}"
|
||||||
else
|
|
||||||
item.text = tag.name
|
|
||||||
end
|
|
||||||
item.description = tag.description
|
item.description = tag.description
|
||||||
item.slug = tag.name
|
item.slug = tag.name
|
||||||
item.relative_url = tag.url
|
item.relative_url = tag.url
|
||||||
|
@ -66,7 +63,7 @@ class TagHashtagDataSource
|
||||||
TagsController
|
TagsController
|
||||||
.tag_counts_json(tags_with_counts)
|
.tag_counts_json(tags_with_counts)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map { |tag| tag_to_hashtag_item(tag, include_count: true) }
|
.map { |tag| tag_to_hashtag_item(tag) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.search_sort(search_results, _)
|
def self.search_sort(search_results, _)
|
||||||
|
@ -89,6 +86,6 @@ class TagHashtagDataSource
|
||||||
TagsController
|
TagsController
|
||||||
.tag_counts_json(tags_with_counts)
|
.tag_counts_json(tags_with_counts)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map { |tag| tag_to_hashtag_item(tag, include_count: true) }
|
.map { |tag| tag_to_hashtag_item(tag) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -395,7 +395,20 @@ export default Component.extend(TextareaTextManipulation, {
|
||||||
treatAsTextarea: true,
|
treatAsTextarea: true,
|
||||||
autoSelectFirstSuggestion: true,
|
autoSelectFirstSuggestion: true,
|
||||||
transformComplete: (v) => v.username || v.name,
|
transformComplete: (v) => v.username || v.name,
|
||||||
dataSource: (term) => userSearch({ term, includeGroups: true }),
|
dataSource: (term) => {
|
||||||
|
return userSearch({ term, includeGroups: true }).then((result) => {
|
||||||
|
if (result?.users?.length > 0) {
|
||||||
|
const presentUserNames =
|
||||||
|
this.chat.presenceChannel.users?.mapBy("username");
|
||||||
|
result.users.forEach((user) => {
|
||||||
|
if (presentUserNames.includes(user.username)) {
|
||||||
|
user.cssClasses = "mention-user-is-online";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
},
|
||||||
afterComplete: (text) => {
|
afterComplete: (text) => {
|
||||||
this.set("value", text);
|
this.set("value", text);
|
||||||
this._focusTextArea();
|
this._focusTextArea();
|
||||||
|
|
|
@ -172,6 +172,16 @@ $float-height: 530px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.is-online & {
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
box-shadow: 0px 0px 0px 1px var(--success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-user-avatar {
|
.chat-user-avatar {
|
||||||
@include unselectable;
|
@include unselectable;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -32,7 +32,7 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
|
||||||
count: 3,
|
count: 3,
|
||||||
)
|
)
|
||||||
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
|
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
|
||||||
expect(hashtag_results.map(&:text)).to eq(["Random", "Raspberry", "razed x 0"])
|
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(["Random", "Raspberry", "razed (x0)"])
|
||||||
end
|
end
|
||||||
|
|
||||||
it "searches for channels as well with # in a topic composer and deprioritises them" do
|
it "searches for channels as well with # in a topic composer and deprioritises them" do
|
||||||
|
@ -44,7 +44,7 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
|
||||||
count: 3,
|
count: 3,
|
||||||
)
|
)
|
||||||
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
|
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
|
||||||
expect(hashtag_results.map(&:text)).to eq(["Raspberry", "razed x 0", "Random"])
|
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(["Raspberry", "razed (x0)", "Random"])
|
||||||
end
|
end
|
||||||
|
|
||||||
it "cooks the hashtags for channels, categories, and tags serverside when the chat message is saved to the database" do
|
it "cooks the hashtags for channels, categories, and tags serverside when the chat message is saved to the database" do
|
||||||
|
|
|
@ -39,19 +39,19 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
describe "#search" do
|
describe "#search" do
|
||||||
it "returns search results for tags and categories by default" do
|
it "returns search results for tags and categories by default" do
|
||||||
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
||||||
["The Book Club", "great-books x 22"],
|
["The Book Club", "great-books"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "respects the types_in_priority_order param" do
|
it "respects the types_in_priority_order param" do
|
||||||
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
||||||
["great-books x 22", "The Book Club"],
|
["great-books", "The Book Club"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "respects the limit param" do
|
it "respects the limit param" do
|
||||||
expect(subject.search("book", %w[tag category], limit: 1).map(&:text)).to eq(
|
expect(subject.search("book", %w[tag category], limit: 1).map(&:text)).to eq(
|
||||||
["great-books x 22"],
|
["great-books"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -72,16 +72,13 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
it "includes the tag count" do
|
it "includes the tag count" do
|
||||||
tag1.update!(topic_count: 78)
|
tag1.update!(topic_count: 78)
|
||||||
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
||||||
["great-books x 78", "The Book Club"],
|
["great-books", "The Book Club"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does case-insensitive search" do
|
it "does case-insensitive search" do
|
||||||
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
|
||||||
["The Book Club", "great-books x 22"],
|
|
||||||
)
|
|
||||||
expect(subject.search("bOOk", %w[category tag]).map(&:text)).to eq(
|
expect(subject.search("bOOk", %w[category tag]).map(&:text)).to eq(
|
||||||
["The Book Club", "great-books x 22"],
|
["The Book Club", "great-books"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -92,7 +89,7 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
|
|
||||||
it "does not include categories the user cannot access" do
|
it "does not include categories the user cannot access" do
|
||||||
category1.update!(read_restricted: true)
|
category1.update!(read_restricted: true)
|
||||||
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(["great-books x 22"])
|
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(["great-books"])
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not include tags the user cannot access" do
|
it "does not include tags the user cannot access" do
|
||||||
|
@ -111,7 +108,7 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(subject.search("book", %w[category tag bookmark]).map(&:text)).to eq(
|
expect(subject.search("book", %w[category tag bookmark]).map(&:text)).to eq(
|
||||||
["The Book Club", "great-books x 22", "read review of this fantasy book"],
|
["The Book Club", "great-books", "read review of this fantasy book"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -177,12 +174,12 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
"Horror",
|
"Horror",
|
||||||
"Book Library",
|
"Book Library",
|
||||||
"Book Reviews",
|
"Book Reviews",
|
||||||
"bookmania x 15",
|
"bookmania",
|
||||||
"Abstract Philosophy",
|
"Abstract Philosophy",
|
||||||
"Romance",
|
"Romance",
|
||||||
"The Book Club",
|
"The Book Club",
|
||||||
"awful-books x 56",
|
"awful-books",
|
||||||
"great-books x 22",
|
"great-books",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -246,9 +243,9 @@ RSpec.describe HashtagAutocompleteService do
|
||||||
"Media",
|
"Media",
|
||||||
"Bookworld",
|
"Bookworld",
|
||||||
Category.find(SiteSetting.uncategorized_category_id).name,
|
Category.find(SiteSetting.uncategorized_category_id).name,
|
||||||
"mid-books x 33",
|
"mid-books",
|
||||||
"great-books x 22",
|
"great-books",
|
||||||
"book x 1",
|
"book",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,9 +31,9 @@ RSpec.describe TagHashtagDataSource do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "includes the topic count for the text of the tag" do
|
it "includes the topic count for the text of the tag in secondary text" do
|
||||||
expect(described_class.search(guardian, "fact", 5).map(&:text)).to eq(
|
expect(described_class.search(guardian, "fact", 5).map(&:secondary_text)).to eq(
|
||||||
["fact x 0", "factor x 5", "factory x 4", "factorio x 3", "factz x 1"],
|
["x0", "x5", "x4", "x3", "x1"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -35,19 +35,21 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
|
||||||
it "searches for categories and tags with # and prioritises categories in the results" do
|
it "searches for categories and tags with # and prioritises categories in the results" do
|
||||||
visit_topic_and_initiate_autocomplete
|
visit_topic_and_initiate_autocomplete
|
||||||
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
|
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
|
||||||
expect(hashtag_results.map(&:text)).to eq(["Cool Category", "cooltag x 325"])
|
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
|
||||||
|
["Cool Category", "cooltag (x325)"],
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "begins showing results as soon as # is pressed based on categories and tags topic_count" do
|
it "begins showing results as soon as # is pressed based on categories and tags topic_count" do
|
||||||
visit_topic_and_initiate_autocomplete(initiation_text: "#", expected_count: 5)
|
visit_topic_and_initiate_autocomplete(initiation_text: "#", expected_count: 5)
|
||||||
hashtag_results = page.all(".hashtag-autocomplete__link")
|
hashtag_results = page.all(".hashtag-autocomplete__link")
|
||||||
expect(hashtag_results.map(&:text)).to eq(
|
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
|
||||||
[
|
[
|
||||||
"Cool Category",
|
"Cool Category",
|
||||||
"Other Category",
|
"Other Category",
|
||||||
uncategorized_category.name,
|
uncategorized_category.name,
|
||||||
"cooltag x 325",
|
"cooltag (x325)",
|
||||||
"othertag x 66",
|
"othertag (x66)",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue