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