Skip to content
Merged
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
180 changes: 172 additions & 8 deletions packages/boxel-ui/addon/src/components/picker/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,45 @@ import type Owner from '@ember/owner';
import { scheduleOnce } from '@ember/runloop';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type { ComponentLike } from '@glint/template';
import { modifier } from 'ember-modifier';
import type { Select } from 'ember-power-select/components/power-select';
import { includes } from 'lodash';

import type { Icon } from '../../icons/types.ts';
import LoadingIndicator from '../loading-indicator/index.gts';
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 = {
disabled?: boolean;
icon?: Icon | string;
id: string;
label: string;
shortLabel?: string;
tooltip?: string;
type?: 'select-all' | 'option';
};

export interface PickerSignature {
Args: {
// State
afterOptionsComponent?: ComponentLike<any>;
disableClientSideSearch?: boolean;
disabled?: boolean;
// Display
extra?: Record<string, unknown>;
hasMore?: boolean;
isLoading?: boolean;
isLoadingMore?: boolean;
label: string;
matchTriggerWidth?: boolean;
maxSelectedDisplay?: number;

onChange: (selected: PickerOption[]) => void;
// Data
onLoadMore?: () => void;
onSearchTermChange?: (term: string) => void;
options: PickerOption[];

placeholder?: string;

renderInPlace?: boolean;
searchPlaceholder?: string;
selected: PickerOption[];
Expand All @@ -44,6 +51,83 @@ export interface PickerSignature {
Element: HTMLElement;
}

let loadMoreSentinel = modifier(
(
element: Element,
[onLoadMore, isLoadingMore]: [
(() => void) | undefined,
boolean | undefined,
],
{ enabled }: { enabled?: boolean },
) => {
if (!enabled || !onLoadMore) {
return;
}

let optionsList = element
.closest('.ember-basic-dropdown-content')
?.querySelector('.ember-power-select-options') as HTMLElement | null;
if (!optionsList) {
return;
}

let alreadyRequested = false;
let handleScroll = () => {
if (isLoadingMore || alreadyRequested) {
return;
}
let { scrollTop, scrollHeight, clientHeight } = optionsList;
if (scrollTop + clientHeight >= scrollHeight - 50) {
Comment thread
FadhlanR marked this conversation as resolved.
alreadyRequested = true;
onLoadMore();
}
};

optionsList.addEventListener('scroll', handleScroll);

// Check immediately: if the list is short enough to fit without
// scrolling, we're already at the "bottom" and should load more.
requestAnimationFrame(() => handleScroll());

return () => optionsList!.removeEventListener('scroll', handleScroll);
Comment thread
FadhlanR marked this conversation as resolved.
},
);

interface PickerAfterOptionsSignature {
Args: {
extra?: Record<string, any>;
select: Record<string, any>;
};
}

class PickerLoadingOverlay extends Component<PickerAfterOptionsSignature> {
get isLoading(): boolean {
return !!this.args.extra?.['isLoading'];
}

<template>
{{#if this.isLoading}}
<div class='picker-full-loading-overlay' data-test-picker-loading>
<LoadingIndicator class='picker-full-loading-spinner' />
</div>
{{/if}}

{{! template-lint-disable require-scoped-style }}
<style>
.picker-full-loading-overlay {
display: flex;
align-items: center;
justify-content: center;
background: var(--boxel-light);
}
Comment thread
FadhlanR marked this conversation as resolved.
.picker-full-loading-spinner {
width: 24px;
height: 24px;
}
</style>
</template>
}

export default class Picker extends Component<PickerSignature> {
@tracked searchTerm = '';
@tracked private pinnedOption: PickerOption | null = null;
Expand Down Expand Up @@ -86,7 +170,7 @@ export default class Picker extends Component<PickerSignature> {
// - 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) {
if (!this.searchTerm || this.args.disableClientSideSearch) {
return this.args.options;
}

Expand Down Expand Up @@ -134,6 +218,7 @@ export default class Picker extends Component<PickerSignature> {
});

const selectAll = options.filter((o) => o.type === 'select-all');

return [...selectAll, ...selected, ...unselected];
}

Expand Down Expand Up @@ -161,6 +246,11 @@ export default class Picker extends Component<PickerSignature> {
return lastSelected === option;
};

isLastOption = (option: PickerOption): boolean => {
const sorted = this.sortedOptions;
return sorted.length > 0 && sorted[sorted.length - 1] === option;
};

get hasUnselected() {
const unselected = this.sortedOptions.filter(
(o) => o.type !== 'select-all' && !this.isVisuallyInSelectedSection(o),
Expand All @@ -174,6 +264,7 @@ export default class Picker extends Component<PickerSignature> {

onSearchTermChange = (term: string) => {
this.searchTerm = term;
this.args.onSearchTermChange?.(term);
};

onOptionHover = (option: PickerOption | null) => {
Expand All @@ -198,15 +289,41 @@ export default class Picker extends Component<PickerSignature> {

get extra() {
return {
...this.args.extra,
label: this.args.label,
searchTerm: this.searchTerm,
searchPlaceholder: this.args.searchPlaceholder,
onSearchTermChange: this.onSearchTermChange,
maxSelectedDisplay: this.args.maxSelectedDisplay,
isLoading: this.args.isLoading,
};
}

get dropdownClass(): string {
let cls = 'boxel-picker__dropdown';
if (this.args.isLoading) {
cls += ' boxel-picker__dropdown--loading';
}
return cls;
}

get afterOptionsComponent(): ComponentLike<any> | undefined {
if (this.args.afterOptionsComponent) {
return this.args.afterOptionsComponent;
}
if (this.args.isLoading) {
return PickerLoadingOverlay;
}
return undefined;
}

onChange = (selected: PickerOption[]) => {
// Ignore clicks on disabled options
const lastAdded = selected.find((opt) => !this.args.selected.includes(opt));
if (lastAdded?.disabled) {
return;
}

const selectAllOptions = selected.filter((option) => {
return option.type === 'select-all';
});
Expand Down Expand Up @@ -276,7 +393,8 @@ export default class Picker extends Component<PickerSignature> {
@extra={{this.extra}}
@triggerComponent={{component this.triggerComponent}}
@beforeOptionsComponent={{component PickerBeforeOptionsWithSearch}}
@dropdownClass='boxel-picker__dropdown'
@afterOptionsComponent={{this.afterOptionsComponent}}
@dropdownClass={{this.dropdownClass}}
...attributes
as |option|
>
Expand All @@ -290,6 +408,21 @@ export default class Picker extends Component<PickerSignature> {
{{#if (this.displayDivider option)}}
<div class='picker-divider' data-test-boxel-picker-divider></div>
{{/if}}
{{#if (this.isLastOption option)}}
{{#if @hasMore}}
<div
class='picker-load-more-sentinel'
{{loadMoreSentinel @onLoadMore @isLoadingMore enabled=@hasMore}}
data-test-picker-infinite-scroll
>
{{#if @isLoadingMore}}
<div class='picker-bottom-loading' data-test-picker-loading-more>
<LoadingIndicator class='picker-loading-spinner' />
</div>
{{/if}}
</div>
{{/if}}
{{/if}}
</BoxelMultiSelectBasic>

{{! template-lint-disable require-scoped-style }}
Expand All @@ -301,6 +434,23 @@ export default class Picker extends Component<PickerSignature> {
width: 100%;
}

.boxel-picker__dropdown {
padding-bottom: var(--boxel-sp-3xs);
}

.boxel-picker__dropdown--loading .picker-before-options {
position: relative;
z-index: 2;
}

.boxel-picker__dropdown--loading
.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);
display: flex;
Expand All @@ -316,6 +466,20 @@ export default class Picker extends Component<PickerSignature> {
.fitted-template :deep(.ember-basic-dropdown-content-wormhole-origin) {
position: absolute;
}

.picker-load-more-sentinel {
min-height: 1px;
}
.picker-bottom-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--boxel-sp-xxs) 0;
}
.picker-loading-spinner {
width: 20px;
height: 20px;
}
</style>
</template>
}
41 changes: 39 additions & 2 deletions packages/boxel-ui/addon/src/components/picker/option-row.gts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cn, sanitizeHtmlSafe } from '../../helpers.ts';
import CheckMark from '../../icons/check-mark.gts';
import SelectAll from '../../icons/select-all.gts';
import type { Icon } from '../../icons/types.ts';
import Tooltip from '../tooltip/index.gts';
import type { PickerOption } from './index.gts';

export interface OptionRowSignature {
Expand Down Expand Up @@ -58,9 +59,19 @@ export default class PickerOptionRow extends Component<OptionRowSignature> {

<template>
<div
class={{cn 'picker-option-row' picker-option-row--selected=@isSelected}}
class={{cn
'picker-option-row'
picker-option-row--selected=@isSelected
picker-option-row--disabled=@option.disabled
}}
data-test-boxel-picker-option-selected={{if @isSelected 'true' 'false'}}
data-test-boxel-picker-option-disabled={{if
@option.disabled
'true'
'false'
}}
data-test-boxel-picker-option-row={{@option.id}}
data-test-boxel-picker-option-label={{@option.label}}
{{on 'mouseenter' this.handleMouseEnter}}
{{on 'mouseleave' this.handleMouseLeave}}
>
Expand Down Expand Up @@ -107,7 +118,18 @@ export default class PickerOptionRow extends Component<OptionRowSignature> {
{{/if}}
</div>
{{/if}}
<div class='picker-option-row__label'>{{@option.label}}</div>
{{#if @option.tooltip}}
<Tooltip @placement='right' class='picker-option-row__tooltip-trigger'>
<:trigger>
<div class='picker-option-row__label'>{{@option.label}}</div>
</:trigger>
<:content>
{{@option.tooltip}}
</:content>
</Tooltip>
{{else}}
<div class='picker-option-row__label'>{{@option.label}}</div>
{{/if}}
</div>

<style scoped>
Expand All @@ -127,6 +149,12 @@ export default class PickerOptionRow extends Component<OptionRowSignature> {
border-radius: 4px;
}

.picker-option-row--disabled {
opacity: 0.4;
pointer-events: none;
cursor: default;
}

.picker-option-row__checkbox {
width: 16px;
height: 16px;
Expand Down Expand Up @@ -183,6 +211,15 @@ export default class PickerOptionRow extends Component<OptionRowSignature> {
flex-shrink: 0;
}

.picker-option-row__tooltip-trigger {
flex: 1;
min-width: 0;
}

.picker-option-row__tooltip-trigger :deep(.trigger) {
width: 100%;
}

.picker-option-row__label {
flex: 1;
font: var(--boxel-font-sm);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default class PickerSelectedItem extends Component<PickerSelectedItemSign
<Pill
class='picker-selected-item'
@size='small'
data-test-boxel-picker-selected-item
data-test-boxel-picker-selected-item={{this.text}}
>
<:iconLeft>
{{#if this.icon}}
Expand Down
Loading
Loading