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:
parent
4e5756e3ae
commit
f5e8e737ad
|
@ -72,6 +72,8 @@
|
|||
filterable=true
|
||||
categoryId=this.buffered.category_id
|
||||
minimum=this.minimumRequiredTags
|
||||
filterPlaceholder="topic_edit.tag_filter_placeholder"
|
||||
useHeaderFilter=true
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -26,6 +26,7 @@ export default MultiSelectComponent.extend(TagsMixin, {
|
|||
closeOnChange: false,
|
||||
maximum: "maxTagsPerTopic",
|
||||
autoInsertNoneItem: false,
|
||||
useHeaderFilter: false,
|
||||
},
|
||||
|
||||
modifyComponentForRow(collection, item) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,6 +21,7 @@ export default SelectKitComponent.extend({
|
|||
autoFilterable: true,
|
||||
caretDownIcon: "caretIcon",
|
||||
caretUpIcon: "caretIcon",
|
||||
useHeaderFilter: false,
|
||||
},
|
||||
|
||||
caretIcon: computed("value.[]", function () {
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -282,6 +282,8 @@ export function createData(store) {
|
|||
colors: "f49|c89|564897",
|
||||
|
||||
charCounterContent: "",
|
||||
|
||||
selectedTags: ["apple", "orange", "potato"],
|
||||
};
|
||||
|
||||
return _data;
|
||||
|
|
Loading…
Reference in New Issue