From dc0cfc7ddecf0ad96d35d2ae40f17b7fedd005c7 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 3 Feb 2026 17:47:41 +0700 Subject: [PATCH 1/5] Adjust Card Chooser search bar to include Realm picker --- .../src/components/multi-select/usage.gts | 44 ++++ .../addon/src/components/picker/index.gts | 8 +- .../app/components/realm-picker/index.gts | 100 ++++++++++ .../search-sheet/card-query-results.gts | 21 +- .../app/components/search-sheet/index.gts | 54 +++-- .../components/search-sheet/search-bar.gts | 188 ++++++++++++++++++ .../components/operator-mode-ui-test.gts | 93 +++++++++ 7 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 packages/host/app/components/realm-picker/index.gts create mode 100644 packages/host/app/components/search-sheet/search-bar.gts diff --git a/packages/boxel-ui/addon/src/components/multi-select/usage.gts b/packages/boxel-ui/addon/src/components/multi-select/usage.gts index dbedbf7a96a..bda6f14a0ce 100644 --- a/packages/boxel-ui/addon/src/components/multi-select/usage.gts +++ b/packages/boxel-ui/addon/src/components/multi-select/usage.gts @@ -16,6 +16,8 @@ import cssVar from '../../helpers/css-var.ts'; import CheckMark from '../../icons/check-mark.gts'; import BoxelField from '../field-container/index.gts'; import BoxelMultiSelect, { BoxelMultiSelectBasic } from './index.gts'; +import BoxelMultiSelectLabeledTrigger from './trigger-labeled.gts'; +import BoxelMultiSelectOptionRow from './option-row.gts'; import BoxelSelectedItem from './selected-item.gts'; export function getPlural(s: string, count?: number) { @@ -171,6 +173,8 @@ export default class BoxelMultiSelectUsage extends Component { @tracked selectedFilter: string | undefined = undefined; @tracked publicAPI: Select | undefined = undefined; + realmExtra = { label: 'Realm' }; + @cssVariable({ cssClassName: 'boxel-multi-select-usage-container' }) declare boxelSelectedPillBackgroundColor: CSSVariableInfo; @@ -415,6 +419,46 @@ export default class BoxelMultiSelectUsage extends Component { + + + <:description> +

+ This example demonstrates the labeled trigger pattern with option + rows. The trigger displays a label on the left and selected items on + the right. Each option row includes a checkbox, optional icon, and + label. +

+

+ To group selected options above unselected ones, you can use the + BoxelMultiSelectGroupedOptions + component in a custom beforeOptions component, or sort your options + array before passing it to the multi-select. +

+ + <:example> + + + + +
} diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index c96c488f5a8..f3415a13cea 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -51,10 +51,6 @@ export default class Picker extends Component { scheduleOnce('afterRender', this, this.ensureDefaultSelection); } - get renderInPlace() { - return this.args.renderInPlace ?? true; - } - private validateSelectAllOption() { const hasSelectAll = this.args.options.some( (option) => option.type === 'select-all', @@ -218,8 +214,8 @@ export default class Picker extends Component { @onChange={{this.onChange}} @placeholder={{@placeholder}} @disabled={{@disabled}} - @renderInPlace={{this.renderInPlace}} - @matchTriggerWidth={{true}} + @renderInPlace={{@renderInPlace}} + @matchTriggerWidth={{@matchTriggerWidth}} @searchEnabled={{false}} @closeOnSelect={{false}} @eventType='click' diff --git a/packages/host/app/components/realm-picker/index.gts b/packages/host/app/components/realm-picker/index.gts new file mode 100644 index 00000000000..cb452ec85ed --- /dev/null +++ b/packages/host/app/components/realm-picker/index.gts @@ -0,0 +1,100 @@ +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { Picker, type PickerOption } from '@cardstack/boxel-ui/components'; + +import type RealmService from '@cardstack/host/services/realm'; +import type RealmServerService from '@cardstack/host/services/realm-server'; + +import WithKnownRealmsLoaded from '../with-known-realms-loaded'; + +const SELECT_ALL_OPTION: PickerOption = { + id: 'select-all', + name: 'All Realms', + type: 'select-all', +}; + +interface Signature { + Args: { + selected: PickerOption[]; + onChange: (selected: PickerOption[]) => void; + label?: string; + placeholder?: string; + }; + Blocks: {}; +} + +export default class RealmPicker extends Component { + @service declare realm: RealmService; + @service declare realmServer: RealmServerService; + + get realmOptions(): PickerOption[] { + const urls = this.realmServer.availableRealmURLs; + const options: PickerOption[] = [SELECT_ALL_OPTION]; + for (const realmURL of urls) { + const info = this.realm.info(realmURL); + const name = info?.name ?? this.realmDisplayNameFromURL(realmURL); + const icon = info?.iconURL ?? undefined; + options.push({ + id: realmURL, + icon, + name, + type: 'option', + }); + } + console.log(options); + return options; + } + + private realmDisplayNameFromURL(realmURL: string): string { + try { + const pathname = new URL(realmURL).pathname; + const segments = pathname.split('/').filter(Boolean); + if (segments.length === 0) { + return 'Base'; + } + return segments[segments.length - 1] ?? 'Base'; + } catch { + return 'Unknown Workspace'; + } + } + + +} diff --git a/packages/host/app/components/search-sheet/card-query-results.gts b/packages/host/app/components/search-sheet/card-query-results.gts index f96d41be2b2..4a92e5ad4ac 100644 --- a/packages/host/app/components/search-sheet/card-query-results.gts +++ b/packages/host/app/components/search-sheet/card-query-results.gts @@ -22,6 +22,7 @@ interface Signature { searchKey: string; isCompact: boolean; handleCardSelect: (cardId: string) => void; + realms?: string[]; }; Blocks: {}; } @@ -30,8 +31,14 @@ export default class CardQueryResults extends Component { @service declare realmServer: RealmServerService; get realms() { - return this.realmServer.availableRealmURLs; + return this.args.realms ?? this.realmServer.availableRealmURLs; } + + /** Serialized for data-test-search-realms (testing realm filter) */ + get realmsForTest(): string { + return this.realms.join(','); + } + get query() { let { searchKey } = this.args; let type = getCodeRefFromSearchKey(searchKey); @@ -67,11 +74,12 @@ export default class CardQueryResults extends Component { } } diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 794479d53c8..1ce16aa1054 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -8,17 +8,16 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import onClickOutside from 'ember-click-outside/modifiers/on-click-outside'; -import { modifier } from 'ember-modifier'; import { consume } from 'ember-provide-consume-context'; import { trackedFunction } from 'reactiveweb/function'; import { Button, - BoxelInput, IconButton, BoxelInputBottomTreatments, } from '@cardstack/boxel-ui/components'; +import type { PickerOption } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { IconSearch } from '@cardstack/boxel-ui/icons'; @@ -29,8 +28,8 @@ import type RealmServerService from '@cardstack/host/services/realm-server'; import CardQueryResults from './card-query-results'; import CardURLResults from './card-url-results'; - import RecentCardsSection from './recent-cards-section'; +import SearchBar from './search-bar'; import { getCodeRefFromSearchKey } from './utils'; import type StoreService from '../../services/store'; @@ -61,18 +60,11 @@ interface Signature { Blocks: {}; } -let elementCallback = modifier( - (element, [callback]: [((element: HTMLElement) => void) | undefined]) => { - if (callback) { - callback(element as HTMLElement); - } - }, -); - export default class SearchSheet extends Component { @consume(GetCardContextName) declare private getCard: getCard; @tracked private searchKey = ''; + @tracked selectedRealms: PickerOption[] = []; @service declare private realmServer: RealmServerService; @service declare private store: StoreService; @@ -144,6 +136,22 @@ export default class SearchSheet extends Component { private resetState() { this.searchKey = ''; + this.selectedRealms = []; + } + + private get selectedRealmURLs(): string[] { + const hasSelectAll = this.selectedRealms.some( + (opt) => opt.type === 'select-all', + ); + if (hasSelectAll || this.selectedRealms.length === 0) { + return this.realmServer.availableRealmURLs; + } + return this.selectedRealms.map((opt) => opt.id).filter(Boolean); + } + + @action + private onRealmChange(selected: PickerOption[]) { + this.selectedRealms = selected; } @action private debouncedSetSearchKey(searchKey: string) { @@ -226,7 +234,7 @@ export default class SearchSheet extends Component { id='search-sheet' class='search-sheet {{this.sheetSize}}' data-test-search-sheet={{@mode}} - {{onClickOutside @onBlur exceptSelector='.add-card-to-neighbor-stack'}} + {{onClickOutside @onBlur exceptSelector='.add-card-to-neighbor-stack,.boxel-picker__dropdown,.picker-before-options-with-search,.picker-option-row'}} > {{#if (eq @mode 'closed')}} { data-test-open-search-field /> {{else}} -
{{#if this.searchKeyIsURL}} @@ -268,6 +275,7 @@ export default class SearchSheet extends Component { {{else}} @@ -326,11 +334,13 @@ export default class SearchSheet extends Component { overflow: hidden; } .search-sheet:not(.closed):deep(.input-container), - .search-sheet:not(.closed):deep(.search-sheet__search-input-group) { + .search-sheet:not(.closed):deep(.search-sheet__search-input-group), + .search-sheet:not(.closed):deep(.search-sheet__search-bar) { border-radius: inherit; } - .search-sheet__search-input-group { + .search-sheet__search-input-group, + .search-sheet__search-bar { transition: height var(--boxel-transition), width var(--boxel-transition); diff --git a/packages/host/app/components/search-sheet/search-bar.gts b/packages/host/app/components/search-sheet/search-bar.gts new file mode 100644 index 00000000000..0dbbd67053c --- /dev/null +++ b/packages/host/app/components/search-sheet/search-bar.gts @@ -0,0 +1,188 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +import { modifier } from 'ember-modifier'; + +import { + BoxelInput, + type BoxelInputBottomTreatments, +} from '@cardstack/boxel-ui/components'; +import type { PickerOption } from '@cardstack/boxel-ui/components'; +import { IconSearch } from '@cardstack/boxel-ui/icons'; + +import RealmPicker from '@cardstack/host/components/realm-picker'; + +let elementCallback = modifier( + (element, [callback]: [((element: HTMLElement) => void) | undefined]) => { + if (callback) { + callback(element as HTMLElement); + } + }, +); + +interface Signature { + Element: HTMLElement; + Args: { + value: string; + placeholder?: string; + onInput?: (value: string) => void; + onFocus?: (ev: Event) => void; + onBlur?: (ev: Event) => void; + onKeyDown?: (ev: KeyboardEvent) => void; + onInputInsertion?: (element: HTMLElement) => void; + selectedRealms: PickerOption[]; + onRealmChange: (selected: PickerOption[]) => void; + bottomTreatment?: BoxelInputBottomTreatments; + state?: 'none' | 'valid' | 'invalid' | 'loading' | 'initial'; + id?: string; + }; + Blocks: {}; +} + +export default class SearchBar extends Component { + @action + handleKeyDown(ev: KeyboardEvent): void { + this.args.onKeyDown?.(ev); + } + + +} diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 7febc126eda..60880165e3e 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -7,6 +7,7 @@ import { triggerEvent, triggerKeyEvent, blur, + settled, } from '@ember/test-helpers'; import GlimmerComponent from '@glimmer/component'; @@ -390,6 +391,98 @@ module('Integration | operator-mode | ui', function (hooks) { assert.dom(`[data-test-search-sheet="closed"]`).exists(); }); + test('search sheet shows realm picker when expanded and filters by selected realm', async function (assert) { + ctx.setCardInOperatorModeState(`${testRealmURL}grid`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor(`[data-test-stack-card="${testRealmURL}grid"]`); + await click(`[data-test-boxel-filter-list-button="All Cards"]`); + await waitFor(`[data-test-cards-grid-item]`); + + await click(`[data-test-open-search-field]`); + assert.dom(`[data-test-search-sheet="search-prompt"]`).exists(); + assert.dom('[data-test-search-sheet-search-bar]').exists(); + assert.dom('[data-test-realm-picker]').exists(); + + // Type a search term to trigger search results; search receives realms from the picker + await typeIn('[data-test-search-field]', 'Person'); + await click('[data-test-search-sheet] .search-sheet-content'); + await waitFor('[data-test-search-label]', { timeout: 8000 }); + await waitFor('[data-test-search-realms]', { timeout: 3000 }); + + // Assert the search is using a realms list that includes the test realm (filter is connected) + const realmsAttr = document + .querySelector('[data-test-search-realms]') + ?.getAttribute('data-test-search-realms'); + assert.ok( + realmsAttr, + 'search should receive realms (data-test-search-realms)', + ); + const realmsList = realmsAttr?.split(',').map((r) => r.trim()) ?? []; + assert.ok( + realmsList.some((r) => r.includes('test-realm') && r.includes('/test')), + 'realms list should include the test realm', + ); + + // When only one realm is available, "All Realms" and selecting that realm both yield one realm + const alreadySingleRealm = realmsList.length === 1; + if (!alreadySingleRealm) { + // Multiple realms: open picker and select only the test realm to verify filter updates + const trigger = + document.querySelector( + '[data-test-realm-picker] .ember-power-select-trigger', + ) ?? document.querySelector('[data-test-realm-picker]'); + assert.ok(trigger, 'realm picker trigger should exist'); + await click(trigger as HTMLElement); + await waitFor('.ember-power-select-option', { timeout: 3000 }); + const options = document.querySelectorAll('.ember-power-select-option'); + const allRealmsOption = Array.from(options).find((el) => + el.textContent?.includes('All Realms'), + ); + if (allRealmsOption?.getAttribute('aria-selected') === 'true') { + await click(allRealmsOption as HTMLElement); + } + const testRealmOption = Array.from(options).find((el) => + el.textContent?.includes(ctx.realmName), + ); + assert.ok( + testRealmOption, + `option for "${ctx.realmName}" should exist`, + ); + await click(testRealmOption as HTMLElement); + await settled(); + await waitUntil( + () => { + const attr = document + .querySelector('[data-test-search-realms]') + ?.getAttribute('data-test-search-realms'); + if (!attr) return false; + const list = attr.split(',').map((r) => r.trim()); + return list.length === 1 && list[0].includes('test-realm'); + }, + { timeout: 5000 }, + ); + } + + const finalAttr = document + .querySelector('[data-test-search-realms]') + ?.getAttribute('data-test-search-realms'); + const finalRealms = finalAttr?.split(',').map((r) => r.trim()) ?? []; + assert.ok( + finalRealms.length >= 1, + 'search should be scoped to at least one realm', + ); + assert.ok( + finalRealms.some( + (r) => r.includes('test-realm') && r.includes('/test'), + ), + 'realms should include the test realm (filter is applied)', + ); + }); + test('displays card in interact mode when clicking `Open in Interact Mode` menu in preview panel', async function (assert) { ctx.setCardInOperatorModeState(`${testRealmURL}grid`); From 1137a2d62e822ee8d476743418db7417ec4b51f9 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 3 Feb 2026 21:05:48 +0700 Subject: [PATCH 2/5] lint fix --- .../src/components/multi-select/usage.gts | 44 ------------- .../search-sheet/card-query-results.gts | 66 +++++++++---------- .../app/components/search-sheet/index.gts | 5 +- .../components/operator-mode-ui-test.gts | 12 +--- 4 files changed, 40 insertions(+), 87 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/multi-select/usage.gts b/packages/boxel-ui/addon/src/components/multi-select/usage.gts index bda6f14a0ce..dbedbf7a96a 100644 --- a/packages/boxel-ui/addon/src/components/multi-select/usage.gts +++ b/packages/boxel-ui/addon/src/components/multi-select/usage.gts @@ -16,8 +16,6 @@ import cssVar from '../../helpers/css-var.ts'; import CheckMark from '../../icons/check-mark.gts'; import BoxelField from '../field-container/index.gts'; import BoxelMultiSelect, { BoxelMultiSelectBasic } from './index.gts'; -import BoxelMultiSelectLabeledTrigger from './trigger-labeled.gts'; -import BoxelMultiSelectOptionRow from './option-row.gts'; import BoxelSelectedItem from './selected-item.gts'; export function getPlural(s: string, count?: number) { @@ -173,8 +171,6 @@ export default class BoxelMultiSelectUsage extends Component { @tracked selectedFilter: string | undefined = undefined; @tracked publicAPI: Select | undefined = undefined; - realmExtra = { label: 'Realm' }; - @cssVariable({ cssClassName: 'boxel-multi-select-usage-container' }) declare boxelSelectedPillBackgroundColor: CSSVariableInfo; @@ -419,46 +415,6 @@ export default class BoxelMultiSelectUsage extends Component { - - - <:description> -

- This example demonstrates the labeled trigger pattern with option - rows. The trigger displays a label on the left and selected items on - the right. Each option row includes a checkbox, optional icon, and - label. -

-

- To group selected options above unselected ones, you can use the - BoxelMultiSelectGroupedOptions - component in a custom beforeOptions component, or sort your options - array before passing it to the multi-select. -

- - <:example> - - - - -
} diff --git a/packages/host/app/components/search-sheet/card-query-results.gts b/packages/host/app/components/search-sheet/card-query-results.gts index 4a92e5ad4ac..26dcb2a2503 100644 --- a/packages/host/app/components/search-sheet/card-query-results.gts +++ b/packages/host/app/components/search-sheet/card-query-results.gts @@ -80,41 +80,41 @@ export default class CardQueryResults extends Component { @format='fitted' @realms={{this.realms}} > - <:loading> - - - <:response as |cards|> - {{#if (or (gt cards.length 0) this.isSearchKeyNotEmpty)}} + <:loading> - {{#each cards as |card i|}} - {{#unless card.isError}} - - {{/unless}} - {{/each}} - - {{/if}} - - + /> + + <:response as |cards|> + {{#if (or (gt cards.length 0) this.isSearchKeyNotEmpty)}} + + {{#each cards as |card i|}} + {{#unless card.isError}} + + {{/unless}} + {{/each}} + + {{/if}} + + } diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 1ce16aa1054..6d06bd9deb3 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -234,7 +234,10 @@ export default class SearchSheet extends Component { id='search-sheet' class='search-sheet {{this.sheetSize}}' data-test-search-sheet={{@mode}} - {{onClickOutside @onBlur exceptSelector='.add-card-to-neighbor-stack,.boxel-picker__dropdown,.picker-before-options-with-search,.picker-option-row'}} + {{onClickOutside + @onBlur + exceptSelector='.add-card-to-neighbor-stack,.boxel-picker__dropdown,.picker-before-options-with-search,.picker-option-row' + }} > {{#if (eq @mode 'closed')}} el.textContent?.includes(ctx.realmName), ); - assert.ok( - testRealmOption, - `option for "${ctx.realmName}" should exist`, - ); + assert.ok(testRealmOption, `option for "${ctx.realmName}" should exist`); await click(testRealmOption as HTMLElement); - await settled(); + await waitUntil( () => { const attr = document @@ -476,9 +472,7 @@ module('Integration | operator-mode | ui', function (hooks) { 'search should be scoped to at least one realm', ); assert.ok( - finalRealms.some( - (r) => r.includes('test-realm') && r.includes('/test'), - ), + finalRealms.some((r) => r.includes('test-realm') && r.includes('/test')), 'realms should include the test realm (filter is applied)', ); }); From 8f8d3359440347eb4c54fd0f925b84c93e2febdd Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 4 Feb 2026 15:29:43 +0700 Subject: [PATCH 3/5] Add max selected items --- .../addon/src/components/picker/index.gts | 2 + .../src/components/picker/trigger-labeled.gts | 74 ++++++++++- .../addon/src/components/picker/usage.gts | 13 ++ .../integration/components/picker-test.gts | 119 ++++++++++++++++++ .../app/components/realm-picker/index.gts | 1 + .../components/operator-mode-ui-test.gts | 102 ++++++--------- 6 files changed, 249 insertions(+), 62 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index f3415a13cea..2075cf5118e 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -25,6 +25,7 @@ export interface PickerSignature { // Display label: string; matchTriggerWidth?: boolean; + maxSelectedDisplay?: number; onChange: (selected: PickerOption[]) => void; // Data @@ -152,6 +153,7 @@ export default class Picker extends Component { searchTerm: this.searchTerm, searchPlaceholder: this.args.searchPlaceholder, onSearchTermChange: this.onSearchTermChange, + maxSelectedDisplay: this.args.maxSelectedDisplay, }; } diff --git a/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts index 6474d79c3fd..2bdd51b64f2 100644 --- a/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts +++ b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts @@ -1,3 +1,4 @@ +import { on } from '@ember/modifier'; import { action } from '@ember/object'; import Component from '@glimmer/component'; import type { ComponentLike } from '@glint/template'; @@ -15,6 +16,7 @@ export interface TriggerLabeledSignature { Args: { extra?: { label?: string; + maxSelectedDisplay?: number; }; placeholder?: string; select: Select; @@ -41,6 +43,36 @@ export default class PickerLabeledTrigger extends Component 0; + } + @action removeItem(item: any, event?: MouseEvent) { event?.stopPropagation(); @@ -51,6 +83,14 @@ export default class PickerLabeledTrigger extends Component {{/each}} + {{#if this.hasMoreItems}} +
+ +{{this.remainingCount}} + more +
+ {{/if}} {{/let}} {{/if}} @@ -149,6 +201,26 @@ export default class PickerLabeledTrigger extends Component + +
+

Realm Picker (with max display limit of 2)

+ +
diff --git a/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts index 022b92c7467..fde44516fa0 100644 --- a/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts +++ b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts @@ -490,4 +490,123 @@ module('Integration | Component | picker', function (hooks) { Ember.onerror = original; } }); + + test('picker limits displayed items when maxSelectedDisplay is set', async function (assert) { + const selected = [testOptions[0], testOptions[1], testOptions[2]]; + + await render( + , + ); + + // Only 2 items should be visible + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + }); + + test('picker shows +X more pill when items exceed maxSelectedDisplay', async function (assert) { + const selected = [ + testOptions[0], + testOptions[1], + testOptions[2], + testOptions[3], + ]; + + await render( + , + ); + + // 2 items + 1 more pill + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + assert.dom('[data-test-boxel-picker-more-items]').exists(); + assert.dom('[data-test-boxel-picker-more-items]').hasText('+2 more'); + }); + + test('picker does not show +X more pill when items do not exceed maxSelectedDisplay', async function (assert) { + const selected = [testOptions[0], testOptions[1]]; + + await render( + , + ); + + // Only 2 items should be visible, no more pill + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + assert.dom('[data-test-boxel-picker-more-items]').doesNotExist(); + }); + + test('clicking +X more pill opens the dropdown', async function (assert) { + const selected = [ + testOptions[0], + testOptions[1], + testOptions[2], + testOptions[3], + ]; + + await render( + , + ); + + assert.dom('[data-test-boxel-picker-more-items]').exists(); + + // Click the more items pill + await click('[data-test-boxel-picker-more-items]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // Dropdown should be open and show all options + assert.dom('[data-test-boxel-picker-option-row]').exists({ count: 5 }); + }); + + test('picker shows all items when maxSelectedDisplay is not set', async function (assert) { + const selected = [ + testOptions[0], + testOptions[1], + testOptions[2], + testOptions[3], + ]; + + await render( + , + ); + + // All 4 items should be visible + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 4 }); + assert.dom('[data-test-boxel-picker-more-items]').doesNotExist(); + }); }); diff --git a/packages/host/app/components/realm-picker/index.gts b/packages/host/app/components/realm-picker/index.gts index cb452ec85ed..590a6957a92 100644 --- a/packages/host/app/components/realm-picker/index.gts +++ b/packages/host/app/components/realm-picker/index.gts @@ -68,6 +68,7 @@ export default class RealmPicker extends Component { @selected={{@selected}} @onChange={{@onChange}} @placeholder={{@placeholder}} + @maxSelectedDisplay={{3}} data-test-realm-picker /> diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 71f13276d6c..613ed534b04 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -406,74 +406,54 @@ module('Integration | operator-mode | ui', function (hooks) { assert.dom('[data-test-search-sheet-search-bar]').exists(); assert.dom('[data-test-realm-picker]').exists(); - // Type a search term to trigger search results; search receives realms from the picker + // Type a search term to trigger search results await typeIn('[data-test-search-field]', 'Person'); await click('[data-test-search-sheet] .search-sheet-content'); await waitFor('[data-test-search-label]', { timeout: 8000 }); await waitFor('[data-test-search-realms]', { timeout: 3000 }); - // Assert the search is using a realms list that includes the test realm (filter is connected) - const realmsAttr = document - .querySelector('[data-test-search-realms]') - ?.getAttribute('data-test-search-realms'); - assert.ok( - realmsAttr, - 'search should receive realms (data-test-search-realms)', - ); - const realmsList = realmsAttr?.split(',').map((r) => r.trim()) ?? []; - assert.ok( - realmsList.some((r) => r.includes('test-realm') && r.includes('/test')), - 'realms list should include the test realm', - ); - - // When only one realm is available, "All Realms" and selecting that realm both yield one realm - const alreadySingleRealm = realmsList.length === 1; - if (!alreadySingleRealm) { - // Multiple realms: open picker and select only the test realm to verify filter updates - const trigger = - document.querySelector( - '[data-test-realm-picker] .ember-power-select-trigger', - ) ?? document.querySelector('[data-test-realm-picker]'); - assert.ok(trigger, 'realm picker trigger should exist'); - await click(trigger as HTMLElement); - await waitFor('.ember-power-select-option', { timeout: 3000 }); - const options = document.querySelectorAll('.ember-power-select-option'); - const allRealmsOption = Array.from(options).find((el) => - el.textContent?.includes('All Realms'), - ); - if (allRealmsOption?.getAttribute('aria-selected') === 'true') { - await click(allRealmsOption as HTMLElement); - } - const testRealmOption = Array.from(options).find((el) => - el.textContent?.includes(ctx.realmName), - ); - assert.ok(testRealmOption, `option for "${ctx.realmName}" should exist`); - await click(testRealmOption as HTMLElement); - - await waitUntil( - () => { - const attr = document - .querySelector('[data-test-search-realms]') - ?.getAttribute('data-test-search-realms'); - if (!attr) return false; - const list = attr.split(',').map((r) => r.trim()); - return list.length === 1 && list[0].includes('test-realm'); - }, - { timeout: 5000 }, - ); - } - - const finalAttr = document - .querySelector('[data-test-search-realms]') - ?.getAttribute('data-test-search-realms'); - const finalRealms = finalAttr?.split(',').map((r) => r.trim()) ?? []; + // Helper function to get current selected realms + const getSelectedRealms = () => { + const attr = document + .querySelector('[data-test-search-realms]') + ?.getAttribute('data-test-search-realms'); + return attr?.split(',').map((r) => r.trim()) ?? []; + }; + + // Verify initial realm filtering includes test realm + let selectedRealms = getSelectedRealms(); assert.ok( - finalRealms.length >= 1, - 'search should be scoped to at least one realm', - ); + selectedRealms.some( + (r) => r.includes('test-realm') && r.includes('/test'), + ), + 'search should initially include the test realm', + ); + + // Select only the test realm in the picker to verify filter updates + const trigger = + document.querySelector( + '[data-test-realm-picker] .ember-power-select-trigger', + ) ?? document.querySelector('[data-test-realm-picker]'); + await click(trigger as HTMLElement); + await waitFor('.ember-power-select-option', { timeout: 3000 }); + + const options = document.querySelectorAll('.ember-power-select-option'); + const testRealmOption = Array.from(options).find((el) => + el.textContent?.includes(ctx.realmName), + ); + assert.ok(testRealmOption, `option for "${ctx.realmName}" should exist`); + await click(testRealmOption as HTMLElement); + + // Verify the filter was applied + await waitUntil(() => getSelectedRealms().includes(testRealmURL), { + timeout: 5000, + }); + selectedRealms = getSelectedRealms(); assert.ok( - finalRealms.some((r) => r.includes('test-realm') && r.includes('/test')), - 'realms should include the test realm (filter is applied)', + selectedRealms.some( + (r) => r.includes('test-realm') && r.includes('/test'), + ), + 'search should be filtered to the test realm after selection', ); }); From bd4cee0d050d36a5c151bf1eab4c8d1dd2aee2cd Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 5 Feb 2026 21:45:45 +0700 Subject: [PATCH 4/5] Address feedback --- .../addon/src/components/picker/index.gts | 66 +++++- .../src/components/picker/option-row.gts | 13 +- .../integration/components/picker-test.gts | 208 +++++++++++++++++- .../app/components/realm-picker/index.gts | 12 +- 4 files changed, 280 insertions(+), 19 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 2075cf5118e..79360c15233 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -45,6 +45,8 @@ export interface PickerSignature { export default class Picker extends Component { @tracked searchTerm = ''; + @tracked private pinnedOption: PickerOption | null = null; + @tracked private pinnedToSection: 'selected' | 'unselected' | null = null; constructor(owner: Owner, args: PickerSignature['Args']) { super(owner, args); @@ -52,6 +54,10 @@ export default class Picker extends Component { scheduleOnce('afterRender', this, this.ensureDefaultSelection); } + get renderInPlace() { + return this.args.renderInPlace ?? true; + } + private validateSelectAllOption() { const hasSelectAll = this.args.options.some( (option) => option.type === 'select-all', @@ -104,22 +110,46 @@ export default class Picker extends Component { // Reorders the already-filtered options so that: // - "select-all" (search-all) options are always first - // - Selected regular options come next - // - Unselected regular options are listed last + // - Selected regular options come next (including pinned items that were in selected section) + // - Unselected regular options are listed last (including pinned items that were in unselected section) get sortedOptions(): PickerOption[] { const options = this.filteredOptions; - const selected = options.filter( - (o) => this.args.selected.includes(o) && o.type !== 'select-all', - ); - const unselected = options.filter( - (o) => !this.args.selected.includes(o) && o.type !== 'select-all', - ); + const { pinnedOption, pinnedToSection } = this; + + const selected = options.filter((o) => { + if (o.type === 'select-all') return false; + // If this is the pinned option, check which section it should stay in + if (o === pinnedOption) { + return pinnedToSection === 'selected'; + } + return this.args.selected.includes(o); + }); + + const unselected = options.filter((o) => { + if (o.type === 'select-all') return false; + // If this is the pinned option, check which section it should stay in + if (o === pinnedOption) { + return pinnedToSection === 'unselected'; + } + return !this.args.selected.includes(o); + }); + const selectAll = options.filter((o) => o.type === 'select-all'); return [...selectAll, ...selected, ...unselected]; } + private isVisuallyInSelectedSection(option: PickerOption): boolean { + if (option.type === 'select-all') return false; + if (option === this.pinnedOption) { + return this.pinnedToSection === 'selected'; + } + return this.args.selected.includes(option); + } + get selectedInSortedOptions(): PickerOption[] { - return this.sortedOptions.filter((o) => this.args.selected.includes(o)); + return this.sortedOptions.filter((o) => + this.isVisuallyInSelectedSection(o), + ); } get isSelected() { @@ -134,7 +164,7 @@ export default class Picker extends Component { get hasUnselected() { const unselected = this.sortedOptions.filter( - (o) => !this.args.selected.includes(o), + (o) => o.type !== 'select-all' && !this.isVisuallyInSelectedSection(o), ); return unselected.length > 0; } @@ -147,6 +177,19 @@ export default class Picker extends Component { this.searchTerm = term; }; + onOptionHover = (option: PickerOption | null) => { + if (option && option.type !== 'select-all') { + // Remember where the option was when hover started + this.pinnedOption = option; + this.pinnedToSection = this.args.selected.includes(option) + ? 'selected' + : 'unselected'; + } else { + this.pinnedOption = null; + this.pinnedToSection = null; + } + }; + get extra() { return { label: this.args.label, @@ -216,7 +259,7 @@ export default class Picker extends Component { @onChange={{this.onChange}} @placeholder={{@placeholder}} @disabled={{@disabled}} - @renderInPlace={{@renderInPlace}} + @renderInPlace={{this.renderInPlace}} @matchTriggerWidth={{@matchTriggerWidth}} @searchEnabled={{false}} @closeOnSelect={{false}} @@ -233,6 +276,7 @@ export default class Picker extends Component { @option={{option}} @isSelected={{this.isSelected option}} @currentSelected={{@selected}} + @onHover={{this.onOptionHover}} /> {{#if (this.displayDivider option)}}
diff --git a/packages/boxel-ui/addon/src/components/picker/option-row.gts b/packages/boxel-ui/addon/src/components/picker/option-row.gts index ef6e789343f..cdd6c74c62a 100644 --- a/packages/boxel-ui/addon/src/components/picker/option-row.gts +++ b/packages/boxel-ui/addon/src/components/picker/option-row.gts @@ -1,4 +1,4 @@ -//import { on } from '@ember/modifier'; +import { on } from '@ember/modifier'; import { htmlSafe } from '@ember/template'; import Component from '@glimmer/component'; import type { Select } from 'ember-power-select/components/power-select'; @@ -13,6 +13,7 @@ export interface OptionRowSignature { Args: { currentSelected?: PickerOption[]; isSelected: boolean; + onHover?: (option: PickerOption | null) => void; option: PickerOption; select?: Select; }; @@ -20,6 +21,14 @@ export interface OptionRowSignature { } export default class PickerOptionRow extends Component { + handleMouseEnter = () => { + this.args.onHover?.(this.args.option); + }; + + handleMouseLeave = () => { + this.args.onHover?.(null); + }; + get icon() { return ( this.args.option.icon ?? @@ -56,6 +65,8 @@ export default class PickerOptionRow extends Component { class={{cn 'picker-option-row' picker-option-row--selected=@isSelected}} data-test-boxel-picker-option-selected={{if @isSelected 'true' 'false'}} data-test-boxel-picker-option-row={{@option.id}} + {{on 'mouseenter' this.handleMouseEnter}} + {{on 'mouseleave' this.handleMouseLeave}} >
{ + controller.selected = newSelected; + }; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // Get the option IDs before any interaction + const getOptionIds = () => + Array.from( + document.querySelectorAll('[data-test-boxel-picker-option-row]'), + ).map((el) => + (el as HTMLElement).getAttribute('data-test-boxel-picker-option-row'), + ); + + // Initial order: select-all, then selected items (1, 2), then unselected (3, 4) + assert.deepEqual( + getOptionIds(), + ['select-all', '1', '2', '3', '4'], + 'Initial order has selected items first', + ); + + // Hover over Option 1 (which is selected) + const option1Row = document.querySelector( + '[data-test-boxel-picker-option-row="1"]', + ) as HTMLElement; + await triggerEvent(option1Row, 'mouseenter'); + + // Click to uncheck Option 1 + await click(option1Row); + + // Option 1 should still be in the selected section while hovering + assert.deepEqual( + getOptionIds(), + ['select-all', '1', '2', '3', '4'], + 'Unchecked item stays in selected section while hovering', + ); + + // Verify Option 1 is actually unchecked (checkbox state) + assert + .dom('[data-test-boxel-picker-option-row="1"]') + .hasAttribute( + 'data-test-boxel-picker-option-selected', + 'false', + 'Option 1 checkbox shows unchecked', + ); + + // Mouse leave - item should now move to unselected section + await triggerEvent(option1Row, 'mouseleave'); + + assert.deepEqual( + getOptionIds(), + ['select-all', '2', '1', '3', '4'], + 'After mouse leave, unchecked item moves to unselected section', + ); + }); + + test('picker keeps checked item in unselected section while hovering', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = [testOptions[0]]; // Only Option 1 selected + } + + const controller = new SelectionController(); + + const onChange = (newSelected: PickerOption[]) => { + controller.selected = newSelected; + }; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + const getOptionIds = () => + Array.from( + document.querySelectorAll('[data-test-boxel-picker-option-row]'), + ).map((el) => + (el as HTMLElement).getAttribute('data-test-boxel-picker-option-row'), + ); + + // Initial order: select-all, then selected item (1), then unselected (2, 3, 4) + assert.deepEqual( + getOptionIds(), + ['select-all', '1', '2', '3', '4'], + 'Initial order has selected items first', + ); + + // Hover over Option 2 (which is unselected) + const option2Row = document.querySelector( + '[data-test-boxel-picker-option-row="2"]', + ) as HTMLElement; + await triggerEvent(option2Row, 'mouseenter'); + + // Click to check Option 2 + await click(option2Row); + + // Option 2 should still be in the unselected section while hovering + assert.deepEqual( + getOptionIds(), + ['select-all', '1', '2', '3', '4'], + 'Checked item stays in unselected section while hovering', + ); + + // Verify Option 2 is actually checked (checkbox state) + assert + .dom('[data-test-boxel-picker-option-row="2"]') + .hasAttribute( + 'data-test-boxel-picker-option-selected', + 'true', + 'Option 2 checkbox shows checked', + ); + + // Mouse leave - item should now move to selected section + await triggerEvent(option2Row, 'mouseleave'); + + assert.deepEqual( + getOptionIds(), + ['select-all', '1', '2', '3', '4'], + 'After mouse leave, checked item moves to selected section', + ); + }); + + test('picker divider stays in correct position while item is pinned', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = [testOptions[0], testOptions[1]]; // Option 1 and 2 selected + } + + const controller = new SelectionController(); + + const onChange = (newSelected: PickerOption[]) => { + controller.selected = newSelected; + }; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // Hover over Option 2 (last selected item) + const option2Row = document.querySelector( + '[data-test-boxel-picker-option-row="2"]', + ) as HTMLElement; + await triggerEvent(option2Row, 'mouseenter'); + + // Click to uncheck Option 2 + await click(option2Row); + + // The divider should still appear after Option 2 (pinned in selected section) + // since it's visually the last item in the selected section + const divider = document.querySelector('[data-test-boxel-picker-divider]'); + assert.dom(divider).exists('Divider should exist while item is pinned'); + + // Mouse leave + await triggerEvent(option2Row, 'mouseleave'); + + // After unpin, divider should now be after Option 1 (only remaining selected) + const dividerAfterUnpin = document.querySelector( + '[data-test-boxel-picker-divider]', + ); + assert + .dom(dividerAfterUnpin) + .exists('Divider should still exist after unpin'); + }); }); diff --git a/packages/host/app/components/realm-picker/index.gts b/packages/host/app/components/realm-picker/index.gts index 590a6957a92..e4d87a4d1d1 100644 --- a/packages/host/app/components/realm-picker/index.gts +++ b/packages/host/app/components/realm-picker/index.gts @@ -42,7 +42,6 @@ export default class RealmPicker extends Component { type: 'option', }); } - console.log(options); return options; } @@ -69,23 +68,24 @@ export default class RealmPicker extends Component { @onChange={{@onChange}} @placeholder={{@placeholder}} @maxSelectedDisplay={{3}} + @renderInPlace={{false}} data-test-realm-picker /> <:loading>
- Realm - Loading… + Realm + Loading…
From 2c7a13e275ffc620b6972d441e31bc4999b56ff4 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 9 Feb 2026 09:43:39 +0700 Subject: [PATCH 5/5] Fix css --- .../app/components/search-sheet/search-bar.gts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/host/app/components/search-sheet/search-bar.gts b/packages/host/app/components/search-sheet/search-bar.gts index 0dbbd67053c..d4663da0ad4 100644 --- a/packages/host/app/components/search-sheet/search-bar.gts +++ b/packages/host/app/components/search-sheet/search-bar.gts @@ -119,14 +119,22 @@ export default class SearchBar extends Component { border-color: var(--ring, var(--boxel-highlight)); } - .search-sheet__search-bar:focus-within :deep(.boxel-input), - .search-sheet__search-bar:focus-within .search-sheet__search-bar-input { - outline: none; + .search-sheet__search-bar :deep(.boxel-input), + .search-sheet__search-bar .search-sheet__search-bar-input { + outline: none !important; + } + + .search-sheet__search-bar :deep(.boxel-input:focus), + .search-sheet__search-bar :deep(.boxel-input:focus-visible) { + outline: none !important; + border-color: transparent !important; } - .search-sheet__search-bar:focus-within :deep(.boxel-input:focus-visible) { + .search-sheet__search-bar:focus-within + :deep(.ember-power-select-trigger:focus), + .search-sheet__search-bar:focus-within + :deep(.ember-power-select-trigger:focus-visible) { outline: none; - border-color: transparent; } .search-sheet__search-bar-icon {