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