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 {TextInput} from './text_input'; 22import {Intent} from './common'; 23 24export interface MultiSelectOption { 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 options: MultiSelectOption[]; 40 onChange?: (diffs: MultiSelectDiff[]) => void; 41 repeatCheckedItemsAtTop?: boolean; 42 showNumSelected?: boolean; 43 fixedSize?: boolean; 44 readonly showSelectAllButton?: 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( 82 attrs: MultiSelectAttrs, 83 options: MultiSelectOption[], 84 ) { 85 const { 86 repeatCheckedItemsAtTop, 87 onChange = () => {}, 88 showSelectAllButton = true, 89 } = attrs; 90 const allChecked = options.every(({checked}) => checked); 91 const anyChecked = options.some(({checked}) => checked); 92 93 if (options.length === 0) { 94 return m(EmptyState, { 95 title: `No results for '${this.searchText}'`, 96 }); 97 } else { 98 return [ 99 m( 100 '.pf-list', 101 repeatCheckedItemsAtTop && 102 anyChecked && 103 m( 104 '.pf-multiselect-container', 105 m( 106 '.pf-multiselect-header', 107 m( 108 'span', 109 this.searchText === '' ? 'Selected' : `Selected (Filtered)`, 110 ), 111 m(Button, { 112 label: 113 this.searchText === '' ? 'Clear All' : 'Clear Filtered', 114 icon: Icons.Deselect, 115 onclick: () => { 116 const diffs = options 117 .filter(({checked}) => checked) 118 .map(({id}) => ({id, checked: false})); 119 onChange(diffs); 120 }, 121 disabled: !anyChecked, 122 }), 123 ), 124 this.renderOptions( 125 attrs, 126 options.filter(({checked}) => checked), 127 ), 128 ), 129 m( 130 '.pf-multiselect-container', 131 m( 132 '.pf-multiselect-header', 133 m( 134 'span', 135 this.searchText === '' ? 'Options' : `Options (Filtered)`, 136 ), 137 showSelectAllButton && 138 m(Button, { 139 label: 140 this.searchText === '' ? 'Select All' : 'Select Filtered', 141 icon: Icons.SelectAll, 142 compact: true, 143 onclick: () => { 144 const diffs = options 145 .filter(({checked}) => !checked) 146 .map(({id}) => ({id, checked: true})); 147 onChange(diffs); 148 }, 149 disabled: allChecked, 150 }), 151 m(Button, { 152 label: this.searchText === '' ? 'Clear All' : 'Clear Filtered', 153 icon: Icons.Deselect, 154 compact: true, 155 onclick: () => { 156 const diffs = options 157 .filter(({checked}) => checked) 158 .map(({id}) => ({id, checked: false})); 159 onChange(diffs); 160 }, 161 disabled: !anyChecked, 162 }), 163 ), 164 this.renderOptions(attrs, options), 165 ), 166 ), 167 ]; 168 } 169 } 170 171 private renderSearchBox() { 172 return m( 173 '.pf-search-bar', 174 m(TextInput, { 175 oninput: (event: Event) => { 176 const eventTarget = event.target as HTMLTextAreaElement; 177 this.searchText = eventTarget.value; 178 }, 179 value: this.searchText, 180 placeholder: 'Filter options...', 181 className: 'pf-search-box', 182 }), 183 this.renderClearButton(), 184 ); 185 } 186 187 private renderClearButton() { 188 if (this.searchText != '') { 189 return m(Button, { 190 onclick: () => { 191 this.searchText = ''; 192 }, 193 label: '', 194 icon: 'close', 195 }); 196 } else { 197 return null; 198 } 199 } 200 201 private renderOptions(attrs: MultiSelectAttrs, options: MultiSelectOption[]) { 202 const {onChange = () => {}} = attrs; 203 204 return options.map((item) => { 205 const {checked, name, id} = item; 206 return m(Checkbox, { 207 label: name, 208 key: id, // Prevents transitions jumping between items when searching 209 checked, 210 className: 'pf-multiselect-item', 211 onchange: () => { 212 onChange([{id, checked: !checked}]); 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