UX: overhaul of GroupSelector with Floatkit (#34685)

This PR modernizes the GroupSelector component by migrating from the
legacy jQuery autocomplete to FloatKit’s DMultiSelect component.

### Changes
* Migrated GroupSelector from an Ember Classic component to Glimmer
component
* Replaced jQuery-based autocomplete with FloatKit’s DMultiSelect
component which handles search
* New scroll-into-view modifier for smooth keyboard navigation (can be
used later to replace d-autocomplete's similar functionality)
* Improved FloatKit's `size` middleware option handling to allow passing
through both minWidth and width props
* Selected items are dynamically removed from dropdown options (no
duplicates)
This commit is contained in:
Kelv
2025-09-18 15:33:52 +08:00
committed by GitHub
parent a406316dde
commit 0fa102b838
10 changed files with 321 additions and 128 deletions
@@ -1,6 +1,5 @@
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";
@@ -17,6 +16,7 @@ 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 scrollIntoView from "discourse/modifiers/scroll-into-view";
import { i18n } from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
@@ -69,6 +69,17 @@ export default class DMultiSelect extends Component {
return new TrackedAsyncData(value);
}
get availableOptions() {
if (!this.data.isResolved || !this.data.value) {
return this.data.value;
}
return this.data.value.filter(
(item) =>
!this.args.selection?.some((selected) => this.compare(item, selected))
);
}
@action
search(event) {
this.preselectedItem = null;
@@ -77,7 +88,9 @@ export default class DMultiSelect extends Component {
@action
focus(input) {
input.focus();
// Reset preselection on dropdown open to prevent unwanted scrolling
this.preselectedItem = null;
input.focus({ preventScroll: true });
}
@action
@@ -88,8 +101,15 @@ export default class DMultiSelect extends Component {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (this.preselectedItem) {
// Only toggle if we have a preselected item and it's in the available options
if (
this.preselectedItem &&
this.availableOptions?.some((item) =>
this.compare(item, this.preselectedItem)
)
) {
this.toggle(this.preselectedItem, event);
}
}
@@ -97,19 +117,19 @@ export default class DMultiSelect extends Component {
if (event.key === "ArrowDown") {
event.preventDefault();
if (!this.data.value?.length) {
if (!this.availableOptions?.length) {
return;
}
if (this.preselectedItem === null) {
this.preselectedItem = this.data.value[0];
this.preselectedItem = this.availableOptions[0];
} else {
const currentIndex = this.data.value.findIndex((item) =>
const currentIndex = this.availableOptions.findIndex((item) =>
this.compare(item, this.preselectedItem)
);
if (currentIndex < this.data.value.length - 1) {
this.preselectedItem = this.data.value[currentIndex + 1];
if (currentIndex < this.availableOptions.length - 1) {
this.preselectedItem = this.availableOptions[currentIndex + 1];
}
}
}
@@ -117,19 +137,19 @@ export default class DMultiSelect extends Component {
if (event.key === "ArrowUp") {
event.preventDefault();
if (!this.data.value?.length) {
if (!this.availableOptions?.length) {
return;
}
if (this.preselectedItem === null) {
this.preselectedItem = this.data.value[0];
this.preselectedItem = this.availableOptions[0];
} else {
const currentIndex = this.data.value.findIndex((item) =>
const currentIndex = this.availableOptions.findIndex((item) =>
this.compare(item, this.preselectedItem)
);
if (currentIndex > 0) {
this.preselectedItem = this.data.value[currentIndex - 1];
this.preselectedItem = this.availableOptions[currentIndex - 1];
}
}
}
@@ -139,26 +159,29 @@ export default class DMultiSelect extends Component {
remove(selectedItem, event) {
event?.stopPropagation();
// Reset preselected item since the available options will change
this.preselectedItem = null;
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));
const currentSelection = makeArray(this.args.selection);
// Check if item is already selected
if (currentSelection.some((item) => this.compare(item, result))) {
return; // Don't add duplicates
}
// Reset preselected item since the available options will change
this.preselectedItem = null;
this.args.onChange?.(currentSelection.concat(result));
}
@action
@@ -170,6 +193,10 @@ export default class DMultiSelect extends Component {
}
}
getDisplayText(item) {
return item?.name;
}
#resolveAsyncData(asyncData, context, resolve, reject) {
return asyncData(context).then(resolve).catch(reject);
}
@@ -179,6 +206,12 @@ export default class DMultiSelect extends Component {
@identifier="d-multi-select"
@triggerComponent={{element "div"}}
@triggerClass={{concatClass (if this.hasSelection "--has-selection")}}
@visibilityOptimizer={{@visibilityOptimizer}}
@placement={{@placement}}
@allowedPlacements={{@allowedPlacements}}
@offset={{@offset}}
@matchTriggerMinWidth={{@matchTriggerMinWidth}}
@matchTriggerWidth={{@matchTriggerWidth}}
...attributes
>
<:trigger>
@@ -188,6 +221,7 @@ export default class DMultiSelect extends Component {
<button
class="d-multi-select-trigger__selected-item"
{{on "click" (fn this.remove item)}}
title={{this.getDisplayText item}}
>
<span class="d-multi-select-trigger__selection-label">{{yield
item
@@ -241,24 +275,20 @@ export default class DMultiSelect extends Component {
{{yield this.data.error to="error"}}
</div>
{{else if this.data.isResolved}}
{{#if this.data.value}}
{{#if this.availableOptions.length}}
<div class="d-multi-select__search-results">
{{#each this.data.value as |result|}}
{{#each this.availableOptions as |result|}}
<menu.item
class={{concatClass
"d-multi-select__result"
(if (eq result this.preselectedItem) "--preselected" "")
}}
role="button"
title={{this.getDisplayText result}}
{{scrollIntoView (eq result this.preselectedItem)}}
{{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>
@@ -1,76 +1,99 @@
/* eslint-disable ember/no-classic-components */
import Component from "@ember/component";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { array } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { observes, on } from "@ember-decorators/object";
import $ from "jquery";
import groupAutocomplete from "discourse/lib/autocomplete/group";
import discourseComputed from "discourse/lib/decorators";
import DMultiSelect from "discourse/components/d-multi-select";
import { i18n } from "discourse-i18n";
export default class GroupSelector extends Component {
@discourseComputed("placeholderKey")
placeholder(placeholderKey) {
return placeholderKey ? i18n(placeholderKey) : "";
@service siteSettings;
@tracked selectedGroups = [];
constructor() {
super(...arguments);
this.initializeSelectedGroups();
}
@observes("groupNames")
_update() {
if (this.canReceiveUpdates === "true") {
this._initializeAutocomplete({ updateData: true });
initializeSelectedGroups() {
const groupNames = this.args.groupNames;
if (isEmpty(groupNames)) {
this.selectedGroups = [];
return;
}
// Convert groupNames (string or array) to array of group objects
let names = Array.isArray(groupNames) ? groupNames : [groupNames];
if (typeof groupNames === "string" && groupNames.includes(",")) {
names = groupNames.split(",").map((name) => name.trim());
}
// Create minimal group objects from names
this.selectedGroups = names
.filter((name) => name && name.length > 0)
.map((name) => ({ id: name, name }));
}
get placeholder() {
return this.args.placeholderKey ? i18n(this.args.placeholderKey) : "";
}
get loadFn() {
return async (term) => {
if (!this.args.groupFinder) {
return [];
}
return this.args.groupFinder(term);
};
}
@action
handleSelectionChange(selectedGroups) {
// Handle single selection mode
if (this.args.single && selectedGroups.length > 1) {
selectedGroups = [selectedGroups[selectedGroups.length - 1]];
}
this.selectedGroups = selectedGroups;
const groupNames = selectedGroups.map((group) => group.name);
if (this.args.onChange) {
this.args.onChange(groupNames.join(","));
} else if (this.args.onChangeCallback) {
this.args.onChangeCallback(groupNames.join(","), groupNames);
}
}
@on("didInsertElement")
_initializeAutocomplete(opts) {
let selectedGroups;
let groupNames = this.groupNames;
$(this.element.querySelector("input")).autocomplete({
debounced: true,
allowAny: false,
items: Array.isArray(groupNames)
? groupNames
: isEmpty(groupNames)
? []
: [groupNames],
single: this.single,
fullWidthWrap: this.fullWidthWrap,
updateData: opts && opts.updateData ? opts.updateData : false,
onChangeItems: (items) => {
selectedGroups = items;
if (this.onChange) {
this.onChange(items.join(","));
} else if (this.onChangeCallback) {
this.onChangeCallback(this.groupNames, selectedGroups);
} else {
this.set("groupNames", items.join(","));
}
},
transformComplete: (g) => {
return g.name;
},
dataSource: (term) => {
return this.groupFinder(term).then((groups) => {
if (!selectedGroups) {
return groups;
}
return groups.filter((group) => {
return !selectedGroups.any((s) => s === group.name);
});
});
},
template: groupAutocomplete,
});
@action
compareGroups(a, b) {
return a.name === b.name;
}
<template>
<input
placeholder={{this.placeholder}}
class="group-selector"
type="text"
name="groups"
/>
<div class="group-selector-wrapper">
<DMultiSelect
@selection={{this.selectedGroups}}
@loadFn={{this.loadFn}}
@onChange={{this.handleSelectionChange}}
@label={{this.placeholder}}
@compareFn={{this.compareGroups}}
@placement="bottom-start"
@allowedPlacements={{array "top-start" "bottom-start"}}
@matchTriggerWidth={{true}}
@matchTriggerMinWidth={{true}}
class="group-selector"
>
<:selection as |group|>
{{group.name}}
</:selection>
<:result as |group|>
{{group.name}}
</:result>
</DMultiSelect>
</div>
</template>
}
@@ -0,0 +1,24 @@
import Modifier from "ember-modifier";
/**
* Modifier to scroll an element into view when a condition is met
* Usage: {{scroll-into-view shouldScroll options}}
*
* @param {boolean} shouldScroll - Whether to scroll this element into view
* @param {Object} options - ScrollIntoView options (behavior, block, inline)
*/
export default class ScrollIntoViewModifier extends Modifier {
modify(element, [shouldScroll, options = {}]) {
if (!shouldScroll || !element) {
return;
}
const scrollOptions = {
behavior: "smooth",
block: "nearest",
...options,
};
element.scrollIntoView(scrollOptions);
}
}
@@ -77,7 +77,7 @@ module("Integration | Component | d-multi-select", function (hooks) {
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__result:nth-child(1)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
@@ -138,7 +138,7 @@ module("Integration | Component | d-multi-select", function (hooks) {
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__result:nth-child(1)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
@@ -174,7 +174,7 @@ module("Integration | Component | d-multi-select", function (hooks) {
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__result:nth-child(1)");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
@@ -184,19 +184,68 @@ module("Integration | Component | d-multi-select", function (hooks) {
.hasText("bar");
});
test("unselect item", async function (assert) {
test("selected items are removed from dropdown", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
// Initially both options should be visible
assert.dom(".d-multi-select__result").exists({ count: 2 });
assert.dom(".d-multi-select__result:nth-child(1)").hasText("foo");
assert.dom(".d-multi-select__result:nth-child(2)").hasText("bar");
// Select the first item
await click(".d-multi-select__result:nth-child(1)");
// Check that item appears in selection and only one option remains
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
assert.dom(".d-multi-select__result").exists({ count: 1 });
assert.dom(".d-multi-select__result:nth-child(1)").hasText("bar");
// Select the second item
await click(".d-multi-select__result:nth-child(1)");
// Check that both items are selected and no options remain
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");
assert.dom(".d-multi-select__result").doesNotExist();
assert.dom(".d-multi-select__search-no-results").exists();
});
test("unselect item via pill removal", 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__result:nth-child(1)");
// Both items should be selected now and no options should remain
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");
assert.dom(".d-multi-select__result").doesNotExist();
// Remove the first selected item via pill
await click(".d-multi-select-trigger__selected-item:nth-child(1)");
// Now only bar should be selected and foo should reappear in dropdown
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();
// The dropdown should still be open and show the unselected item
assert.dom(".d-multi-select__result").exists({ count: 1 });
assert.dom(".d-multi-select__result:nth-child(1)").hasText("foo");
});
test("preselect item", async function (assert) {
@@ -219,4 +268,56 @@ module("Integration | Component | d-multi-select", function (hooks) {
assert.dom(".d-multi-select__error").hasText("Error: error");
});
test("prevents duplicate selections when pressing Enter multiple times", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
// Navigate to first item and press Enter
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
// Verify first item is selected
assert
.dom(".d-multi-select-trigger__selected-item")
.exists({ count: 1 }, "Should have exactly one selected item");
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
// Press Enter again multiple times on the same item
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
// Verify still only one item is selected (no duplicates)
assert
.dom(".d-multi-select-trigger__selected-item")
.exists(
{ count: 1 },
"Should still have exactly one selected item after multiple Enter presses"
);
assert
.dom(".d-multi-select-trigger__selected-item:nth-child(1)")
.hasText("foo");
});
test("Enter key does nothing when no item is preselected", async function (assert) {
await render(<template><TestComponent /></template>);
await click(".d-multi-select-trigger");
// Press Enter without navigating to any item first
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
// Verify no items are selected
assert
.dom(".d-multi-select-trigger__selected-item")
.doesNotExist("Should not select any items when no item is preselected");
// Verify both options are still available
assert.dom(".d-multi-select__result").exists({ count: 2 });
assert.dom(".d-multi-select__result:nth-child(1)").hasText("foo");
assert.dom(".d-multi-select__result:nth-child(2)").hasText("bar");
});
});
@@ -194,22 +194,21 @@ function buildShiftMiddleware(options, detectOverflowOptions) {
}
function buildMatchSizeMiddleware(options) {
if (options.matchTriggerWidth) {
if (options.matchTriggerWidth || options.matchTriggerMinWidth) {
return size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
});
}
const styleProps = {};
const widthValue = `${rects.reference.width}px`;
if (options.matchTriggerMinWidth) {
return size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
});
if (options.matchTriggerWidth) {
styleProps.width = widthValue;
}
if (options.matchTriggerMinWidth) {
styleProps.minWidth = widthValue;
}
Object.assign(elements.floating.style, styleProps);
},
});
}
@@ -29,6 +29,7 @@
@import "footer-nav";
@import "form-template-field";
@import "group-member-dropdown";
@import "group-selector";
@import "groups-form-membership-fields";
@import "hashtag";
@import "horizontal-overflow-nav";
@@ -11,15 +11,8 @@
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);
}
}
&.-expanded {
@include default-focus;
}
}
@@ -33,6 +26,7 @@
.d-multi-select-trigger__label {
font-size: var(--font-0);
line-height: normal;
color: var(--primary-medium);
}
.d-multi-select__search-no-result,
@@ -65,7 +59,6 @@
padding: 0.25em;
gap: 0.25em;
height: 150px;
overflow-y: auto;
}
.d-multi-select__search-no-results {
@@ -108,10 +101,6 @@
}
}
.d-multi-select__result-checkbox {
margin: 0 !important;
}
.d-multi-select__skeletons {
display: flex;
flex-direction: column;
@@ -150,3 +139,18 @@
animation: dmultiselect-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-radius: var(--d-border-radius);
}
.fk-d-menu[data-identifier="d-multi-select"] {
z-index: z("modal", "dialog") + 1;
// This ensures child elements of the main d-multi-select container maintain the same width
// even if content width changes as options are selected to ensure consistency with the UI
// and click-on-outside behaviour
.fk-d-menu__inner-content {
width: inherit;
.d-multi-select__content {
width: inherit;
}
}
}
@@ -0,0 +1,11 @@
.group-selector-wrapper {
.d-multi-select-trigger__selected-item > {
.d-multi-select-trigger__selection-label {
max-width: 10em;
}
}
.d-multi-select-trigger {
width: 100%;
}
}
@@ -587,7 +587,6 @@ export default class PostEventBuilder extends Component {
@label="discourse_post_event.builder_modal.invitees.label"
>
<GroupSelector
@fullWidthWrap={{true}}
@groupFinder={{this.groupFinder}}
@groupNames={{@model.event.rawInvitees}}
@onChangeCallback={{this.setRawInvitees}}
@@ -262,8 +262,9 @@ describe "Post event", type: :system do
find(".toolbar-menu__options-trigger").click
find("button[title='#{I18n.t("js.discourse_post_event.builder_modal.attach")}']").click
find(".d-modal input[name=status][value=private]").click
find(".d-modal input.group-selector").send_keys(group.name)
find(".autocomplete.ac-group").click
find(".group-selector").click
find(".d-multi-select__search-input").send_keys(group.name)
find(".d-multi-select__result", text: group.name).click
find(".d-modal .custom-field-input").fill_in(with: "custom value")
dropdown = PageObjects::Components::SelectKit.new(".available-recurrences")
dropdown.expand
@@ -277,7 +278,7 @@ describe "Post event", type: :system do
post_event_page.edit
expect(find(".d-modal input[name=status][value=private]").checked?).to eq(true)
expect(find(".d-modal")).to have_text(group.name)
expect(find(".group-selector .d-multi-select-trigger__selection")).to have_text(group.name)
expect(find(".d-modal .custom-field-input").value).to eq("custom value")
expect(page).to have_selector(".d-modal .recurrence-until .date-picker") do |input|
input.value == "#{1.year.from_now.year}-12-30"