| // 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'; | 
 |  | 
 | 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 & { | 
 |   minimal?: boolean; | 
 |   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, | 
 |                   minimal: true, | 
 |                   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, | 
 |                 minimal: true, | 
 |                 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, | 
 |                 minimal: true, | 
 |                 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', | 
 |         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, | 
 |         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, minimal, compact} = attrs; | 
 |  | 
 |     return m( | 
 |       Popup, | 
 |       { | 
 |         trigger: m(Button, { | 
 |           label: this.labelText(attrs), | 
 |           icon, | 
 |           minimal, | 
 |           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; | 
 |     } | 
 |   } | 
 | } |