DEV: DMultiSelect (#32240)

The `DMultiSelect` component provides a customizable multi-select
dropdown with support for both mouse and keyboard interactions.

![Screenshot 2025-04-10 at 15 40
26](https://github.com/user-attachments/assets/277619db-6e56-4beb-8eda-f76360cd2ad8)

### Parameters

#### `@loadFn` (required)
An async function that returns the data to populate the dropdown
options.

```javascript
const loadFn = async () => {
  return [
    { id: 1, name: "Option 1" },
    { id: 2, name: "Option 2" },
  ];
};
```

#### `@compareFn`

A function used to determine equality between items. This is
particularly useful when working with complex objects. By default, `id`
will be used.

```javascript
const compareFn = (a, b) => {
  return a.name === b.name;
};
```

#### `@selection`
An array of pre-selected items that will be displayed as selected when
the component renders.

```javascript
const selection = [
  { id: 1, name: "Option 1" },
  { id: 2, name: "Option 2" },
];
```

#### `@label`
Text label displayed in the trigger element when no items are selected.

```javascript
@label="Select options"
```

### Named Blocks

#### :selection
Block for customizing how selected items appear in the trigger.

```javascript
<:selection as |result|>{{result.name}}</:selection>
```

#### :result
Block for customizing how items appear in the dropdown list.

```javascript
<:result as |result|>{{result.name}}</:result>
```

#### :result
Block for customizing how errors appear in the component.

```javascript
<:error as |error|>{{error}}</:error>
```

### Example Usage

```javascript
<DMultiSelect
  @loadFn={{this.loadOptions}}
  @selection={{this.selectedItems}}
  @compareFn={{this.compareItems}}
  @label="Select options">
  <:selection as |result|>{{result.name}}</:selection>
  <:result as |result|>{{result.name}}</:result>
  <:error as |error|>{{error}}</:error>
</DMultiSelect>
```

Co-Authored-By: Jordan Vidrine
<30537603+jordanvidrine@users.noreply.github.com>

---------

Co-authored-by: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com>
This commit is contained in:
Joffrey JAFFEUX 2025-04-15 14:56:57 +02:00 committed by GitHub
parent 4510d1ad46
commit 59ec86933a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 703 additions and 0 deletions

View File

@ -0,0 +1,279 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { htmlSafe } from "@ember/template";
import { TrackedAsyncData } from "ember-async-data";
import { Promise as RsvpPromise } from "rsvp";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import TextField from "discourse/components/text-field";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import element from "discourse/helpers/element";
import discourseDebounce from "discourse/lib/debounce";
import { INPUT_DELAY } from "discourse/lib/environment";
import { makeArray } from "discourse/lib/helpers";
import { i18n } from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
class Skeleton extends Component {
get width() {
return htmlSafe(`width: ${Math.floor(Math.random() * 70) + 20}%`);
}
<template>
<div class="d-multi-select__skeleton">
<div class="d-multi-select__skeleton-checkbox" />
<div class="d-multi-select__skeleton-text" style={{this.width}} />
</div>
</template>
}
export default class DMultiSelect extends Component {
@tracked searchTerm = "";
@tracked preselectedItem = null;
compareKey = "id";
get hasSelection() {
return this.args.selection?.length > 0;
}
get label() {
return this.args.label ?? i18n("multi_select.label");
}
@cached
get data() {
if (this.isDestroying || this.isDestroyed) {
return;
}
const value = new Promise((resolve, reject) => {
discourseDebounce(
this,
this.#resolveAsyncData,
this.args.loadFn,
this.searchTerm,
resolve,
reject,
INPUT_DELAY
);
});
return new TrackedAsyncData(value);
}
@action
search(event) {
this.preselectedItem = null;
this.searchTerm = event.target.value;
}
@action
focus(input) {
input.focus();
}
@action
handleKeydown(event) {
if (!this.data.isResolved) {
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (this.preselectedItem) {
this.toggle(this.preselectedItem, event);
}
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (!this.data.value?.length) {
return;
}
if (this.preselectedItem === null) {
this.preselectedItem = this.data.value[0];
} else {
const currentIndex = this.data.value.findIndex((item) =>
this.compare(item, this.preselectedItem)
);
if (currentIndex < this.data.value.length - 1) {
this.preselectedItem = this.data.value[currentIndex + 1];
}
}
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (!this.data.value?.length) {
return;
}
if (this.preselectedItem === null) {
this.preselectedItem = this.data.value[0];
} else {
const currentIndex = this.data.value.findIndex((item) =>
this.compare(item, this.preselectedItem)
);
if (currentIndex > 0) {
this.preselectedItem = this.data.value[currentIndex - 1];
}
}
}
}
@action
remove(selectedItem, event) {
event?.stopPropagation();
this.args.onChange?.(
this.args.selection?.filter((item) => !this.compare(item, selectedItem))
);
}
@action
isSelected(result) {
return this.args.selection?.filter((item) => this.compare(item, result))
.length;
}
@action
toggle(result, event) {
event?.stopPropagation();
if (this.isSelected(result)) {
this.remove(result, event);
} else {
this.args.onChange?.(makeArray(this.args.selection).concat(result));
}
}
@action
compare(a, b) {
if (this.args.compareFn) {
return this.args.compareFn(a, b);
} else {
return a[this.compareKey] === b[this.compareKey];
}
}
#resolveAsyncData(asyncData, context, resolve, reject) {
return asyncData(context).then(resolve).catch(reject);
}
<template>
<DMenu
@identifier="d-multi-select"
@triggerComponent={{element "div"}}
@triggerClass={{concatClass (if this.hasSelection "--has-selection")}}
...attributes
>
<:trigger>
{{#if @selection}}
<div class="d-multi-select-trigger__selection">
{{#each @selection as |item|}}
<button
class="d-multi-select-trigger__selected-item"
{{on "click" (fn this.remove item)}}
>
<span class="d-multi-select-trigger__selection-label">{{yield
item
to="selection"
}}</span>
{{icon
"xmark"
class="d-multi-select-trigger__remove-selection-icon"
}}
</button>
{{/each}}
</div>
{{else}}
<span class="d-multi-select-trigger__label">{{this.label}}</span>
{{/if}}
<DButton
@icon="angle-down"
class="d-multi-select-trigger__expand-btn btn-transparent"
@action={{@componentArgs.show}}
/>
</:trigger>
<:content>
<DropdownMenu class="d-multi-select__content" as |menu|>
<menu.item class="d-multi-select__search-container">
{{icon "magnifying-glass"}}
<TextField
class="d-multi-select__search-input"
autocomplete="off"
@placeholder={{i18n "multi_select.search"}}
@type="search"
{{on "input" this.search}}
{{on "keydown" this.handleKeydown}}
{{didInsert this.focus}}
@value={{readonly this.searchTerm}}
/>
</menu.item>
<menu.divider />
{{#if this.data.isPending}}
<div class="d-multi-select__skeletons">
<Skeleton />
<Skeleton />
<Skeleton />
<Skeleton />
<Skeleton />
</div>
{{else if this.data.isRejected}}
<div class="d-multi-select__error">
{{yield this.data.error to="error"}}
</div>
{{else if this.data.isResolved}}
{{#if this.data.value}}
<div class="d-multi-select__search-results">
{{#each this.data.value as |result|}}
<menu.item
class={{concatClass
"d-multi-select__result"
(if (eq result this.preselectedItem) "--preselected" "")
}}
role="button"
{{on "mouseenter" (fn (mut this.preselectedItem) result)}}
{{on "click" (fn this.toggle result)}}
>
<Input
@type="checkbox"
@checked={{this.isSelected result}}
class="d-multi-select__result-checkbox"
/>
<span class="d-multi-select__result-label">
{{yield result to="result"}}
</span>
</menu.item>
{{/each}}
</div>
{{else}}
<div class="d-multi-select__search-no-results">
{{i18n "multi_select.no_results"}}
</div>
{{/if}}
{{/if}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View File

@ -0,0 +1,222 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import {
click,
fillIn,
render,
triggerEvent,
triggerKeyEvent,
} from "@ember/test-helpers";
import { module, test } from "qunit";
import DMultiSelect from "discourse/components/d-multi-select";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
class TestComponent extends Component {
@tracked selection = this.args.selection ?? [];
@action
onChange(newSelection) {
this.selection = newSelection;
}
@action
async loadFn(filter) {
return [
{ id: 1, name: "foo" },
{ id: 2, name: "bar" },
].filter((item) => {
return item.name.toLowerCase().includes(filter.toLowerCase());
});
}
<template>
<DMultiSelect
@loadFn={{if @loadFn @loadFn this.loadFn}}
@compareFn={{@compareFn}}
@onChange={{this.onChange}}
@selection={{this.selection}}
@label={{@label}}
>
<:selection as |result|>{{result.name}}</:selection>
<:result as |result|>{{result.name}}</:result>
<:error as |error|>{{error}}</:error>
</DMultiSelect>
</template>
}
module("Integration | Component | d-multi-select", function (hooks) {
setupRenderingTest(hooks);
test("filter", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await fillIn(".d-multi-select__search-input", "bar");
assert.dom(".d-multi-select__result:nth-child(1)").hasText("bar");
assert.dom(".d-multi-select__result:nth-child(2)").doesNotExist();
});
test("@selection", async function (assert) {
const selection = [{ id: 1, name: "foo" }];
await render(
<template><TestComponent @selection={{selection}} /></template>
);
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(2)")
.doesNotExist();
});
test("@onChange", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await click(".d-multi-select__result:nth-child(1)");
await click(".d-multi-select__result:nth-child(2)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(2)")
.hasText("bar");
});
test("keyboard", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
assert
.dom(".d-multi-select__result:nth-child(1)")
.hasClass("--preselected");
await triggerKeyEvent(document.activeElement, "keydown", "ArrowUp");
assert
.dom(".d-multi-select__result:nth-child(1)")
.hasClass("--preselected");
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
assert
.dom(".d-multi-select__result:nth-child(2)")
.hasClass("--preselected");
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
assert
.dom(".d-multi-select__result:nth-child(2)")
.hasClass("--preselected");
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("bar");
});
test("@compareFn", async function (assert) {
const compareFn = (a, b) => {
return a.name === b.name;
};
const loadFn = async () => {
return [{ name: "foo" }, { name: "bar" }];
};
await render(
<template>
<TestComponent @compareFn={{compareFn}} @loadFn={{loadFn}} />
</template>
);
await click(".d-multi-select-trigger");
await click(".d-multi-select__result:nth-child(1)");
await click(".d-multi-select__result:nth-child(2)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(2)")
.hasText("bar");
});
test("@label", async function (assert) {
await render(<template><TestComponent @label="label" /></template>);
assert.dom(".d-multi-select-trigger__label").hasText("label");
});
test("@loadFn", async function (assert) {
const loadFn = async () => {
return [
{ id: 1, name: "cat" },
{ id: 2, name: "dog" },
];
};
await render(<template><TestComponent @loadFn={{loadFn}} /></template>);
await click(".d-multi-select-trigger");
assert.dom(".d-multi-select__result:nth-child(1)").hasText("cat");
assert.dom(".d-multi-select__result:nth-child(2)").hasText("dog");
});
test("select item", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await click(".d-multi-select__result:nth-child(1)");
await click(".d-multi-select__result:nth-child(2)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(2)")
.hasText("bar");
});
test("unselect item", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await click(".d-multi-select__result:nth-child(1)");
await click(".d-multi-select__result:nth-child(2)");
await click(".d-multi-select-trigger__selected-item:nth-child(1)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("bar");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(2)")
.doesNotExist();
});
test("preselect item", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
await triggerEvent(".d-multi-select__result:nth-child(1)", "mouseenter");
assert
.dom(".d-multi-select__result:nth-child(1)")
.hasClass("--preselected");
});
test(":error", async function (assert) {
const loadFn = async () => {
throw new Error("error");
};
await render(<template><TestComponent @loadFn={{loadFn}} /></template>);
await click(".d-multi-select-trigger");
assert.dom(".d-multi-select__error").hasText("Error: error");
});
});

View File

@ -75,6 +75,7 @@ export default class DMenu extends Component {
get componentArgs() {
return {
close: this.menuInstance.close,
show: this.menuInstance.show,
data: this.options.data,
};
}
@ -131,6 +132,7 @@ export default class DMenu extends Component {
data-trigger
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
@componentArgs={{this.componentArgs}}
...attributes
>
{{#if (has-block "trigger")}}

View File

@ -63,3 +63,4 @@
@import "filter-input";
@import "dropdown-menu";
@import "welcome-banner";
@import "d-multi-select";

View File

@ -0,0 +1,151 @@
.d-multi-select-trigger {
@include form-item-sizing;
display: inline-flex;
border-radius: var(--d-border-radius);
cursor: pointer;
align-items: center;
gap: 0.25em;
line-height: normal;
box-sizing: border-box;
width: 200px;
border: 1px solid var(--primary-medium);
justify-content: space-between;
&:hover {
&:not(.--has-selection) {
background-color: var(--primary-medium);
color: var(--secondary);
.btn-transparent .d-icon-angle-down {
color: var(--secondary);
}
}
}
}
.d-multi-select-trigger__selection {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
height: 100%;
}
.d-multi-select-trigger__label {
font-size: var(--font-0);
line-height: normal;
}
.d-multi-select__search-no-result,
.d-multi-select__error {
padding: 0.25em 0.5em;
}
.d-multi-select__search-input {
border: 0 !important;
margin: 0 !important;
outline: none !important;
padding: 0 !important;
}
.d-multi-select__search-container {
display: flex;
align-items: center;
padding: 0.5em;
gap: 0.5em;
.d-icon-magnifying-glass {
color: var(--primary-high);
font-size: var(--font-down-1);
}
}
.d-multi-select__search-results {
display: flex;
flex-direction: column;
padding: 0.25em;
gap: 0.25em;
height: 150px;
overflow-y: auto;
}
.d-multi-select__search-no-results {
padding: 0.25em 0.5em;
}
.d-multi-select-trigger__selected-item {
box-sizing: border-box;
border: 0;
font-size: var(--font-down-1);
display: flex;
align-items: center;
border-radius: var(--d-border-radius);
background-color: var(--primary-low);
&:hover {
background-color: var(--primary-low-mid);
}
.d-multi-select-trigger__selection-label {
@include ellipsis;
max-width: 3.5em;
}
}
.d-multi-select-trigger__expand-btn {
padding: 0;
}
.d-multi-select__result {
padding: 0.25em;
border-radius: var(--d-border-radius);
cursor: pointer;
gap: 0.5em;
display: flex;
align-items: center;
&.--preselected {
background-color: var(--primary-low);
}
}
.d-multi-select__result-checkbox {
margin: 0 !important;
}
.d-multi-select__skeletons {
display: flex;
flex-direction: column;
gap: 0.5em;
padding: 0.25em;
height: 150px;
overflow-y: hidden;
}
.d-multi-select__skeleton {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.25em;
}
.d-multi-select__skeleton-checkbox {
@keyframes pulse {
50% {
opacity: 0.5;
}
}
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
width: 14px;
height: 14px;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
box-sizing: border-box;
}
.d-multi-select__skeleton-text {
background: var(--primary-low);
width: 100%;
height: 24px;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-radius: var(--d-border-radius);
}

View File

@ -2248,6 +2248,10 @@ en:
modal:
close: "close"
dismiss_error: "Dismiss error"
multi_select:
no_results: "No results"
search: "Search…"
label: "Select options"
form_kit:
reset: Reset
optional: optional

View File

@ -0,0 +1,14 @@
<StyleguideExample @title="<DMultiSelect />">
<Styleguide::Component @tag="d-multi-select component">
<:sample>
<DMultiSelect
@loadFn={{this.loadDummyData}}
@onChange={{this.onChange}}
@selection={{this.selection}}
>
<:result as |result|>{{result.name}}</:result>
<:selection as |result|>{{result.name}}</:selection>
</DMultiSelect>
</:sample>
</Styleguide::Component>
</StyleguideExample>

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
export default class MultiSelect extends Component {
@tracked selection = [{ id: 1, name: "foo" }];
@action
onChange(selection) {
this.selection = selection;
}
@action
async loadDummyData(filter) {
await new Promise((resolve) => setTimeout(resolve, 500));
return [
{ id: 1, name: "foo" },
{ id: 2, name: "bar" },
{ id: 3, name: "baz" },
].filter((item) => {
return item.name.toLowerCase().includes(filter.toLowerCase());
});
}
}

View File

@ -15,6 +15,7 @@ import charCounter from "../components/sections/molecules/char-counter";
import emptyState from "../components/sections/molecules/empty-state";
import footerMessage from "../components/sections/molecules/footer-message";
import menus from "../components/sections/molecules/menus";
import multiselect from "../components/sections/molecules/multi-select";
import navigationBar from "../components/sections/molecules/navigation-bar";
import navigationStacked from "../components/sections/molecules/navigation-stacked";
import postMenu from "../components/sections/molecules/post-menu";
@ -75,6 +76,7 @@ const SECTIONS = [
{ component: postMenu, category: "molecules", id: "post-menu" },
{ component: tooltips, category: "molecules", id: "tooltips" },
{ component: menus, category: "molecules", id: "menus" },
{ component: multiselect, category: "molecules", id: "multi-select" },
{ component: toasts, category: "molecules", id: "toasts" },
{ component: signupCta, category: "molecules", id: "signup-cta" },
{ component: topicListItem, category: "molecules", id: "topic-list-item" },

View File

@ -26,6 +26,8 @@ en:
title: "Date/Time inputs"
menus:
title: "Menus"
multi_select:
title: "Multi select"
toasts:
title: "Toasts"
font_scale:

View File

@ -32,6 +32,7 @@ RSpec.describe "Styleguide Smoke Test", type: :system do
{ href: "/molecules/navigation-stacked", title: "Navigation Stacked" },
{ href: "/molecules/post-menu", title: "Post Menu" },
{ href: "/molecules/signup-cta", title: "Signup CTA" },
{ href: "/molecules/multi-select", title: "Multi select" },
{ href: "/molecules/toasts", title: "Toasts" },
{ href: "/molecules/tooltips", title: "Tooltips" },
{ href: "/molecules/topic-list-item", title: "Topic List Item" },