• 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 {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