Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
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;
}

.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 on lines +213 to +214
return true;
};

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