mirror of
https://github.com/discourse/discourse.git
synced 2025-04-17 09:09:05 +08:00
DEV: DMultiSelect (#32240)
The `DMultiSelect` component provides a customizable multi-select dropdown with support for both mouse and keyboard interactions.  ### 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:
parent
4510d1ad46
commit
59ec86933a
@ -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>
|
||||
}
|
@ -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");
|
||||
});
|
||||
});
|
@ -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")}}
|
||||
|
@ -63,3 +63,4 @@
|
||||
@import "filter-input";
|
||||
@import "dropdown-menu";
|
||||
@import "welcome-banner";
|
||||
@import "d-multi-select";
|
||||
|
151
app/assets/stylesheets/common/components/d-multi-select.scss
Normal file
151
app/assets/stylesheets/common/components/d-multi-select.scss
Normal 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);
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
@ -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" },
|
||||
|
@ -26,6 +26,8 @@ en:
|
||||
title: "Date/Time inputs"
|
||||
menus:
|
||||
title: "Menus"
|
||||
multi_select:
|
||||
title: "Multi select"
|
||||
toasts:
|
||||
title: "Toasts"
|
||||
font_scale:
|
||||
|
@ -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" },
|
||||
|
Loading…
x
Reference in New Issue
Block a user