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
64 changes: 53 additions & 11 deletions packages/boxel-ui/addon/src/components/picker/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface PickerSignature {
// Display
label: string;
matchTriggerWidth?: boolean;
maxSelectedDisplay?: number;

onChange: (selected: PickerOption[]) => void;
// Data
Expand All @@ -44,6 +45,8 @@ export interface PickerSignature {

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 @@ -107,22 +110,46 @@ export default class Picker extends Component<PickerSignature> {

// 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() {
Expand All @@ -137,7 +164,7 @@ export default class Picker extends Component<PickerSignature> {

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;
}
Expand All @@ -150,12 +177,26 @@ export default class Picker extends Component<PickerSignature> {
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,
searchTerm: this.searchTerm,
searchPlaceholder: this.args.searchPlaceholder,
onSearchTermChange: this.onSearchTermChange,
maxSelectedDisplay: this.args.maxSelectedDisplay,
};
}

Expand Down Expand Up @@ -219,7 +260,7 @@ export default class Picker extends Component<PickerSignature> {
@placeholder={{@placeholder}}
@disabled={{@disabled}}
@renderInPlace={{this.renderInPlace}}
@matchTriggerWidth={{true}}
@matchTriggerWidth={{@matchTriggerWidth}}
@searchEnabled={{false}}
@closeOnSelect={{false}}
@eventType='click'
Expand All @@ -235,6 +276,7 @@ export default class Picker extends Component<PickerSignature> {
@option={{option}}
@isSelected={{this.isSelected option}}
@currentSelected={{@selected}}
@onHover={{this.onOptionHover}}
/>
{{#if (this.displayDivider option)}}
<div class='picker-divider' data-test-boxel-picker-divider></div>
Expand Down
13 changes: 12 additions & 1 deletion packages/boxel-ui/addon/src/components/picker/option-row.gts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,13 +13,22 @@ export interface OptionRowSignature {
Args: {
currentSelected?: PickerOption[];
isSelected: boolean;
onHover?: (option: PickerOption | null) => void;
option: PickerOption;
select?: Select;
};
Element: HTMLElement;
}

export default class PickerOptionRow extends Component<OptionRowSignature> {
handleMouseEnter = () => {
this.args.onHover?.(this.args.option);
};

handleMouseLeave = () => {
this.args.onHover?.(null);
};

get icon() {
return (
this.args.option.icon ??
Expand Down Expand Up @@ -56,6 +65,8 @@ export default class PickerOptionRow extends Component<OptionRowSignature> {
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}}
>
<div
class={{cn
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,7 @@ export interface TriggerLabeledSignature {
Args: {
extra?: {
label?: string;
maxSelectedDisplay?: number;
};
placeholder?: string;
select: Select;
Expand All @@ -41,6 +43,36 @@ export default class PickerLabeledTrigger extends Component<TriggerLabeledSignat
return this.args.extra?.label || '';
}

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

get displayedItems() {
const selected = this.args.select.selected;
if (
!this.maxSelectedDisplay ||
selected.length <= this.maxSelectedDisplay
) {
return selected;
}
return selected.slice(0, this.maxSelectedDisplay);
}

get remainingCount() {
const selected = this.args.select.selected;
if (
!this.maxSelectedDisplay ||
selected.length <= this.maxSelectedDisplay
) {
return 0;
}
return selected.length - this.maxSelectedDisplay;
}

get hasMoreItems() {
return this.remainingCount > 0;
}

@action
removeItem(item: any, event?: MouseEvent) {
event?.stopPropagation();
Expand All @@ -51,6 +83,14 @@ export default class PickerLabeledTrigger extends Component<TriggerLabeledSignat
this.args.select.actions.select(newSelected);
}

@action
openDropdown(event: MouseEvent) {
event.stopPropagation();
if (!this.args.select.isOpen) {
this.args.select.actions.open(event);
}
}

get select(): ExtendedSelect {
return {
...this.args.select,
Expand Down Expand Up @@ -86,7 +126,7 @@ export default class PickerLabeledTrigger extends Component<TriggerLabeledSignat
)
as |SelectedComponent|
}}
{{#each @select.selected as |item|}}
{{#each this.displayedItems as |item|}}
<SelectedComponent
@option={{item}}
@select={{this.select}}
Expand All @@ -95,6 +135,18 @@ export default class PickerLabeledTrigger extends Component<TriggerLabeledSignat
{{yield option select}}
</SelectedComponent>
{{/each}}
{{#if this.hasMoreItems}}
<div
class='picker-more-items'
role='button'
tabindex='0'
data-test-boxel-picker-more-items
{{on 'click' this.openDropdown}}
>
+{{this.remainingCount}}
more
</div>
{{/if}}
{{/let}}
{{/if}}
</div>
Expand Down Expand Up @@ -149,6 +201,26 @@ export default class PickerLabeledTrigger extends Component<TriggerLabeledSignat
transform: rotate(180deg);
}

.picker-more-items {
display: flex;
align-items: center;
gap: var(--boxel-sp-4xs);
padding: var(--boxel-sp-4xs);
border-radius: var(--boxel-border-radius-xs);
border: solid 1px var(--boxel-300);
background-color: var(--boxel-300);
min-height: 30px;
font: 500 var(--boxel-font-xs);
letter-spacing: var(--boxel-lsp-sm);
cursor: pointer;
user-select: none;
}

.picker-more-items:hover {
background-color: var(--boxel-400);
border-color: var(--boxel-400);
}

/*Ember power select has a right padding to the trigger element*/
:global(.ember-power-select-trigger) {
padding: 0px;
Expand Down
13 changes: 13 additions & 0 deletions packages/boxel-ui/addon/src/components/picker/usage.gts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ export default class PickerUsage extends Component {
@placeholder='Select types'
/>
</div>

<div class='picker-usage-example'>
<h3>Realm Picker (with max display limit of 2)</h3>
<Picker
@options={{this.realmOptions}}
@selected={{this.selectedRealms}}
@onChange={{this.onRealmChange}}
@label='Realm'
@placeholder='Select realms'
@searchPlaceholder='search for a realm'
@maxSelectedDisplay={{2}}
/>
</div>
</div>
</:example>
</FreestyleUsage>
Expand Down
Loading