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