discourse/app/assets/javascripts/float-kit/addon/components/d-menu.gjs
Joffrey JAFFEUX 59ec86933a
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>
2025-04-15 14:56:57 +02:00

204 lines
5.5 KiB
Text
Vendored

import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import curryComponent from "ember-curry-component";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class";
import { isTesting } from "discourse/lib/environment";
import DFloatBody from "float-kit/components/d-float-body";
import { MENU } from "float-kit/lib/constants";
import DMenuInstance from "float-kit/lib/d-menu-instance";
export default class DMenu extends Component {
@service menu;
@service site;
menuInstance = new DMenuInstance(getOwner(this), {
...this.allowedProperties,
autoUpdate: true,
listeners: true,
});
registerTrigger = modifier((domElement) => {
this.menuInstance.trigger = domElement;
this.options.onRegisterApi?.(this.menuInstance);
return () => {
this.menuInstance.destroy();
};
});
registerFloatBody = modifier((domElement) => {
this.body = domElement;
return () => {
this.body = null;
};
});
@action
teardownFloatBody() {
this.body = null;
}
@action
forwardTabToContent(event) {
if (!this.body) {
return;
}
if (event.key === "Tab") {
event.preventDefault();
const firstFocusable = this.body.querySelector(
'button, a, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus() || this.body.focus();
}
}
get menuId() {
return `d-menu-${this.menuInstance.id}`;
}
get options() {
return this.menuInstance?.options ?? {};
}
get componentArgs() {
return {
close: this.menuInstance.close,
show: this.menuInstance.show,
data: this.options.data,
};
}
get triggerComponent() {
const instance = this;
const baseArguments = {
get icon() {
return instance.args.icon;
},
get translatedLabel() {
return instance.args.label;
},
get translatedAriaLabel() {
return instance.args.ariaLabel;
},
get translatedTitle() {
return instance.args.title;
},
get disabled() {
return instance.args.disabled;
},
get isLoading() {
return instance.args.isLoading;
},
};
return (
this.args.triggerComponent ||
curryComponent(DButton, baseArguments, getOwner(this))
);
}
get allowedProperties() {
const properties = {};
for (const [key, value] of Object.entries(MENU.options)) {
properties[key] = this.args[key] ?? value;
}
return properties;
}
<template>
<this.triggerComponent
{{this.registerTrigger}}
class={{concatClass
"fk-d-menu__trigger"
(if this.menuInstance.expanded "-expanded")
(concat this.options.identifier "-trigger")
@triggerClass
@class
}}
id={{this.menuInstance.id}}
data-identifier={{this.options.identifier}}
data-trigger
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
@componentArgs={{this.componentArgs}}
...attributes
>
{{#if (has-block "trigger")}}
{{yield this.componentArgs to="trigger"}}
{{/if}}
</this.triggerComponent>
{{#if this.menuInstance.expanded}}
{{#if (and this.site.mobileView this.options.modalForMobile)}}
<DModal
@closeModal={{this.menuInstance.close}}
@hideHeader={{true}}
@autofocus={{this.options.autofocus}}
class={{concatClass
"fk-d-menu-modal"
(concat this.options.identifier "-content")
@contentClass
@class
}}
@inline={{(isTesting)}}
data-identifier={{this.options.identifier}}
data-content
>
<div class="fk-d-menu-modal__grip" aria-hidden="true"></div>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.menuInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DModal>
{{else}}
<DFloatBody
@instance={{this.menuInstance}}
@trapTab={{this.options.trapTab}}
@mainClass={{concatClass
"fk-d-menu"
(concat this.options.identifier "-content")
@class
@contentClass
}}
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}
{{this.registerFloatBody}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.menuInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DFloatBody>
{{/if}}
{{/if}}
</template>
}