From 59ec86933a2a9db8eb888e6a64310883d1ea4286 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 15 Apr 2025 14:56:57 +0200 Subject: [PATCH] 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}} ``` #### :result Block for customizing how items appear in the dropdown list. ```javascript <:result as |result|>{{result.name}} ``` #### :result Block for customizing how errors appear in the component. ```javascript <:error as |error|>{{error}} ``` ### Example Usage ```javascript <:selection as |result|>{{result.name}} <:result as |result|>{{result.name}} <:error as |error|>{{error}} ``` Co-Authored-By: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> --------- Co-authored-by: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> --- .../app/components/d-multi-select.gjs | 279 ++++++++++++++++++ .../components/d-multi-select-test.gjs | 222 ++++++++++++++ .../float-kit/addon/components/d-menu.gjs | 2 + .../stylesheets/common/components/_index.scss | 1 + .../common/components/d-multi-select.scss | 151 ++++++++++ config/locales/client.en.yml | 4 + .../sections/molecules/multi-select.hbs | 14 + .../sections/molecules/multi-select.js | 25 ++ .../javascripts/discourse/lib/styleguide.js | 2 + .../styleguide/config/locales/client.en.yml | 2 + .../styleguide/spec/system/smoke_test_spec.rb | 1 + 11 files changed, 703 insertions(+) create mode 100644 app/assets/javascripts/discourse/app/components/d-multi-select.gjs create mode 100644 app/assets/javascripts/discourse/tests/integration/components/d-multi-select-test.gjs create mode 100644 app/assets/stylesheets/common/components/d-multi-select.scss create mode 100644 plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.hbs create mode 100644 plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.js diff --git a/app/assets/javascripts/discourse/app/components/d-multi-select.gjs b/app/assets/javascripts/discourse/app/components/d-multi-select.gjs new file mode 100644 index 00000000000..97678aae4d2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-multi-select.gjs @@ -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}%`); + } + + +} + +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); + } + + +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-multi-select-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/d-multi-select-test.gjs new file mode 100644 index 00000000000..b9ce9018728 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/d-multi-select-test.gjs @@ -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()); + }); + } + + +} + +module("Integration | Component | d-multi-select", function (hooks) { + setupRenderingTest(hooks); + + test("filter", async function (assert) { + await render(); + + 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( + + ); + + 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(); + 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(); + 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( + + ); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + await click(".d-multi-select-trigger"); + + assert.dom(".d-multi-select__error").hasText("Error: error"); + }); +}); diff --git a/app/assets/javascripts/float-kit/addon/components/d-menu.gjs b/app/assets/javascripts/float-kit/addon/components/d-menu.gjs index 037d48aea50..ba5560c0608 100644 --- a/app/assets/javascripts/float-kit/addon/components/d-menu.gjs +++ b/app/assets/javascripts/float-kit/addon/components/d-menu.gjs @@ -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")}} diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 74f38bb5da0..33980625cfc 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -63,3 +63,4 @@ @import "filter-input"; @import "dropdown-menu"; @import "welcome-banner"; +@import "d-multi-select"; diff --git a/app/assets/stylesheets/common/components/d-multi-select.scss b/app/assets/stylesheets/common/components/d-multi-select.scss new file mode 100644 index 00000000000..56bad65a19b --- /dev/null +++ b/app/assets/stylesheets/common/components/d-multi-select.scss @@ -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); +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bdc8780e184..87b7d4491c4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.hbs new file mode 100644 index 00000000000..f7225133af7 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.hbs @@ -0,0 +1,14 @@ + + + <:sample> + + <:result as |result|>{{result.name}} + <:selection as |result|>{{result.name}} + + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.js b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.js new file mode 100644 index 00000000000..d57e5926cf7 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/multi-select.js @@ -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()); + }); + } +} diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js index 1f337cdafd2..95bfc457baf 100644 --- a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js +++ b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js @@ -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" }, diff --git a/plugins/styleguide/config/locales/client.en.yml b/plugins/styleguide/config/locales/client.en.yml index b394bf02294..09fd4ef0667 100644 --- a/plugins/styleguide/config/locales/client.en.yml +++ b/plugins/styleguide/config/locales/client.en.yml @@ -26,6 +26,8 @@ en: title: "Date/Time inputs" menus: title: "Menus" + multi_select: + title: "Multi select" toasts: title: "Toasts" font_scale: diff --git a/plugins/styleguide/spec/system/smoke_test_spec.rb b/plugins/styleguide/spec/system/smoke_test_spec.rb index 00bd9a4f628..fb0f9b56afc 100644 --- a/plugins/styleguide/spec/system/smoke_test_spec.rb +++ b/plugins/styleguide/spec/system/smoke_test_spec.rb @@ -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" },