UX: Compact option for multi-selects (#22239)

Adds an alternative to the default multi select item, better suited for quickly adding/removing tags.
This commit is contained in:
Penar Musaraj 2023-07-25 11:00:02 -04:00 committed by GitHub
parent 4e5756e3ae
commit f5e8e737ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 204 additions and 26 deletions

View File

@ -72,6 +72,8 @@
filterable=true
categoryId=this.buffered.category_id
minimum=this.minimumRequiredTags
filterPlaceholder="topic_edit.tag_filter_placeholder"
useHeaderFilter=true
}}
/>
{{/if}}

View File

@ -1,6 +1,6 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { click, render, triggerKeyEvent } from "@ember/test-helpers";
import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
import { hbs } from "ember-cli-htmlbars";
@ -148,3 +148,64 @@ module(
});
}
);
module(
"Integration | Component | select-kit/mini-tag-chooser useHeaderFilter=true",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set("subject", selectKit());
});
test("displays tags and filter in header", async function (assert) {
this.set("value", ["apple", "orange", "potato"]);
await render(
hbs`<MiniTagChooser @value={{this.value}} @options={{hash filterable=true useHeaderFilter=true}} />`
);
assert.strictEqual(this.subject.header().value(), "apple,orange,potato");
assert.dom(".select-kit-header--filter").exists();
assert.dom(".select-kit-header button[data-name='apple']").exists();
assert.dom(".select-kit-header button[data-name='orange']").exists();
assert.dom(".select-kit-header button[data-name='potato']").exists();
const filterInput = ".select-kit-header .filter-input";
await click(filterInput);
await triggerKeyEvent(filterInput, "keydown", "ArrowDown");
await triggerKeyEvent(filterInput, "keydown", "Enter");
assert.dom(".select-kit-header button[data-name='monkey']").exists();
await triggerKeyEvent(filterInput, "keydown", "Backspace");
assert
.dom(".select-kit-header button[data-name='monkey']")
.doesNotExist();
await this.subject.fillInFilter("foo");
await triggerKeyEvent(filterInput, "keydown", "Backspace");
assert.dom(".select-kit-header button[data-name='potato']").exists();
});
test("removing a tag does not display the dropdown", async function (assert) {
this.set("value", ["apple", "orange", "potato"]);
await render(
hbs`<MiniTagChooser @value={{this.value}} @options={{hash filterable=true useHeaderFilter=true}} />`
);
assert.strictEqual(this.subject.header().value(), "apple,orange,potato");
await click(".select-kit-header button[data-name='apple']");
assert.dom(".select-kit-collection").doesNotExist();
assert.dom(".select-kit-header button[data-name='apple']").doesNotExist();
assert.strictEqual(this.subject.header().value(), "orange,potato");
});
}
);

View File

