From 09b459224c8f1592161c87f866d832ae4d534210 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 10 Apr 2026 16:41:36 +0700 Subject: [PATCH 1/4] CS-10688: Add selected items summary section to Picker dropdown Replace reorder-based approach with a "shopping cart" pattern: - Selected items shown in a summary section at the top of the dropdown - Main option list stays in original order (never reorders) - Select-all rendered in before-options section above the summary - Users can toggle selection from both summary and main list Co-Authored-By: Claude Opus 4.6 (1M context) --- .../picker/before-options-with-search.gts | 85 +++++ .../addon/src/components/picker/index.gts | 157 ++------ .../src/components/picker/option-row.gts | 13 - .../integration/components/picker-test.gts | 345 ++++++++++-------- 4 files changed, 314 insertions(+), 286 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts b/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts index 3e1ee14b483..58a0de1d95c 100644 --- a/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts +++ b/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts @@ -1,10 +1,13 @@ import { autoFocus } from '@cardstack/boxel-ui/modifiers'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; import { action } from '@ember/object'; import Component from '@glimmer/component'; import type { Select } from 'ember-power-select/components/power-select'; import BoxelInput from '../input/index.gts'; import type { PickerOption } from './index.gts'; +import PickerOptionRow from './option-row.gts'; export interface BeforeOptionsWithSearchSignature { Args: { @@ -13,9 +16,13 @@ export interface BeforeOptionsWithSearchSignature { options: PickerOption[], searchTerm: string, ) => PickerOption[]; + isSelectAllActive?: boolean; onSearchTermChange?: (term: string) => void; + onToggleItem?: (item: PickerOption) => void; searchPlaceholder?: string; searchTerm?: string; + selectAllOption?: PickerOption; + selectedItems?: PickerOption[]; }; select: Select; }; @@ -30,11 +37,34 @@ export default class PickerBeforeOptionsWithSearch extends Component 0; + } + @action updateSearchTerm(value: string) { this.args.extra?.onSearchTermChange?.(value); } + @action + handleToggleItem(item: PickerOption, event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + this.args.extra?.onToggleItem?.(item); + } + } diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index c2f58f545c1..0f36cf83703 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -131,8 +131,6 @@ class PickerLoadingOverlay extends Component { 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); @@ -166,99 +164,42 @@ export default class Picker extends Component { } } - // When there is a search term: - // - Always keep any "select-all" (search-all) option at the very top - // - Then list already-selected options (so they stay visible even if they don't match the term) - // - Then list unselected options that match the search term, in their original order - get filteredOptions(): PickerOption[] { + // Returns non-select-all options in original order, filtered by search term. + // Select-all is rendered in the before-options section, not in the main list. + get displayOptions(): PickerOption[] { + const nonSelectAll = this.args.options.filter( + (o) => o.type !== 'select-all', + ); if (!this.searchTerm || this.args.disableClientSideSearch) { - return this.args.options; + return nonSelectAll; } - - const selectAll = this.args.options.filter((o) => o.type === 'select-all'); - const selectedOptions = this.args.options.filter( - (o) => this.args.selected.includes(o) && o.type !== 'select-all', - ); - const unselectedOptions = this.args.options.filter( - (o) => !this.args.selected.includes(o) && o.type !== 'select-all', - ); - const term = this.searchTerm.toLowerCase(); - return [ - ...selectAll, - ...selectedOptions, - ...unselectedOptions.filter((option) => { - const text = option.label.toLowerCase(); - return text.includes(term); - }), - ]; - } - - // Reorders the already-filtered options so that: - // - "select-all" (search-all) options are always first - // - 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 { pinnedOption, pinnedToSection } = this; - - const selected = options.filter((o) => { - if (o.type === 'select-all') return false; - if (pinnedOption && o.id === pinnedOption.id) { - return pinnedToSection === 'selected'; - } - return this.args.selected.includes(o); - }); - - const unselected = options.filter((o) => { - if (o.type === 'select-all') return false; - if (pinnedOption && o.id === pinnedOption.id) { - return pinnedToSection === 'unselected'; - } - return !this.args.selected.includes(o); + return nonSelectAll.filter((option) => { + return option.label.toLowerCase().includes(term); }); + } - const selectAll = options.filter((o) => o.type === 'select-all'); - - return [...selectAll, ...selected, ...unselected]; + get selectAllOption(): PickerOption | undefined { + return this.args.options.find((o) => o.type === 'select-all'); } - private isVisuallyInSelectedSection(option: PickerOption): boolean { - if (option.type === 'select-all') return false; - if (this.pinnedOption && option.id === this.pinnedOption.id) { - return this.pinnedToSection === 'selected'; - } - return this.args.selected.includes(option); + get selectedItems(): PickerOption[] { + return this.args.selected.filter((o) => o.type !== 'select-all'); } - get selectedInSortedOptions(): PickerOption[] { - return this.sortedOptions.filter((o) => - this.isVisuallyInSelectedSection(o), - ); + get isSelectAllActive(): boolean { + return this.args.selected.some((o) => o.type === 'select-all'); } get isSelected() { return (option: PickerOption) => includes(this.args.selected, option); } - isLastSelected = (option: PickerOption) => { - const selectedInSorted = this.selectedInSortedOptions; - const lastSelected = selectedInSorted[selectedInSorted.length - 1]; - return lastSelected === option; - }; - isLastOption = (option: PickerOption): boolean => { - const sorted = this.sortedOptions; - return sorted.length > 0 && sorted[sorted.length - 1] === option; + const display = this.displayOptions; + return display.length > 0 && display[display.length - 1] === option; }; - get hasUnselected() { - const unselected = this.sortedOptions.filter( - (o) => o.type !== 'select-all' && !this.isVisuallyInSelectedSection(o), - ); - return unselected.length > 0; - } - get triggerComponent() { return PickerLabeledTrigger; } @@ -268,23 +209,8 @@ export default class Picker extends Component { this.args.onSearchTermChange?.(term); }; - onOptionHover = (option: PickerOption | null) => { - if ( - option && - option.type !== 'select-all' && - this.pinnedOption?.id !== option.id - ) { - // Remember where the option was when hover started - this.pinnedOption = option; - this.pinnedToSection = this.args.selected.includes(option) - ? 'selected' - : 'unselected'; - } - }; - - resetPinnedOption = () => { - this.pinnedOption = null; - this.pinnedToSection = null; + onClose = () => { + this.searchTerm = ''; return true; }; @@ -297,6 +223,10 @@ export default class Picker extends Component { onSearchTermChange: this.onSearchTermChange, maxSelectedDisplay: this.args.maxSelectedDisplay, isLoading: this.args.isLoading, + selectAllOption: this.selectAllOption, + selectedItems: this.selectedItems, + isSelectAllActive: this.isSelectAllActive, + onToggleItem: this.onToggleItem, }; } @@ -318,6 +248,17 @@ export default class Picker extends Component { return undefined; } + onToggleItem = (item: PickerOption) => { + const isCurrentlySelected = this.args.selected.includes(item); + let newSelected: PickerOption[]; + if (isCurrentlySelected) { + newSelected = this.args.selected.filter((o) => o !== item); + } else { + newSelected = [...this.args.selected, item]; + } + this.onChange(newSelected); + }; + onChange = (selected: PickerOption[]) => { // Ignore clicks on disabled options const lastAdded = selected.find((opt) => !this.args.selected.includes(opt)); @@ -368,21 +309,12 @@ export default class Picker extends Component { this.args.onChange(selected); }; - displayDivider = (option: PickerOption) => { - return ( - (this.isLastSelected(option) && this.hasUnselected) || - (option.type === 'select-all' && - this.selectedInSortedOptions.length === 0) - ); - }; -