| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| |
| import {Icons} from '../base/semantic_icons'; |
| |
| import {Button} from './button'; |
| import {Checkbox} from './checkbox'; |
| import {EmptyState} from './empty_state'; |
| import {Popup, PopupPosition} from './popup'; |
| import {scheduleFullRedraw} from './raf'; |
| import {TextInput} from './text_input'; |
| import {Intent} from './common'; |
| |
| export interface Option { |
| // The ID is used to indentify this option, and is used in callbacks. |
| id: string; |
| // This is the name displayed and used for searching. |
| name: string; |
| // Whether the option is selected or not. |
| checked: boolean; |
| } |
| |
| export interface MultiSelectDiff { |
| id: string; |
| checked: boolean; |
| } |
| |
| export interface MultiSelectAttrs { |
| options: Option[]; |
| onChange?: (diffs: MultiSelectDiff[]) => void; |
| repeatCheckedItemsAtTop?: boolean; |
| showNumSelected?: boolean; |
| fixedSize?: boolean; |
| } |
| |
| export type PopupMultiSelectAttrs = MultiSelectAttrs & { |
| intent?: Intent; |
| compact?: boolean; |
| icon?: string; |
| label: string; |
| popupPosition?: PopupPosition; |
| }; |
| |
| // A component which shows a list of items with checkboxes, allowing the user to |
| // select from the list which ones they want to be selected. |
| // Also provides search functionality. |
| // This component is entirely controlled and callbacks must be supplied for when |
| // the selected items list changes, and when the search term changes. |
| // There is an optional boolean flag to enable repeating the selected items at |
| // the top of the list for easy access - defaults to false. |
| export class MultiSelect implements m.ClassComponent<MultiSelectAttrs> { |
| private searchText: string = ''; |
| |
| view({attrs}: m.CVnode<MultiSelectAttrs>) { |
| const {options, fixedSize = true} = attrs; |
| |
| const filteredItems = options.filter(({name}) => { |
| return name.toLowerCase().includes(this.searchText.toLowerCase()); |
| }); |
| |
| return m( |
| fixedSize |
| ? '.pf-multiselect-panel.pf-multi-select-fixed-size' |
| : '.pf-multiselect-panel', |
| this.renderSearchBox(), |
| this.renderListOfItems(attrs, filteredItems), |
| ); |
| } |
| |
| private renderListOfItems(attrs: MultiSelectAttrs, options: Option[]) { |
| const {repeatCheckedItemsAtTop, onChange = () => {}} = attrs; |
| const allChecked = options.every(({checked}) => checked); |
| const anyChecked = options.some(({checked}) => checked); |
| |
| if (options.length === 0) { |
| return m(EmptyState, { |
| title: `No results for '${this.searchText}'`, |
| }); |
| } else { |
| return [ |
| m( |
| '.pf-list', |
| repeatCheckedItemsAtTop && |
| anyChecked && |
| m( |
| '.pf-multiselect-container', |
| m( |
| '.pf-multiselect-header', |
| m( |
| 'span', |
| this.searchText === '' ? 'Selected' : `Selected (Filtered)`, |
| ), |
| m(Button, { |
| label: |
| this.searchText === '' ? 'Clear All' : 'Clear Filtered', |
| icon: Icons.Deselect, |
| onclick: () => { |
| const diffs = options |
| .filter(({checked}) => checked) |
| .map(({id}) => ({id, checked: false})); |
| onChange(diffs); |
| scheduleFullRedraw(); |
| }, |
| disabled: !anyChecked, |
| }), |
| ), |
| this.renderOptions( |
| attrs, |
| options.filter(({checked}) => checked), |
| ), |
| ), |
| m( |
| '.pf-multiselect-container', |
| m( |
| '.pf-multiselect-header', |
| m( |
| 'span', |
| this.searchText === '' ? 'Options' : `Options (Filtered)`, |
| ), |
| m(Button, { |
| label: |
| this.searchText === '' ? 'Select All' : 'Select Filtered', |
| icon: Icons.SelectAll, |
| compact: true, |
| onclick: () => { |
| const diffs = options |
| .filter(({checked}) => !checked) |
| .map(({id}) => ({id, checked: true})); |
| onChange(diffs); |
| scheduleFullRedraw(); |
| }, |
| disabled: allChecked, |
| }), |
| m(Button, { |
| label: this.searchText === '' ? 'Clear All' : 'Clear Filtered', |
| icon: Icons.Deselect, |
| compact: true, |
| onclick: () => { |
| const diffs = options |
| .filter(({checked}) => checked) |
| .map(({id}) => ({id, checked: false})); |
| onChange(diffs); |
| scheduleFullRedraw(); |
| }, |
| disabled: !anyChecked, |
| }), |
| ), |
| this.renderOptions(attrs, options), |
| ), |
| ), |
| ]; |
| } |
| } |
| |
| private renderSearchBox() { |
| return m( |
| '.pf-search-bar', |
| m(TextInput, { |
| oninput: (event: Event) => { |
| const eventTarget = event.target as HTMLTextAreaElement; |
| this.searchText = eventTarget.value; |
| scheduleFullRedraw(); |
| }, |
| value: this.searchText, |
| placeholder: 'Filter options...', |
| className: 'pf-search-box', |
| }), |
| this.renderClearButton(), |
| ); |
| } |
| |
| private renderClearButton() { |
| if (this.searchText != '') { |
| return m(Button, { |
| onclick: () => { |
| this.searchText = ''; |
| scheduleFullRedraw(); |
| }, |
| label: '', |
| icon: 'close', |
| }); |
| } else { |
| return null; |
| } |
| } |
| |
| private renderOptions(attrs: MultiSelectAttrs, options: Option[]) { |
| const {onChange = () => {}} = attrs; |
| |
| return options.map((item) => { |
| const {checked, name, id} = item; |
| return m(Checkbox, { |
| label: name, |
| key: id, // Prevents transitions jumping between items when searching |
| checked, |
| className: 'pf-multiselect-item', |
| onchange: () => { |
| onChange([{id, checked: !checked}]); |
| scheduleFullRedraw(); |
| }, |
| }); |
| }); |
| } |
| } |
| |
| // The same multi-select component that functions as a drop-down instead of |
| // a list. |
| export class PopupMultiSelect |
| implements m.ClassComponent<PopupMultiSelectAttrs> |
| { |
| view({attrs}: m.CVnode<PopupMultiSelectAttrs>) { |
| const {icon, popupPosition = PopupPosition.Auto, intent, compact} = attrs; |
| |
| return m( |
| Popup, |
| { |
| trigger: m(Button, { |
| label: this.labelText(attrs), |
| icon, |
| intent, |
| compact, |
| }), |
| position: popupPosition, |
| }, |
| m(MultiSelect, attrs as MultiSelectAttrs), |
| ); |
| } |
| |
| private labelText(attrs: PopupMultiSelectAttrs): string { |
| const {options, showNumSelected, label} = attrs; |
| |
| if (showNumSelected) { |
| const numSelected = options.filter(({checked}) => checked).length; |
| return `${label} (${numSelected} selected)`; |
| } else { |
| return label; |
| } |
| } |
| } |