@ -26,6 +26,7 @@ export default MultiSelectComponent.extend(TagsMixin, {
closeOnChange: false,
maximum: "maxTagsPerTopic",
autoInsertNoneItem: false,
useHeaderFilter: false,
},
modifyComponentForRow(collection, item) {

View File

@ -12,23 +12,25 @@
@selectKit={{this.selectKit}}
@id={{concat this.selectKit.uniqueID "-body"}}
>
{{component
this.selectKit.options.filterComponent
selectKit=this.selectKit
id=(concat this.selectKit.uniqueID "-filter")
}}
{{#unless this.selectKit.options.useHeaderFilter}}
{{component
this.selectKit.options.filterComponent
selectKit=this.selectKit
id=(concat this.selectKit.uniqueID "-filter")
}}
{{#if this.selectedContent.length}}
<div class="selected-content">
{{#each this.selectedContent as |item|}}
{{component
this.selectKit.options.selectedChoiceComponent
item=item
selectKit=this.selectKit
}}
{{/each}}
</div>
{{/if}}
{{#if this.selectedContent.length}}
<div class="selected-content">
{{#each this.selectedContent as |item|}}
{{component
this.selectKit.options.selectedChoiceComponent
item=item
selectKit=this.selectKit
}}
{{/each}}
</div>
{{/if}}
{{/unless}}
{{#each this.collections as |collection|}}
{{component

View File

@ -21,6 +21,7 @@ export default SelectKitComponent.extend({
autoFilterable: true,
caretDownIcon: "caretIcon",
caretUpIcon: "caretIcon",
useHeaderFilter: false,
},
caretIcon: computed("value.[]", function () {

View File

@ -3,10 +3,30 @@
{{d-icon icon}}
{{/each}}
<MultiSelect::FormatSelectedContent
@content={{or this.selectedContent this.selectKit.noneItem}}
@selectKit={{this.selectKit}}
/>
{{#if this.selectKit.options.useHeaderFilter}}
<div class="select-kit-header--filter">
{{#if this.selectedContent.length}}
{{#each this.selectedContent as |item|}}
{{component
this.selectKit.options.selectedChoiceComponent
item=item
selectKit=this.selectKit
}}
{{/each}}
{{/if}}
{{d-icon this.caretIcon class="caret-icon"}}
{{component
this.selectKit.options.filterComponent
selectKit=this.selectKit
id=(concat this.selectKit.uniqueID "-filter")
}}
</div>
{{else}}
<MultiSelect::FormatSelectedContent
@content={{or this.selectedContent this.selectKit.noneItem}}
@selectKit={{this.selectKit}}
/>
{{d-icon this.caretIcon class="caret-icon"}}
{{/if}}
</div>

View File

@ -114,6 +114,7 @@ export default Component.extend(
highlightPrevious: bind(this, this._highlightPrevious),
highlightLast: bind(this, this._highlightLast),
highlightFirst: bind(this, this._highlightFirst),
deselectLast: bind(this, this._deselectLast),
change: bind(this, this._onChangeWrapper),
select: bind(this, this.select),
deselect: bind(this, this.deselect),
@ -295,6 +296,7 @@ export default Component.extend(
minimum: null,
autoInsertNoneItem: true,
closeOnChange: true,
useHeaderFilter: false,
limitMatches: null,
placement: isDocumentRTL() ? "bottom-end" : "bottom-start",
verticalOffset: 3,
@ -801,6 +803,12 @@ export default Component.extend(
}
},
_deselectLast() {
if (this.selectKit.hasSelection) {
this.deselectByValue(this.value[this.value.length - 1]);
}
},
select(value, item) {
if (!isPresent(value)) {
this._onClearSelection();

View File

@ -79,6 +79,12 @@ export default Component.extend(UtilsMixin, {
return true;
}
if (event.key === "Backspace" && !this.selectKit.filter) {
this.selectKit.deselectLast();
event.preventDefault();
return false;
}
if (event.key === "ArrowUp") {
this.selectKit.highlightLast();
event.preventDefault();
@ -86,6 +92,9 @@ export default Component.extend(UtilsMixin, {
}
if (event.key === "ArrowDown") {
if (!this.selectKit.isExpanded) {
this.selectKit.open(event);
}
this.selectKit.highlightFirst();
event.preventDefault();
return false;

View File

@ -64,6 +64,12 @@ export default Component.extend(UtilsMixin, {
event.preventDefault();
event.stopPropagation();
if (
event.target?.classList.contains("selected-choice") ||
event.target.parentNode?.classList.contains("selected-choice")
) {
return false;
}
this.selectKit.toggle(event);
},
@ -74,7 +80,11 @@ export default Component.extend(UtilsMixin, {
},
keyDown(event) {
if (this.selectKit.isDisabled || this.selectKit.options.disabled) {
if (
this.selectKit.isDisabled ||
this.selectKit.options.disabled ||
this.selectKit.options.useHeaderFilter
) {
return;
}

View File

@ -301,14 +301,16 @@ a.badge-category {
}
.category-chooser,
.mini-tag-chooser {
flex: 1 1 49%;
flex: 1 1 35%;
margin: 0 0 9px 0;
@media all and (max-width: 500px) {
flex: 1 1 100%;
}
}
.mini-tag-chooser {
margin-left: 2%; // category/tag chooser are 49% wide, so this is 1% * 2
flex: 1 1 54%;
margin: 0 0 9px 0;
margin-left: 1%; // category at 40%, tag chooser at 58%
@media all and (max-width: 500px) {
margin-left: 0;
}

View File

@ -55,6 +55,42 @@
@include ellipsis;
display: inline-block;
}
.select-kit-header--filter {
display: flex;
flex-wrap: wrap;
margin: -0.25em;
margin-bottom: -0.45em;
position: relative;
.selected-choice {
margin: 0 0.25em 0.25em 0;
padding: 0.2em 0.3em;
font-size: var(--font-down-1);
&.selected-choice-color {
border-bottom: 2px solid transparent;
}
}
.select-kit-filter {
display: inline-flex;
flex: 1 1 30px;
width: auto;
margin-left: 0.25em;
position: static;
&.is-expanded {
padding: 0;
}
.filter-input {
font-size: var(--font-down-1);
min-height: 28px;
flex: 1;
display: block;
border: none;
min-width: 0;
}
}
}
}
&.is-expanded .multi-select-header,

View File

@ -2044,7 +2044,7 @@ en:
summarized_on: "Summarized with AI on %{date}"
model_used: "AI used: %{model}"
outdated: "Summary is outdated"
outdated_posts:
outdated_posts:
one: "(%{count} post missing)"
other: "(%{count} posts missing)"
enabled_description: "You're viewing this topic top replies: the most interesting posts as determined by the community."
@ -3900,6 +3900,8 @@ en:
personal_message:
title: "This topic is a personal message"
help: "This topic is a personal message"
topic_edit:
tag_filter_placeholder: "+ tag"
posts: "Posts"
pending_posts:
label: "Pending"

View File

@ -123,6 +123,28 @@
<MultiSelect @content={{@dummy.options}} @onChange={{@dummyAction}} />
</StyleguideExample>
<StyleguideExample @title="<MiniTagChooser>">
<div class="inline-form">
<MiniTagChooser
@value={{@dummy.selectedTags}}
@options={{hash filterable=true}}
/>
</div>
</StyleguideExample>
<StyleguideExample @title="<MiniTagChooser> with useHeaderFilter=true">
<div class="inline-form">
<MiniTagChooser
@value={{@dummy.selectedTags}}
@options={{hash
filterable=true
filterPlaceholder="topic_edit.tag_filter_placeholder"
useHeaderFilter=true
}}
/>
</div>
</StyleguideExample>
<StyleguideExample @title="admin <GroupChooser>">
<GroupChooser
@selected={{@dummy.selectedGroups}}

View File

@ -282,6 +282,8 @@ export function createData(store) {
colors: "f49|c89|564897",
charCounterContent: "",
selectedTags: ["apple", "orange", "potato"],
};
return _data;