• 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';
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