| // Copyright (C) 2025 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_input.scss'; | |
| /** | |
| * MultiselectInput - A widget for selecting multiple items from a list with | |
| * auto-complete. | |
| * | |
| * This widget provides a text-input-based interface for multi-selection, | |
| * combining typing to filter with clicking to select. Selected items appear as | |
| * removable chips in the input field. | |
| * | |
| * Features: | |
| * - Type to filter available options | |
| * - Click options to toggle selection | |
| * - Selected items shown as chips with remove buttons | |
| * - Keyboard navigation with arrow keys | |
| * - Popup automatically opens on focus, closes on blur | |
| * | |
| * User Interactions: | |
| * - Click anywhere in the input area to focus and open the popup | |
| * - Type text to filter the available options | |
| * - Click an option in the popup to toggle its selection | |
| * - Click the × on a chip to remove that selection | |
| * - Click outside the widget or tab away to close the popup | |
| * | |
| * Keyboard Shortcuts: | |
| * - Enter: Toggle selection of the currently highlighted option | |
| * - ArrowUp/ArrowDown: Navigate through filtered options | |
| * - Backspace (when input empty): Remove the last selected chip | |
| * - Escape: Close the popup by blurring the input | |
| * | |
| * Implementation Details: | |
| * - Popup state is tied to input focus (focused = open, blurred = closed) | |
| * - Clicking inside the popup prevents input blur via | |
| * mousedown.preventDefault() | |
| * - All keyboard handling uses Mithril's event system (no manual event | |
| * listeners) | |
| * - Uses controlled mode for the Popup widget (no automatic toggle on trigger | |
| * click) | |
| */ | |
| import m from 'mithril'; | |
| import {type HTMLAttrs, Intent} from './common'; | |
| import {Icon} from './icon'; | |
| import {Popup, PopupPosition} from './popup'; | |
| import {EmptyState} from './empty_state'; | |
| import {classNames} from '../base/classnames'; | |
| import {Stack} from './stack'; | |
| import {Chip} from './chip'; | |
| export interface Option { | |
| readonly key: string; | |
| readonly label: string; | |
| } | |
| export interface MultiselectInputAttrs extends HTMLAttrs { | |
| readonly options: ReadonlyArray<Option>; | |
| readonly selectedOptions: ReadonlyArray<string>; | |
| readonly onOptionAdd: (key: string) => void; | |
| readonly onOptionRemove: (key: string) => void; | |
| readonly placeholder?: string; | |
| } | |
| export class MultiselectInput implements m.ClassComponent<MultiselectInputAttrs> { | |
| private currentTextValue = ''; | |
| private selectedItemIndex = 0; | |
| private popupIsOpen = false; | |
| view({attrs}: m.CVnode<MultiselectInputAttrs>) { | |
| const {selectedOptions, placeholder, options, ...htmlAttrs} = attrs; | |
| return m( | |
| Popup, | |
| { | |
| className: 'pf-multiselect-input__popup', | |
| position: PopupPosition.Bottom, | |
| matchWidth: true, | |
| isOpen: this.popupIsOpen, | |
| // Disable Popup's built-in close handlers - we manage via input focus/blur | |
| closeOnEscape: false, | |
| closeOnOutsideClick: false, | |
| trigger: m( | |
| '.pf-multiselect-input', | |
| htmlAttrs, | |
| // Render the selected options as tags in the text field | |
| m( | |
| Stack, | |
| {orientation: 'horizontal', spacing: 'small'}, | |
| selectedOptions.map((key) => { | |
| const option = options.find((o) => o.key === key); | |
| if (option) { | |
| return renderTag({ | |
| label: option.label, | |
| onRemove: () => attrs.onOptionRemove(option.key), | |
| }); | |
| } else { | |
| return undefined; | |
| } | |
| }), | |
| ), | |
| m('input', { | |
| value: this.currentTextValue, | |
| placeholder, | |
| onfocus: () => { | |
| this.popupIsOpen = true; | |
| }, | |
| onblur: () => { | |
| this.popupIsOpen = false; | |
| }, | |
| onkeydown: (ev: KeyboardEvent) => { | |
| const filteredOptions = this.filterOptions(attrs); | |
| if (ev.key === 'Escape') { | |
| // Blur the input, which will close the popup via onblur | |
| (ev.target as HTMLInputElement).blur(); | |
| ev.preventDefault(); | |
| } else if (ev.key === 'Enter') { | |
| if (filteredOptions.length > 0) { | |
| const option = filteredOptions[this.selectedItemIndex]; | |
| const alreadyAdded = selectedOptions.includes(option.key); | |
| if (alreadyAdded) { | |
| attrs.onOptionRemove(option.key); | |
| } else { | |
| attrs.onOptionAdd(option.key); | |
| } | |
| this.currentTextValue = ''; | |
| } | |
| ev.preventDefault(); | |
| } else if (ev.key === 'ArrowUp') { | |
| if (filteredOptions.length > 0) { | |
| this.selectedItemIndex = Math.max( | |
| 0, | |
| this.selectedItemIndex - 1, | |
| ); | |
| } | |
| ev.preventDefault(); | |
| } else if (ev.key === 'ArrowDown') { | |
| if (filteredOptions.length > 0) { | |
| this.selectedItemIndex = Math.min( | |
| filteredOptions.length - 1, | |
| this.selectedItemIndex + 1, | |
| ); | |
| } | |
| ev.preventDefault(); | |
| } else if (ev.key === 'Backspace') { | |
| if ( | |
| this.currentTextValue === '' && | |
| selectedOptions.length > 0 | |
| ) { | |
| attrs.onOptionRemove( | |
| selectedOptions[selectedOptions.length - 1], | |
| ); | |
| ev.preventDefault(); | |
| } | |
| } | |
| }, | |
| oninput: (ev: InputEvent) => { | |
| const el = ev.target as HTMLInputElement; | |
| this.currentTextValue = el.value; | |
| this.selectedItemIndex = 0; | |
| }, | |
| }), | |
| ), | |
| }, | |
| this.renderOptionsPopup(attrs), | |
| ); | |
| } | |
| private renderOptionsPopup(attrs: MultiselectInputAttrs) { | |
| const {onOptionAdd, onOptionRemove, selectedOptions} = attrs; | |
| const filtered = this.filterOptions(attrs); | |
| if (filtered.length === 0) { | |
| return m(EmptyState, {title: 'No results found'}); | |
| } | |
| return m( | |
| '.pf-multiselect-input__scroller', | |
| { | |
| onmousedown: (e: MouseEvent) => { | |
| // Prevent input from losing focus when clicking inside popup | |
| e.preventDefault(); | |
| }, | |
| }, | |
| filtered.map((o, index) => { | |
| const alreadyAdded = selectedOptions.includes(o.key); | |
| return m( | |
| '.pf-multiselect-input__option-row', | |
| { | |
| key: o.key, | |
| className: classNames( | |
| this.selectedItemIndex === index && | |
| 'pf-multiselect-input__option-row--selected', | |
| ), | |
| onclick: () => { | |
| if (alreadyAdded) { | |
| onOptionRemove(o.key); | |
| } else { | |
| onOptionAdd(o.key); | |
| } | |
| }, | |
| }, | |
| alreadyAdded && m(Icon, {icon: 'check'}), | |
| o.label, | |
| ); | |
| }), | |
| ); | |
| } | |
| private filterOptions({options}: MultiselectInputAttrs) { | |
| return options.filter((o) => { | |
| return o.label | |
| .toLowerCase() | |
| .includes(this.currentTextValue.toLowerCase()); | |
| }); | |
| } | |
| } | |
| interface TagAttrs { | |
| readonly label: string; | |
| readonly onRemove?: () => void; | |
| } | |
| function renderTag({label, onRemove}: TagAttrs): m.Children { | |
| return m(Chip, { | |
| label, | |
| compact: true, | |
| intent: Intent.Primary, | |
| removable: true, | |
| onRemove: () => onRemove?.(), | |
| }); | |
| } |