// 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 { private searchText: string = ''; view({attrs}: m.CVnode) { 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(); }, }); }); } }