xref: /aosp_15_r20/external/perfetto/ui/src/widgets/multiselect.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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