| // 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 './multiselect.scss'; | |
| import m from 'mithril'; | |
| import {Icons} from '../base/semantic_icons'; | |
| import {Button, type ButtonVariant} from './button'; | |
| import {Checkbox} from './checkbox'; | |
| import {EmptyState} from './empty_state'; | |
| import {Popup, PopupPosition} from './popup'; | |
| import {TextInput} from './text_input'; | |
| import type {Intent} from './common'; | |
| export interface MultiSelectOption { | |
| // The ID is used to indentify this option, and is used in callbacks. | |
| id: string; | |
| // This is the name used for searching, and displayed if `label` is not | |
| // provided. | |
| name: string; | |
| // Whether the option is selected or not. | |
| checked: boolean; | |
| // If provided, this is used as checkbox the label. | |
| label?: m.Children; | |
| // Optional details that are shown on hover. | |
| details?: string; | |
| } | |
| export interface MultiSelectDiff { | |
| id: string; | |
| checked: boolean; | |
| } | |
| export interface MultiSelectAttrs { | |
| options: MultiSelectOption[]; | |
| onChange?: (diffs: MultiSelectDiff[]) => void; | |
| repeatCheckedItemsAtTop?: boolean; | |
| showNumSelected?: boolean; | |
| fixedSize?: boolean; | |
| readonly showSelectAllButton?: boolean; | |
| } | |
| export type PopupMultiSelectAttrs = MultiSelectAttrs & { | |
| readonly intent?: Intent; | |
| readonly compact?: boolean; | |
| readonly icon?: string; | |
| readonly label: string; | |
| readonly variant?: ButtonVariant; | |
| readonly position?: 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; | |
| this.validateOptions(options); | |
| 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 validateOptions(options: MultiSelectOption[]) { | |
| const ids = new Set<string>(); | |
| for (const option of options) { | |
| if (ids.has(option.id)) { | |
| throw new Error(`Duplicate option ID: ${option.id}`); | |
| } | |
| ids.add(option.id); | |
| } | |
| } | |
| private renderListOfItems( | |
| attrs: MultiSelectAttrs, | |
| options: MultiSelectOption[], | |
| ) { | |
| const { | |
| repeatCheckedItemsAtTop, | |
| onChange = () => {}, | |
| showSelectAllButton = true, | |
| } = 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); | |
| }, | |
| disabled: !anyChecked, | |
| }), | |
| ), | |
| this.renderOptions( | |
| attrs, | |
| options.filter(({checked}) => checked), | |
| ), | |
| ), | |
| m( | |
| '.pf-multiselect-container', | |
| m( | |
| '.pf-multiselect-header', | |
| m( | |
| 'span', | |
| this.searchText === '' ? 'Options' : `Options (Filtered)`, | |
| ), | |
| showSelectAllButton && | |
| 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); | |
| }, | |
| 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); | |
| }, | |
| 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; | |
| }, | |
| leftIcon: Icons.Search, | |
| value: this.searchText, | |
| placeholder: 'Filter options...', | |
| className: 'pf-search-box', | |
| }), | |
| this.renderClearButton(), | |
| ); | |
| } | |
| private renderClearButton() { | |
| if (this.searchText != '') { | |
| return m(Button, { | |
| onclick: () => { | |
| this.searchText = ''; | |
| }, | |
| label: '', | |
| icon: 'close', | |
| }); | |
| } else { | |
| return null; | |
| } | |
| } | |
| private renderOptions(attrs: MultiSelectAttrs, options: MultiSelectOption[]) { | |
| const {onChange = () => {}} = attrs; | |
| return options.map((item) => { | |
| const {checked, name, label, details, id} = item; | |
| return m(Checkbox, { | |
| label: label != undefined ? label : name, | |
| key: id, // Prevents transitions jumping between items when searching | |
| checked, | |
| className: 'pf-multiselect-item', | |
| onchange: () => { | |
| onChange([{id, checked: !checked}]); | |
| }, | |
| title: details, | |
| }); | |
| }); | |
| } | |
| } | |
| // 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, | |
| position = PopupPosition.Auto, | |
| intent, | |
| compact, | |
| variant, | |
| } = attrs; | |
| return m( | |
| Popup, | |
| { | |
| trigger: m(Button, { | |
| label: this.labelText(attrs), | |
| icon, | |
| intent, | |
| compact, | |
| variant, | |
| }), | |
| position, | |
| }, | |
| 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; | |
| } | |
| } | |
| } |