Skip to content
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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;
};
Expand All @@ -30,11 +37,34 @@ export default class PickerBeforeOptionsWithSearch extends Component<BeforeOptio
return this.args.extra?.searchPlaceholder || 'Search...';
}

get selectAllOption() {
return this.args.extra?.selectAllOption;
}

get isSelectAllActive() {
return this.args.extra?.isSelectAllActive ?? false;
}

get selectedItems() {
return this.args.extra?.selectedItems ?? [];
}

get showSelectedSummary() {
return !this.isSelectAllActive && this.selectedItems.length > 0;
}

@action
updateSearchTerm(value: string) {
this.args.extra?.onSearchTermChange?.(value);
}

@action
handleToggleItem(item: PickerOption, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
Comment thread
FadhlanR marked this conversation as resolved.
Outdated
this.args.extra?.onToggleItem?.(item);
}

<template>
<div class='picker-before-options' data-test-boxel-picker-before-options>
<div class='picker-before-options__search' data-test-boxel-picker-search>
Expand All @@ -47,6 +77,40 @@ export default class PickerBeforeOptionsWithSearch extends Component<BeforeOptio
{{autoFocus}}
/>
</div>

{{#if this.selectAllOption}}
<button
type='button'
class='picker-before-options__option'
data-test-boxel-picker-select-all
{{on 'click' (fn this.handleToggleItem this.selectAllOption)}}
>
<PickerOptionRow
@option={{this.selectAllOption}}
@isSelected={{this.isSelectAllActive}}
/>
</button>
{{/if}}

{{#if this.showSelectedSummary}}
<div
class='picker-before-options__selected-summary'
data-test-boxel-picker-selected-summary
>
{{#each this.selectedItems as |item|}}
<button
type='button'
class='picker-before-options__option'
data-test-boxel-picker-summary-item={{item.id}}
{{on 'click' (fn this.handleToggleItem item)}}
>
<PickerOptionRow @option={{item}} @isSelected={{true}} />
</button>
{{/each}}
</div>
{{/if}}

<div class='picker-divider' data-test-boxel-picker-divider></div>
</div>

<style scoped>
Expand Down Expand Up @@ -85,6 +149,27 @@ export default class PickerBeforeOptionsWithSearch extends Component<BeforeOptio
.search {
outline: none;
}

.picker-before-options__option {
all: unset;
display: block;
width: 100%;
cursor: pointer;
padding: 0 var(--boxel-sp-2xs);
box-sizing: border-box;
}

Comment thread
FadhlanR marked this conversation as resolved.
.picker-before-options__selected-summary {
max-height: 150px;
overflow-y: auto;
}

.picker-divider {
height: 1px;
background-color: var(--boxel-200);
margin: var(--boxel-sp-2xs) 0;
width: 100%;
}
</style>
</template>
}
158 changes: 38 additions & 120 deletions packages/boxel-ui/addon/src/components/picker/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,6 @@ class PickerLoadingOverlay extends Component<PickerAfterOptionsSignature> {

export default class Picker extends Component<PickerSignature> {
@tracked searchTerm = '';
@tracked private pinnedOption: PickerOption | null = null;
@tracked private pinnedToSection: 'selected' | 'unselected' | null = null;

constructor(owner: Owner, args: PickerSignature['Args']) {
super(owner, args);
Expand Down Expand Up @@ -166,99 +164,42 @@ export default class Picker extends Component<PickerSignature> {
}
}

// 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;
}
Expand All @@ -268,23 +209,9 @@ export default class Picker extends Component<PickerSignature> {
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 = '';
this.args.onSearchTermChange?.('');
Comment thread
FadhlanR marked this conversation as resolved.
Outdated
return true;
Comment thread
FadhlanR marked this conversation as resolved.
};

Expand All @@ -297,6 +224,10 @@ export default class Picker extends Component<PickerSignature> {
onSearchTermChange: this.onSearchTermChange,
maxSelectedDisplay: this.args.maxSelectedDisplay,
isLoading: this.args.isLoading,
selectAllOption: this.selectAllOption,
selectedItems: this.selectedItems,
isSelectAllActive: this.isSelectAllActive,
onToggleItem: this.onToggleItem,
};
}

Expand All @@ -318,6 +249,17 @@ export default class Picker extends Component<PickerSignature> {
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));
Expand Down Expand Up @@ -368,21 +310,12 @@ export default class Picker extends Component<PickerSignature> {
this.args.onChange(selected);
};

displayDivider = (option: PickerOption) => {
return (
(this.isLastSelected(option) && this.hasUnselected) ||
(option.type === 'select-all' &&
this.selectedInSortedOptions.length === 0)
);
};

<template>
<BoxelMultiSelectBasic
@options={{this.sortedOptions}}
@options={{this.displayOptions}}
@selected={{@selected}}
@onChange={{this.onChange}}
@onBlur={{this.resetPinnedOption}}
@onClose={{this.resetPinnedOption}}
@onClose={{this.onClose}}
@placeholder={{@placeholder}}
@disabled={{@disabled}}
@renderInPlace={{this.renderInPlace}}
Expand All @@ -404,12 +337,7 @@ export default class Picker extends Component<PickerSignature> {
@option={{option}}
@isSelected={{this.isSelected option}}
@currentSelected={{@selected}}
@onFocus={{this.onOptionHover}}
@onLeave={{this.resetPinnedOption}}
/>
{{#if (this.displayDivider option)}}
<div class='picker-divider' data-test-boxel-picker-divider></div>
{{/if}}
{{#if (this.isLastOption option)}}
{{#if @hasMore}}
<div
Expand All @@ -429,13 +357,6 @@ export default class Picker extends Component<PickerSignature> {

{{! template-lint-disable require-scoped-style }}
<style>
.picker-divider {
height: 1px;
background-color: var(--boxel-200);
margin: var(--boxel-sp-2xs) 0;
width: 100%;
}

.boxel-picker__dropdown {
padding-bottom: var(--boxel-sp-3xs);
}
Expand All @@ -449,9 +370,6 @@ export default class Picker extends Component<PickerSignature> {
.ember-power-select-option:not(:first-child) {
display: none;
}
.boxel-picker__dropdown--loading .picker-divider:not(:last-child) {
display: none;
}

.boxel-picker__dropdown .ember-power-select-option {
padding: 0 var(--boxel-sp-2xs);
Expand Down
Loading
Loading