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 {Icons} from '../base/semantic_icons'; 17import {Button} from './button'; 18import {Checkbox} from './checkbox'; 19import {EmptyState} from './empty_state'; 20import {Popup, PopupPosition} from './popup'; 21import {scheduleFullRedraw} from './raf'; 22import {TextInput} from './text_input'; 23import {Intent} from './common'; 24 25export interface Option { 26 // The ID is used to indentify this option, and is used in callbacks. 27 id: string; 28 // This is the name displayed and used for searching. 29 name: string; 30 // Whether the option is selected or not. 31 checked: boolean; 32} 33 34export interface MultiSelectDiff { 35 id: string; 36 checked: boolean; 37} 38 39export interface MultiSelectAttrs { 40 options: Option[]; 41 onChange?: (diffs: MultiSelectDiff[]) => void; 42 repeatCheckedItemsAtTop?: boolean; 43 showNumSelected?: boolean; 44 fixedSize?: boolean; 45} 46 47export type PopupMultiSelectAttrs = MultiSelectAttrs & { 48 intent?: Intent; 49 compact?: boolean; 50 icon?: string; 51 label: string; 52 popupPosition?: PopupPosition; 53}; 54 55// A component which shows a list of items with checkboxes, allowing the user to 56// select from the list which ones they want to be selected. 57// Also provides search functionality. 58// This component is entirely controlled and callbacks must be supplied for when 59// the selected items list changes, and when the search term changes. 60// There is an optional boolean flag to enable repeating the selected items at 61// the top of the list for easy access - defaults to false. 62export class MultiSelect implements m.ClassComponent<MultiSelectAttrs> { 63 private searchText: string = ''; 64 65 view({attrs}: m.CVnode<MultiSelectAttrs>) { 66 const {options, fixedSize = true} = attrs; 67 68 const filteredItems = options.filter(({name}) => { 69 return name.toLowerCase().includes(this.searchText.toLowerCase()); 70 }); 71 72 return m( 73 fixedSize 74 ? '.pf-multiselect-panel.pf-multi-select-fixed-size' 75 : '.pf-multiselect-panel', 76 this.renderSearchBox(), 77 this.renderListOfItems(attrs, filteredItems), 78 ); 79 } 80 81 private renderListOfItems(attrs: MultiSelectAttrs, options: Option[]) { 82 const {repeatCheckedItemsAtTop, onChange = () => {}} = attrs; 83 const allChecked = options.every(({checked}) => checked); 84 const anyChecked = options.some(({checked}) => checked); 85 86 if (options.length === 0) { 87 return m(EmptyState, { 88 title: `No results for '${this.searchText}'`, 89 }); 90 } else { 91 return [ 92 m( 93 '.pf-list', 94 repeatCheckedItemsAtTop && 95 anyChecked && 96 m( 97 '.pf-multiselect-container', 98 m( 99 '.pf-multiselect-header', 100 m( 101 'span', 102 this.searchText === '' ? 'Selected' : `Selected (Filtered)`, 103 ), 104 m(Button, { 105 label: 106 this.searchText === '' ? 'Clear All' : 'Clear Filtered', 107 icon: Icons.Deselect, 108 onclick: () => { 109 const diffs = options 110 .filter(({checked}) => checked) 111 .map(({id}) => ({id, checked: false})); 112 onChange(diffs); 113 scheduleFullRedraw(); 114 }, 115 disabled: !anyChecked, 116 }), 117 ), 118 this.renderOptions( 119 attrs, 120 options.filter(({checked}) => checked), 121 ), 122 ), 123 m( 124 '.pf-multiselect-container', 125 m( 126 '.pf-multiselect-header', 127 m( 128 'span', 129 this.searchText === '' ? 'Options' : `Options (Filtered)`, 130 ), 131 m(Button, { 132 label: 133 this.searchText === '' ? 'Select All' : 'Select Filtered', 134 icon: Icons.SelectAll, 135 compact: true, 136 onclick: () => { 137 const diffs = options 138 .filter(({checked}) => !checked) 139 .map(({id}) => ({id, checked: true})); 140 onChange(diffs); 141 scheduleFullRedraw(); 142 }, 143 disabled: allChecked, 144 }), 145 m(Button, { 146 label: this.searchText === '' ? 'Clear All' : 'Clear Filtered', 147 icon: Icons.Deselect, 148 compact: true, 149 onclick: () => { 150 const diffs = options 151 .filter(({checked}) => checked) 152 .map(({id}) => ({id, checked: false})); 153 onChange(diffs); 154 scheduleFullRedraw(); 155 }, 156 disabled: !anyChecked, 157 }), 158 ), 159 this.renderOptions(attrs, options), 160 ), 161 ), 162 ]; 163 } 164 } 165 166 private renderSearchBox() { 167 return m( 168 '.pf-search-bar', 169 m(TextInput, { 170 oninput: (event: Event) => { 171 const eventTarget = event.target as HTMLTextAreaElement; 172 this.searchText = eventTarget.value; 173 scheduleFullRedraw(); 174 }, 175 value: this.searchText, 176 placeholder: 'Filter options...', 177 className: 'pf-search-box', 178 }), 179 this.renderClearButton(), 180 ); 181 } 182 183 private renderClearButton() { 184 if (this.searchText != '') { 185 return m(Button, { 186 onclick: () => { 187 this.searchText = ''; 188 scheduleFullRedraw(); 189 }, 190 label: '', 191 icon: 'close', 192 }); 193 } else { 194 return null; 195 } 196 } 197 198 private renderOptions(attrs: MultiSelectAttrs, options: Option[]) { 199 const {onChange = () => {}} = attrs; 200 201 return options.map((item) => { 202 const {checked, name, id} = item; 203 return m(Checkbox, { 204 label: name, 205 key: id, // Prevents transitions jumping between items when searching 206 checked, 207 className: 'pf-multiselect-item', 208 onchange: () => { 209 onChange([{id, checked: !checked}]); 210 scheduleFullRedraw(); 211 }, 212 }); 213 }); 214 } 215} 216 217// The same multi-select component that functions as a drop-down instead of 218// a list. 219export class PopupMultiSelect 220 implements m.ClassComponent<PopupMultiSelectAttrs> 221{ 222 view({attrs}: m.CVnode<PopupMultiSelectAttrs>) { 223 const {icon, popupPosition = PopupPosition.Auto, intent, compact} = attrs; 224 225 return m( 226 Popup, 227 { 228 trigger: m(Button, { 229 label: this.labelText(attrs), 230 icon, 231 intent, 232 compact, 233 }), 234 position: popupPosition, 235 }, 236 m(MultiSelect, attrs as MultiSelectAttrs), 237 ); 238 } 239 240 private labelText(attrs: PopupMultiSelectAttrs): string { 241 const {options, showNumSelected, label} = attrs; 242 243 if (showNumSelected) { 244 const numSelected = options.filter(({checked}) => checked).length; 245 return `${label} (${numSelected} selected)`; 246 } else { 247 return label; 248 } 249 } 250} 251