diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index bd9264bd469..f3e343eec82 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -47,6 +47,7 @@ import BoxelMultiSelect, { BoxelMultiSelectBasic, } from './components/multi-select/index.gts'; import PhoneInput from './components/phone-input/index.gts'; +import Picker, { type PickerOption } from './components/picker/index.gts'; import Pill from './components/pill/index.gts'; import ProgressBar from './components/progress-bar/index.gts'; import ProgressRadial from './components/progress-radial/index.gts'; @@ -123,6 +124,8 @@ export { Message, Modal, PhoneInput, + Picker, + PickerOption, Pill, ProgressBar, ProgressRadial, diff --git a/packages/boxel-ui/addon/src/components/multi-select/index.gts b/packages/boxel-ui/addon/src/components/multi-select/index.gts index a8ae196e448..9be1eac9bd6 100644 --- a/packages/boxel-ui/addon/src/components/multi-select/index.gts +++ b/packages/boxel-ui/addon/src/components/multi-select/index.gts @@ -18,9 +18,12 @@ import BoxelMultiSelectDefaultTrigger, { } from './trigger.gts'; export interface BoxelMultiSelectArgs extends PowerSelectArgs { + afterOptionsComponent?: ComponentLike; ariaLabel?: string; + beforeOptionsComponent?: ComponentLike; closeOnSelect?: boolean; disabled?: boolean; + dropdownClass?: string; extra?: any; matchTriggerWidth?: boolean; onBlur?: (select: Select, e: Event) => boolean | undefined; @@ -75,7 +78,11 @@ export class BoxelMultiSelectBasic extends Component> { @registerAPI={{@registerAPI}} @initiallyOpened={{@initiallyOpened}} @extra={{@extra}} - @dropdownClass='boxel-multi-select__dropdown' + @dropdownClass={{if + @dropdownClass + @dropdownClass + 'boxel-multi-select__dropdown' + }} {{! actions }} @onOpen={{@onOpen}} @onClose={{@onClose}} @@ -84,7 +91,11 @@ export class BoxelMultiSelectBasic extends Component> { @selectedItemComponent={{@selectedItemComponent}} @triggerComponent={{@triggerComponent}} @afterOptionsComponent={{@afterOptionsComponent}} - @beforeOptionsComponent={{component BeforeOptions}} + @beforeOptionsComponent={{if + @beforeOptionsComponent + (component @beforeOptionsComponent) + (component BeforeOptions) + }} ...attributes as |option| > 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 new file mode 100644 index 00000000000..a7f1c2129bd --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts @@ -0,0 +1,87 @@ +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'; + +export interface BeforeOptionsWithSearchSignature { + Args: { + extra?: { + filterOptions?: ( + options: PickerOption[], + searchTerm: string, + ) => PickerOption[]; + onSearchTermChange?: (term: string) => void; + searchPlaceholder?: string; + searchTerm?: string; + }; + select: Select; + }; +} + +export default class PickerBeforeOptionsWithSearch extends Component { + get searchTerm() { + return this.args.extra?.searchTerm || ''; + } + + get searchPlaceholder() { + return this.args.extra?.searchPlaceholder || 'search for a realm'; + } + + @action + updateSearchTerm(value: string) { + this.args.extra?.onSearchTermChange?.(value); + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts new file mode 100644 index 00000000000..c571af850e3 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -0,0 +1,257 @@ +import type Owner from '@ember/owner'; +import { scheduleOnce } from '@ember/runloop'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import type { Select } from 'ember-power-select/components/power-select'; +import { includes } from 'lodash'; + +import type { Icon } from '../../icons/types.ts'; +import { BoxelMultiSelectBasic } from '../multi-select/index.gts'; +import PickerBeforeOptionsWithSearch from './before-options-with-search.gts'; +import PickerOptionRow from './option-row.gts'; +import PickerLabeledTrigger from './trigger-labeled.gts'; + +export type PickerOption = { + icon?: Icon | string; + id: string; + name: string; + type?: 'select-all' | 'option'; +}; + +export interface PickerSignature { + Args: { + // State + disabled?: boolean; + // Display + label: string; + matchTriggerWidth?: boolean; + + onChange: (selected: PickerOption[]) => void; + // Data + options: PickerOption[]; + + placeholder?: string; + + renderInPlace?: boolean; + searchPlaceholder?: string; + selected: PickerOption[]; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLElement; +} + +export default class Picker extends Component { + @tracked searchTerm = ''; + + constructor(owner: Owner, args: PickerSignature['Args']) { + super(owner, args); + this.validateSelectAllOption(); + scheduleOnce('afterRender', this, this.ensureDefaultSelection); + } + + private validateSelectAllOption() { + const hasSelectAll = this.args.options.some( + (option) => option.type === 'select-all', + ); + if (!hasSelectAll) { + throw new Error( + 'Picker requires a select-all option in @options (type: "select-all").', + ); + } + } + + private ensureDefaultSelection() { + if (this.args.selected.length === 0) { + const selectAllOptions = this.args.options.filter( + (option) => option.type === 'select-all', + ); + if (selectAllOptions.length > 0) { + this.args.onChange(selectAllOptions); + } + } + } + + // 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[] { + if (!this.searchTerm) { + return this.args.options; + } + + 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.name.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 + // - Unselected regular options are listed last + 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 selectAll = options.filter((o) => o.type === 'select-all'); + return [...selectAll, ...selected, ...unselected]; + } + + get selectedInSortedOptions(): PickerOption[] { + return this.sortedOptions.filter((o) => this.args.selected.includes(o)); + } + + 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; + }; + + get hasUnselected() { + const unselected = this.sortedOptions.filter( + (o) => !this.args.selected.includes(o), + ); + return unselected.length > 0; + } + + get triggerComponent() { + return PickerLabeledTrigger; + } + + onSearchTermChange = (term: string) => { + this.searchTerm = term; + }; + + get extra() { + return { + label: this.args.label, + searchTerm: this.searchTerm, + searchPlaceholder: this.args.searchPlaceholder, + onSearchTermChange: this.onSearchTermChange, + }; + } + + onChange = (selected: PickerOption[]) => { + const selectAllOptions = selected.filter((option) => { + return option.type === 'select-all'; + }); + const nonSelectAllOptions = selected.filter((option) => { + return option.type !== 'select-all'; + }); + const previouslyHadSelectAll = this.args.selected.some( + (option) => option.type === 'select-all', + ); + const allSelectAllOptions = this.args.options.filter( + (option) => option.type === 'select-all', + ); + const allNonSelectAllOptions = this.args.options.filter( + (option) => option.type !== 'select-all', + ); + + // Deselect select-all if there are other options selected + if (selectAllOptions.length > 0 && nonSelectAllOptions.length > 0) { + if (previouslyHadSelectAll) { + this.args.onChange(nonSelectAllOptions); + return; + } + this.args.onChange(selectAllOptions); + return; + } + + // Select select-all if all other options are selected + // and deselect all other options + // or if no options are selected + let isAllOptionsSelected = + nonSelectAllOptions.length > 0 && + nonSelectAllOptions.length === allNonSelectAllOptions.length; + let isNoOptionSelected = nonSelectAllOptions.length === 0; + if ( + allSelectAllOptions.length > 0 && + (isAllOptionsSelected || isNoOptionSelected) + ) { + this.args.onChange(allSelectAllOptions); + return; + } + this.args.onChange(selected); + }; + + displayDivider = (option: PickerOption) => { + return ( + (this.isLastSelected(option) && this.hasUnselected) || + (option.type === 'select-all' && + this.selectedInSortedOptions.length === 0) + ); + }; + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/option-row.gts b/packages/boxel-ui/addon/src/components/picker/option-row.gts new file mode 100644 index 00000000000..10044469994 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/option-row.gts @@ -0,0 +1,189 @@ +//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'; + +import { cn } from '../../helpers.ts'; +import CheckMark from '../../icons/check-mark.gts'; +import type { Icon } from '../../icons/types.ts'; +import type { PickerOption } from './index.gts'; + +export interface OptionRowSignature { + Args: { + currentSelected?: PickerOption[]; + isSelected: boolean; + option: PickerOption; + select?: Select; + }; + Element: HTMLElement; +} + +export default class PickerOptionRow extends Component { + get icon() { + return this.args.option.icon; + } + + get label() { + return this.args.option.name; + } + + get isIconString() { + return typeof this.icon === 'string'; + } + + get isIconURL() { + return this.isIconString && (this.icon as string).startsWith('http'); + } + + get isIconSVG() { + return this.isIconString && !this.isIconURL; + } + + get iconString() { + return this.isIconString ? (this.icon as string) : undefined; + } + + get iconComponent() { + return !this.isIconString ? (this.icon as Icon | undefined) : undefined; + } + + +} + +function addClassToSVG(svgString: string, className: string) { + return svgString + .replace(/]*)\sclass="([^"]*)"/, `]*)>/, + ``, + ); +} diff --git a/packages/boxel-ui/addon/src/components/picker/selected-item.gts b/packages/boxel-ui/addon/src/components/picker/selected-item.gts new file mode 100644 index 00000000000..e4681cc4a0b --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/selected-item.gts @@ -0,0 +1,187 @@ +import { addClassToSVG } from '@cardstack/boxel-ui/helpers'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import Component from '@glimmer/component'; +import type { Select } from 'ember-power-select/components/power-select'; + +import IconX from '../../icons/icon-x.gts'; +import type { Icon } from '../../icons/types.ts'; +import IconButton from '../icon-button/index.gts'; +import Pill from '../pill/index.gts'; +import type { PickerOption } from './index.gts'; + +export interface PickerSelectedItemSignature { + Args: { + extra?: { + getItemIcon?: (item: PickerOption) => Icon | string | undefined; + getItemText?: (item: PickerOption) => string; + }; + option: PickerOption; + select: Select & { + actions: { + remove: (item: PickerOption) => void; + }; + }; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLDivElement; +} + +export default class PickerSelectedItem extends Component { + get icon() { + return this.args.option.icon; + } + + get text() { + return this.args.option.name; + } + + get isIconString() { + return typeof this.icon === 'string'; + } + + get isIconURL() { + return this.isIconString && (this.icon as string).startsWith('http'); + } + + get isIconSVG() { + return this.isIconString && !this.isIconURL; + } + + get iconString() { + return this.isIconString ? (this.icon as string) : undefined; + } + + get iconComponent() { + return !this.isIconString ? (this.icon as Icon | undefined) : undefined; + } + + @action + remove(item: PickerOption, event: MouseEvent) { + // Do not remove these event methods + // This is to ensure that the close/click event from selected item does not bubble up to the trigger + // and cause the dropdown to close + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (typeof this.args.select.actions.remove === 'function') { + this.args.select.actions.remove(item); + } else { + console.warn('Remove action is not available'); + } + } + + get displayRemoveButton() { + return this.args.option.type !== 'select-all'; + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts new file mode 100644 index 00000000000..6474d79c3fd --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts @@ -0,0 +1,158 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import type { ComponentLike } from '@glint/template'; +import type { Select } from 'ember-power-select/components/power-select'; + +import { cn } from '../../helpers.ts'; +import { not } from '../../helpers/truth-helpers.ts'; +import CaretDown from '../../icons/caret-down.gts'; +import type { PickerOption } from './index.gts'; +import PickerSelectedItem, { + type PickerSelectedItemSignature, +} from './selected-item.gts'; + +export interface TriggerLabeledSignature { + Args: { + extra?: { + label?: string; + }; + placeholder?: string; + select: Select; + selectedItemComponent?: ComponentLike; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLElement; +} + +type ExtendedSelect = Select & { + actions: { + remove: (item: PickerOption) => void; + } & Select['actions']; +}; + +export default class PickerLabeledTrigger extends Component { + get showPlaceholder() { + return this.args.placeholder && this.args.select.selected.length === 0; + } + + get label() { + return this.args.extra?.label || ''; + } + + @action + removeItem(item: any, event?: MouseEvent) { + event?.stopPropagation(); + const newSelected = this.args.select.selected.filter( + (i: any) => i !== item, + ); + this.args.select.selected = [...newSelected]; + this.args.select.actions.select(newSelected); + } + + get select(): ExtendedSelect { + return { + ...this.args.select, + actions: { + remove: this.removeItem as (item: PickerOption) => void, + ...this.args.select.actions, + }, + }; + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/usage.gts b/packages/boxel-ui/addon/src/components/picker/usage.gts new file mode 100644 index 00000000000..704dc381a78 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/usage.gts @@ -0,0 +1,166 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { + Card, + Field, + File, + IconCircle, + IconGlobe, + IconHexagon, +} from '../../icons.gts'; +import Picker, { type PickerOption } from './index.gts'; + +export default class PickerUsage extends Component { + selectAllOption: PickerOption = { + id: 'select-all', + name: 'Select All', + type: 'select-all', + }; + anyTypeOption: PickerOption = { + id: 'any-type', + name: 'Any Type', + type: 'select-all', + }; + @tracked selectedRealms: PickerOption[] = []; + @tracked selectedTypes: PickerOption[] = []; + + @tracked realmOptions: PickerOption[] = [ + this.selectAllOption, + { id: '1', name: 'Boxel Catalog', icon: IconGlobe }, + { id: '2', name: 'Buffalo Exchange', icon: IconHexagon }, + { id: '3', name: 'Burritos Inc.', icon: IconCircle }, + { id: '4', name: 'Buzzsaw Club', icon: File }, + { id: '5', name: 'Canole Bros.', icon: IconGlobe }, + { id: '6', name: 'Capybara Mania', icon: IconHexagon }, + { id: '7', name: 'Cat Fancy Blog', icon: IconCircle }, + ]; + + @tracked typeOptions: PickerOption[] = [ + this.anyTypeOption, + { id: '1', name: 'Card', icon: Card }, + { id: '2', name: 'Field', icon: Field }, + { id: '3', name: 'Component', icon: File }, + { id: '4', name: 'Template', icon: File }, + ]; + + @action + onRealmChange(selected: PickerOption[]) { + this.selectedRealms = selected; + } + + @action + onTypeChange(selected: PickerOption[]) { + this.selectedTypes = selected; + } + + +} diff --git a/packages/boxel-ui/addon/src/helpers.ts b/packages/boxel-ui/addon/src/helpers.ts index b3ce0d4ed3a..b63211d3813 100644 --- a/packages/boxel-ui/addon/src/helpers.ts +++ b/packages/boxel-ui/addon/src/helpers.ts @@ -1,3 +1,4 @@ +import { addClassToSVG } from './helpers/add-class-to-svg.ts'; import { copyCardURLToClipboard } from './helpers/clipboard.ts'; import cn from './helpers/cn.ts'; import compact from './helpers/compact.ts'; @@ -64,6 +65,7 @@ export * from './helpers/color-tools.ts'; export { add, + addClassToSVG, and, bool, buildCssGroups, diff --git a/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts b/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts new file mode 100644 index 00000000000..28ea6435b84 --- /dev/null +++ b/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts @@ -0,0 +1,8 @@ +export function addClassToSVG(svgString: string, className: string) { + return svgString + .replace(/]*)\sclass="([^"]*)"/, `]*)>/, + ``, + ); +} diff --git a/packages/boxel-ui/addon/src/usage.ts b/packages/boxel-ui/addon/src/usage.ts index 00195fff54d..0d2f7453846 100644 --- a/packages/boxel-ui/addon/src/usage.ts +++ b/packages/boxel-ui/addon/src/usage.ts @@ -35,6 +35,7 @@ import MessageUsage from './components/message/usage.gts'; import ModalUsage from './components/modal/usage.gts'; import MultiSelectUsage from './components/multi-select/usage.gts'; import PhoneInputUsage from './components/phone-input/usage.gts'; +import PickerUsage from './components/picker/usage.gts'; import PillUsage from './components/pill/usage.gts'; import ProgressBarUsage from './components/progress-bar/usage.gts'; import ProgressRadialUsage from './components/progress-radial/usage.gts'; @@ -87,6 +88,7 @@ export const ALL_USAGE_COMPONENTS = [ ['Modal', ModalUsage], ['MultiSelect', MultiSelectUsage], ['PhoneInput', PhoneInputUsage], + ['Picker', PickerUsage], ['Pill', PillUsage], ['ProgressBar', ProgressBarUsage], ['ProgressRadial', ProgressRadialUsage], 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 new file mode 100644 index 00000000000..022b92c7467 --- /dev/null +++ b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts @@ -0,0 +1,493 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'test-app/tests/helpers'; +import { click, render, waitFor, fillIn } from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; +import { Picker, type PickerOption } from '@cardstack/boxel-ui/components'; +import Ember from 'ember'; + +function noop() {} + +module('Integration | Component | picker', function (hooks) { + setupRenderingTest(hooks); + + const testOptions: PickerOption[] = [ + { id: '1', name: 'Option 1', icon: 'https://via.placeholder.com/20' }, + { id: '2', name: 'Option 2', icon: 'https://via.placeholder.com/20' }, + { id: '3', name: 'Option 3' }, + { id: '4', name: 'Option 4' }, + ]; + const selectAllOption: PickerOption = { + id: 'select-all', + name: 'All options', + type: 'select-all', + }; + const testOptionsWithSelectAll: PickerOption[] = [ + selectAllOption, + ...testOptions, + ]; + + const emptyArray: PickerOption[] = []; + + test('picker renders with label and defaults to select-all', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + const controller = new SelectionController(); + + const onChange = (newSelected: PickerOption[]) => { + controller.selected = newSelected; + }; + + await render( + , + ); + + assert.dom('[data-test-boxel-picker-trigger-label]').hasText('Test Label'); + assert.dom('[data-test-boxel-picker-trigger-placeholder]').doesNotExist(); + }); + + test('picker shows selected items in trigger', async function (assert) { + const selected = [testOptions[0], testOptions[1]]; + + await render( + , + ); + + // Check that selected items are displayed (they should be in pills) + await click('[data-test-boxel-picker-trigger]'); + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + }); + + test('picker opens dropdown when clicked', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + 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]'); + + assert.dom('[data-test-boxel-picker-option-row]').exists({ count: 5 }); + }); + + test('picker groups selected items first when groupSelected is true', async function (assert) { + const selected = [testOptions[2], testOptions[3]]; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // First two should be selected + assert + .dom( + `[data-test-boxel-picker-option-row="2"][data-test-boxel-picker-option-selected]`, + ) + .exists(); + assert + .dom( + `[data-test-boxel-picker-option-row="3"][data-test-boxel-picker-option-selected]`, + ) + .exists(); + + // Check for divider after selected items + const divider = document.querySelector('[data-test-boxel-picker-divider]'); + assert + .dom(divider) + .exists('Divider should exist between selected and unselected'); + }); + + test('picker toggles selection when option is clicked', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + 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]'); + + // Click first option + const firstOption = document.querySelectorAll( + '[data-test-boxel-picker-option-row]', + )[1]; + await click(firstOption as HTMLElement); + + assert.strictEqual( + controller.selected.length, + 1, + 'Should have one selected item', + ); + assert.strictEqual( + controller.selected[0].id, + '1', + 'Should have selected first option', + ); + + await click(firstOption as HTMLElement); + assert.strictEqual( + controller.selected.length, + 1, + 'Select-all option cannot be deselected', + ); + }); + + test('picker shows search input when searchEnabled is true', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + 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-before-options]'); + + assert.dom('[data-test-boxel-picker-search]').exists(); + assert + .dom('[data-test-boxel-picker-search] input') + .hasAttribute('placeholder', 'Search...'); + }); + + test('picker keeps select-all and selected options first when searching', async function (assert) { + const selected = [testOptionsWithSelectAll[2]]; // Option 2 + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-before-options]'); + + await fillIn('[data-test-boxel-picker-search] input', '3'); + await waitFor('[data-test-boxel-picker-option-row]'); + + const optionIds = Array.from( + document.querySelectorAll('[data-test-boxel-picker-option-row]'), + ).map((el) => + (el as HTMLElement).getAttribute('data-test-boxel-picker-option-row'), + ); + + assert.deepEqual( + optionIds, + ['select-all', '2', '3'], + 'select-all stays first, then selected option, then matching unselected option', + ); + }); + + test('picker removes select-all when another option is selected', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = [selectAllOption]; + } + + 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 secondOption = document.querySelectorAll( + '[data-test-boxel-picker-option-row]', + )[1]; + await click(secondOption as HTMLElement); + + assert.deepEqual( + controller.selected.map((option) => option.id), + ['1'], + 'select-all is removed once another option is selected', + ); + }); + + test('picker selects select-all when it is chosen after other options', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = [testOptionsWithSelectAll[1]]; + } + + 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 selectAllRow = document.querySelectorAll( + '[data-test-boxel-picker-option-row]', + )[0]; + await click(selectAllRow as HTMLElement); + + assert.deepEqual( + controller.selected.map((option) => option.id), + ['select-all'], + 'select-all replaces existing selections when selected', + ); + }); + + test('picker hides remove button for select-all pill', async function (assert) { + const selecteOptions: PickerOption[] = [selectAllOption]; + + await render( + , + ); + + assert + .dom( + '[data-test-boxel-picker-selected-item] button[aria-label="Remove item"]', + ) + .doesNotExist('select-all pill should not render a remove button'); + }); + + test('picker selects select-all when all options are selected', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + 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 optionRows = Array.from( + document.querySelectorAll('[data-test-boxel-picker-option-row]'), + ); + const nonSelectAllRows = optionRows.filter( + (row) => + (row as HTMLElement).getAttribute( + 'data-test-boxel-picker-option-row', + ) !== 'select-all', + ); + + for (const row of nonSelectAllRows) { + await click(row as HTMLElement); + } + + assert.deepEqual( + controller.selected.map((option) => option.id), + ['select-all'], + 'select-all replaces individual selections when all are selected', + ); + }); + + test('picker selects select-all when no options are selected', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + 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 firstOption = document.querySelectorAll( + '[data-test-boxel-picker-option-row]', + )[1]; + + await click(firstOption as HTMLElement); + await click(firstOption as HTMLElement); + + assert.deepEqual( + controller.selected.map((option) => option.id), + ['select-all'], + 'select-all is selected when no options remain selected', + ); + }); + + test('picker selects select-all by default when selected is empty', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + const controller = new SelectionController(); + + const onChange = (newSelected: PickerOption[]) => { + controller.selected = newSelected; + }; + + await render( + , + ); + + assert.deepEqual( + controller.selected.map((option) => option.id), + ['select-all'], + 'select-all is chosen when no initial selection is provided', + ); + }); + + test('picker throws when select-all option is missing', async function (assert) { + let original = Ember.onerror; + + Ember.onerror = (error) => { + assert.ok( + /select-all option/i.test(error.message), + 'throws expected select-all option error', + ); + // swallow so it doesn't become a "global failure" + return true; + }; + + try { + await render( + , + ); + } finally { + Ember.onerror = original; + } + }); +});