blob: 77debb2dec8f253042a579bfbb744ac05f583ff5 [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {globals} from '../globals';
import {DESELECT, SELECT_ALL} from '../icons';
import {Button} from './button';
import {Checkbox} from './checkbox';
import {EmptyState} from './empty_state';
import {Popup, PopupPosition} from './popup';
import {TextInput} from './text_input';
export interface Option {
// The ID is used to indentify this option, and is used in callbacks.
id: string;
// This is the name displayed and used for searching.
name: string;
// Whether the option is selected or not.
checked: boolean;
}
export interface MultiSelectDiff {
id: string;
checked: boolean;
}
export interface MultiSelectAttrs {
icon?: string;
label: string;
options: Option[];
onChange?: (diffs: MultiSelectDiff[]) => void;
repeatCheckedItemsAtTop?: boolean;
showNumSelected?: boolean;
popupPosition?: PopupPosition;
}
// A component which shows a list of items with checkboxes, allowing the user to
// select from the list which ones they want to be selected.
// Also provides search functionality.
// This component is entirely controlled and callbacks must be supplied for when
// the selected items changes, and when the search term changes.
// There is an optional boolean flag to enable repeating the selected items at
// the top of the list for easy access - defaults to false.
export class MultiSelect implements m.ClassComponent<MultiSelectAttrs> {
private searchText: string = '';
view({attrs}: m.CVnode<MultiSelectAttrs>) {
const {
icon,
popupPosition = PopupPosition.Auto,
} = attrs;
return m(
Popup,
{
trigger: m(Button, {label: this.labelText(attrs), icon}),
position: popupPosition,
},
this.renderPopup(attrs),
);
}
private labelText(attrs: MultiSelectAttrs): string {
const {
options,
showNumSelected,
label,
} = attrs;
if (showNumSelected) {
const numSelected = options.filter(({checked}) => checked).length;
return `${label} (${numSelected} selected)`;
} else {
return label;
}
}
private renderPopup(attrs: MultiSelectAttrs) {
const {
options,
} = attrs;
const filteredItems = options.filter(({name}) => {
return name.toLowerCase().includes(this.searchText.toLowerCase());
});
return m(
'.pf-multiselect-popup',
this.renderSearchBox(),
this.renderListOfItems(attrs, filteredItems),
);
}
private renderListOfItems(attrs: MultiSelectAttrs, options: Option[]) {
const {
repeatCheckedItemsAtTop,
onChange = () => {},
} = attrs;
const allChecked = options.every(({checked}) => checked);
const anyChecked = options.some(({checked}) => checked);
if (options.length === 0) {
return m(EmptyState, {
header: `No results for '${this.searchText}'`,
});
} else {
return [m(
'.pf-list',
repeatCheckedItemsAtTop && anyChecked &&
m(
'.pf-multiselect-container',
m(
'.pf-multiselect-header',
m('span',
this.searchText === '' ? 'Selected' :
`Selected (Filtered)`),
m(Button, {
label: this.searchText === '' ? 'Clear All' :
'Clear Filtered',
icon: DESELECT,
minimal: true,
onclick: () => {
const diffs =
options.filter(({checked}) => checked)
.map(({id}) => ({id, checked: false}));
onChange(diffs);
globals.rafScheduler.scheduleFullRedraw();
},
disabled: !anyChecked,
}),
),
this.renderOptions(
attrs, options.filter(({checked}) => checked)),
),
m(
'.pf-multiselect-container',
m(
'.pf-multiselect-header',
m('span',
this.searchText === '' ? 'Options' : `Options (Filtered)`),
m(Button, {
label: this.searchText === '' ? 'Select All' :
'Select Filtered',
icon: SELECT_ALL,
minimal: true,
compact: true,
onclick: () => {
const diffs = options.filter(({checked}) => !checked)
.map(({id}) => ({id, checked: true}));
onChange(diffs);
globals.rafScheduler.scheduleFullRedraw();
},
disabled: allChecked,
}),
m(Button, {
label: this.searchText === '' ? 'Clear All' :
'Clear Filtered',
icon: DESELECT,
minimal: true,
compact: true,
onclick: () => {
const diffs = options.filter(({checked}) => checked)
.map(({id}) => ({id, checked: false}));
onChange(diffs);
globals.rafScheduler.scheduleFullRedraw();
},
disabled: !anyChecked,
}),
),
this.renderOptions(attrs, options),
),
)];
}
}
private renderSearchBox() {
return m(
'.pf-search-bar',
m(TextInput, {
oninput: (event: Event) => {
const eventTarget = event.target as HTMLTextAreaElement;
this.searchText = eventTarget.value;
globals.rafScheduler.scheduleFullRedraw();
},
value: this.searchText,
placeholder: 'Filter options...',
extraClasses: 'pf-search-box',
}),
this.renderClearButton(),
);
}
private renderClearButton() {
if (this.searchText != '') {
return m(Button, {
onclick: () => {
this.searchText = '';
globals.rafScheduler.scheduleFullRedraw();
},
label: '',
icon: 'close',
minimal: true,
});
} else {
return null;
}
}
private renderOptions(attrs: MultiSelectAttrs, options: Option[]) {
const {
onChange = () => {},
} = attrs;
return options.map((item) => {
const {checked, name, id} = item;
return m(Checkbox, {
label: name,
key: id, // Prevents transitions jumping between items when searching
checked,
classes: 'pf-multiselect-item',
onchange: () => {
onChange([{id, checked: !checked}]);
globals.rafScheduler.scheduleFullRedraw();
},
});
});
}
}