1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {globals} from '../globals'; 17import {DESELECT, SELECT_ALL} from '../icons'; 18import {Button} from './button'; 19import {Checkbox} from './checkbox'; 20import {EmptyState} from './empty_state'; 21import {Popup, PopupPosition} from './popup'; 22import {TextInput} from './text_input'; 23 24export interface Option { 25 // The ID is used to indentify this option, and is used in callbacks. 26 id: string; 27 // This is the name displayed and used for searching. 28 name: string; 29 // Whether the option is selected or not. 30 checked: boolean; 31} 32 33export interface MultiSelectDiff { 34 id: string; 35 checked: boolean; 36} 37 38export interface MultiSelectAttrs { 39 icon?: string; 40 label: string; 41 options: Option[]; 42 onChange?: (diffs: MultiSelectDiff[]) => void; 43 repeatCheckedItemsAtTop?: boolean; 44 showNumSelected?: boolean; 45 popupPosition?: PopupPosition; 46} 47 48// A component which shows a list of items with checkboxes, allowing the user to 49// select from the list which ones they want to be selected. 50// Also provides search functionality. 51// This component is entirely controlled and callbacks must be supplied for when 52// the selected items changes, and when the search term changes. 53// There is an optional boolean flag to enable repeating the selected items at 54// the top of the list for easy access - defaults to false. 55export class MultiSelect implements m.ClassComponent<MultiSelectAttrs> { 56 private searchText: string = ''; 57 view({attrs}: m.CVnode<MultiSelectAttrs>) { 58 const { 59 icon, 60 popupPosition = PopupPosition.Auto, 61 } = attrs; 62 63 return m( 64 Popup, 65 { 66 trigger: m(Button, {label: this.labelText(attrs), icon}), 67 position: popupPosition, 68 }, 69 this.renderPopup(attrs), 70 ); 71 } 72 73 private labelText(attrs: MultiSelectAttrs): string { 74 const { 75 options, 76 showNumSelected, 77 label, 78 } = attrs; 79 80 if (showNumSelected) { 81 const numSelected = options.filter(({checked}) => checked).length; 82 return `${label} (${numSelected} selected)`; 83 } else { 84 return label; 85 } 86 } 87 88 private renderPopup(attrs: MultiSelectAttrs) { 89 const { 90 options, 91 } = attrs; 92 93 const filteredItems = options.filter(({name}) => { 94 return name.toLowerCase().includes(this.searchText.toLowerCase()); 95 }); 96 97 return m( 98 '.pf-multiselect-popup', 99 this.renderSearchBox(), 100 this.renderListOfItems(attrs, filteredItems), 101 ); 102 } 103 104 private renderListOfItems(attrs: MultiSelectAttrs, options: Option[]) { 105 const { 106 repeatCheckedItemsAtTop, 107 onChange = () => {}, 108 } = attrs; 109 const allChecked = options.every(({checked}) => checked); 110 const anyChecked = options.some(({checked}) => checked); 111 112 if (options.length === 0) { 113 return m(EmptyState, { 114 header: `No results for '${this.searchText}'`, 115 }); 116 } else { 117 return [m( 118 '.pf-list', 119 repeatCheckedItemsAtTop && anyChecked && 120 m( 121 '.pf-multiselect-container', 122 m( 123 '.pf-multiselect-header', 124 m('span', 125 this.searchText === '' ? 'Selected' : 126 `Selected (Filtered)`), 127 m(Button, { 128 label: this.searchText === '' ? 'Clear All' : 129 'Clear Filtered', 130 icon: DESELECT, 131 minimal: true, 132 onclick: () => { 133 const diffs = 134 options.filter(({checked}) => checked) 135 .map(({id}) => ({id, checked: false})); 136 onChange(diffs); 137 globals.rafScheduler.scheduleFullRedraw(); 138 }, 139 disabled: !anyChecked, 140 }), 141 ), 142 this.renderOptions( 143 attrs, options.filter(({checked}) => checked)), 144 ), 145 m( 146 '.pf-multiselect-container', 147 m( 148 '.pf-multiselect-header', 149 m('span', 150 this.searchText === '' ? 'Options' : `Options (Filtered)`), 151 m(Button, { 152 label: this.searchText === '' ? 'Select All' : 153 'Select Filtered', 154 icon: SELECT_ALL, 155 minimal: true, 156 compact: true, 157 onclick: () => { 158 const diffs = options.filter(({checked}) => !checked) 159 .map(({id}) => ({id, checked: true})); 160 onChange(diffs); 161 globals.rafScheduler.scheduleFullRedraw(); 162 }, 163 disabled: allChecked, 164 }), 165 m(Button, { 166 label: this.searchText === '' ? 'Clear All' : 167 'Clear Filtered', 168 icon: DESELECT, 169 minimal: true, 170 compact: true, 171 onclick: () => { 172 const diffs = options.filter(({checked}) => checked) 173 .map(({id}) => ({id, checked: false})); 174 onChange(diffs); 175 globals.rafScheduler.scheduleFullRedraw(); 176 }, 177 disabled: !anyChecked, 178 }), 179 ), 180 this.renderOptions(attrs, options), 181 ), 182 )]; 183 } 184 } 185 186 private renderSearchBox() { 187 return m( 188 '.pf-search-bar', 189 m(TextInput, { 190 oninput: (event: Event) => { 191 const eventTarget = event.target as HTMLTextAreaElement; 192 this.searchText = eventTarget.value; 193 globals.rafScheduler.scheduleFullRedraw(); 194 }, 195 value: this.searchText, 196 placeholder: 'Filter options...', 197 extraClasses: 'pf-search-box', 198 }), 199 this.renderClearButton(), 200 ); 201 } 202 203 private renderClearButton() { 204 if (this.searchText != '') { 205 return m(Button, { 206 onclick: () => { 207 this.searchText = ''; 208 globals.rafScheduler.scheduleFullRedraw(); 209 }, 210 label: '', 211 icon: 'close', 212 minimal: true, 213 }); 214 } else { 215 return null; 216 } 217 } 218 219 private renderOptions(attrs: MultiSelectAttrs, options: Option[]) { 220 const { 221 onChange = () => {}, 222 } = attrs; 223 224 return options.map((item) => { 225 const {checked, name, id} = item; 226 return m(Checkbox, { 227 label: name, 228 key: id, // Prevents transitions jumping between items when searching 229 checked, 230 classes: 'pf-multiselect-item', 231 onchange: () => { 232 onChange([{id, checked: !checked}]); 233 globals.rafScheduler.scheduleFullRedraw(); 234 }, 235 }); 236 }); 237 } 238} 239