blob: a91c8eed480c1fa0dfa499463f5faecb09ec9b28 [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 {Icons} from '../base/semantic_icons';
import {Button} from './button';
import {Checkbox} from './checkbox';
import {EmptyState} from './empty_state';
import {Popup, PopupPosition} from './popup';
import {scheduleFullRedraw} from './raf';
import {TextInput} from './text_input';
import {Intent} from './common';
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 {
options: Option[];
onChange?: (diffs: MultiSelectDiff[]) => void;
repeatCheckedItemsAtTop?: boolean;
showNumSelected?: boolean;
fixedSize?: boolean;
}
export type PopupMultiSelectAttrs = MultiSelectAttrs & {
intent?: Intent;
compact?: boolean;
icon?: string;
label: string;
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 list 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 {options, fixedSize = true} = attrs;
const filteredItems = options.filter(({name}) => {
return name.toLowerCase().includes(this.searchText.toLowerCase());
});
return m(
fixedSize
? '.pf-multiselect-panel.pf-multi-select-fixed-size'
: '.pf-multiselect-panel',
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, {
title: `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: Icons.Deselect,
onclick: () => {
const diffs = options
.filter(({checked}) => checked)
.map(({id}) => ({id, checked: false}));
onChange(diffs);
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: Icons.SelectAll,
compact: true,
onclick: () => {
const diffs = options
.filter(({checked}) => !checked)
.map(({id}) => ({id, checked: true}));
onChange(diffs);
scheduleFullRedraw();
},
disabled: allChecked,
}),
m(Button, {
label: this.searchText === '' ? 'Clear All' : 'Clear Filtered',
icon: Icons.Deselect,
compact: true,
onclick: () => {
const diffs = options
.filter(({checked}) => checked)
.map(({id}) => ({id, checked: false}));
onChange(diffs);
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;
scheduleFullRedraw();
},
value: this.searchText,
placeholder: 'Filter options...',
className: 'pf-search-box',
}),
this.renderClearButton(),
);
}
private renderClearButton() {
if (this.searchText != '') {
return m(Button, {
onclick: () => {
this.searchText = '';
scheduleFullRedraw();
},
label: '',
icon: 'close',
});
} 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,
className: 'pf-multiselect-item',
onchange: () => {
onChange([{id, checked: !checked}]);
scheduleFullRedraw();
},
});
});
}
}
// The same multi-select component that functions as a drop-down instead of
// a list.
export class PopupMultiSelect
implements m.ClassComponent<PopupMultiSelectAttrs>
{
view({attrs}: m.CVnode<PopupMultiSelectAttrs>) {
const {icon, popupPosition = PopupPosition.Auto, intent, compact} = attrs;
return m(
Popup,
{
trigger: m(Button, {
label: this.labelText(attrs),
icon,
intent,
compact,
}),
position: popupPosition,
},
m(MultiSelect, attrs as MultiSelectAttrs),
);
}
private labelText(attrs: PopupMultiSelectAttrs): string {
const {options, showNumSelected, label} = attrs;
if (showNumSelected) {
const numSelected = options.filter(({checked}) => checked).length;
return `${label} (${numSelected} selected)`;
} else {
return label;
}
}
}