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