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:
chapoi 2022-12-19 12:31:45 +01:00 committed by GitHub
parent c5957490df
commit 8db1f1892d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 166 additions and 80 deletions

View File

@ -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>

View File

@ -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}}'>

View File

@ -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 {

View File

@ -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);
}
} }

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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