| // Copyright (C) 2026 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 './combobox.scss'; |
| import m from 'mithril'; |
| import {Popup, PopupPosition} from './popup'; |
| import {TextInput} from './text_input'; |
| import {FuzzyFinder, type FuzzySegment} from '../base/fuzzy'; |
| import {classNames} from '../base/classnames'; |
| import {HotkeyGlyphs, Keycap} from './hotkey_glyphs'; |
| import {Menu, MenuDivider, MenuItem} from './menu'; |
| |
| /** |
| * A text input with a filterable suggestion dropdown. |
| * |
| * Spec: |
| * - When empty, focus (click or keyboard) to open the popup and show all |
| * options. |
| * - Whenver there is text in the input, show the fuzzy filtered list of options |
| * a the top of the popup, with the full list below. |
| * - If the input text doesn't exactly match an option (case sensitive), show an |
| * additional option 'Use "input text"' at the bottom of the filtered list. |
| * - Bluring or tabbing away from the input closes the popup. |
| * - Blurring the input never changes the text value, providing an escape hatch |
| * to allow the user to choose an item that's not in the list. |
| * - Whenever the user modifies the search term, the highlighted index resets to |
| * the first item in the filtered list (or the 'Use "input text"' item if |
| * there are no filtered results). |
| * - Pressing the arrow keys when the popup is open moves the highlighted index |
| * up and down. |
| * - Pressing enter when the popup is open replaces the input text with the |
| * highlighted item, and closes the popup - the input remains focused, |
| * allowing the user to tab to the next input in the form. |
| * - Pressing escape when the popup is open closes the popup. |
| * |
| * Edge cases: |
| * - Arrow down past the last or arrow up past the first item clamps. |
| * - Pressing any key when the popup is closed opens the popup. |
| */ |
| |
| export interface ComboboxSuggestion { |
| readonly value: string; |
| readonly icon?: string; |
| } |
| |
| export interface ComboboxAttrs { |
| readonly suggestions: ReadonlyArray<ComboboxSuggestion | string>; |
| readonly value: string; |
| readonly onChange?: (value: string) => void; |
| readonly placeholder?: string; |
| readonly className?: string; |
| readonly icon?: string; |
| } |
| |
| export class Combobox implements m.ClassComponent<ComboboxAttrs> { |
| private isOpen = false; |
| private highlightIdx = 0; |
| |
| view({attrs}: m.CVnode<ComboboxAttrs>) { |
| const { |
| value, |
| placeholder, |
| className, |
| icon, |
| onChange = () => {}, |
| suggestions, |
| } = attrs; |
| |
| const selectItem = (value: string) => { |
| this.isOpen = false; |
| this.highlightIdx = 0; |
| onChange(value); |
| }; |
| |
| const suggestionsNorm = suggestions.map((s) => |
| typeof s === 'string' ? {value: s} : s, |
| ); |
| |
| // Avoid loading the entire list if the popup is closed to save on expensive |
| // unecessary fuzzy calculations. |
| const options = this.isOpen |
| ? buildOptionsList(value, suggestionsNorm) |
| : {filtered: [], all: []}; |
| |
| const allItems = options.filtered.concat(options.all); |
| const totalItems = allItems.length; |
| |
| const onKeyDown = (e: KeyboardEvent) => { |
| if (e.key === 'ArrowDown') { |
| e.preventDefault(); |
| this.isOpen = true; |
| this.highlightIdx = Math.min(this.highlightIdx + 1, totalItems - 1); |
| } else if (e.key === 'ArrowUp') { |
| e.preventDefault(); |
| this.highlightIdx = Math.max(this.highlightIdx - 1, 0); |
| } else if (e.key === 'Enter') { |
| e.preventDefault(); |
| if (this.isOpen) { |
| onChange(allItems[this.highlightIdx].value); |
| this.isOpen = false; |
| this.highlightIdx = 0; |
| } else { |
| this.isOpen = true; |
| } |
| } else if (e.key === 'Escape') { |
| this.isOpen = false; |
| this.highlightIdx = 0; |
| } else { |
| this.isOpen = true; |
| } |
| }; |
| |
| const trigger = m(TextInput, { |
| value, |
| placeholder, |
| className, |
| leftIcon: icon, |
| onInput: (value: string) => { |
| onChange(value); |
| this.isOpen = true; |
| this.highlightIdx = 0; |
| }, |
| onfocus: () => { |
| this.isOpen = true; |
| this.highlightIdx = 0; |
| }, |
| onblur: () => { |
| this.isOpen = false; |
| }, |
| onpointerdown: () => { |
| if (!this.isOpen) { |
| this.isOpen = true; |
| this.highlightIdx = 0; |
| } |
| }, |
| onkeydown: onKeyDown, |
| }); |
| |
| const popupMenu = [ |
| m( |
| Menu, |
| { |
| tabIndex: -1, // Make the menu focusable to receive blur events. |
| className: 'pf-combobox__menu', |
| onfocusin: () => { |
| this.isOpen = true; |
| }, |
| onkeydown: onKeyDown, |
| }, |
| options.filtered.map((s, idx) => |
| m( |
| SuggestionItem, |
| { |
| key: `filtered-${s.value}`, |
| isHighlighted: idx === this.highlightIdx, |
| icon: s.icon, |
| onclick: () => selectItem(s.value), |
| }, |
| s.content, |
| ), |
| ), |
| m(MenuDivider), |
| options.all.map((s, idx) => { |
| const realIdx = idx + options.filtered.length; |
| return m( |
| SuggestionItem, |
| { |
| key: `all-${s.value}`, |
| isHighlighted: realIdx === this.highlightIdx, |
| icon: s.icon, |
| onclick: () => selectItem(s.value), |
| }, |
| s.content, |
| ); |
| }), |
| ), |
| this.renderFooter(), |
| ]; |
| |
| return m( |
| Popup, |
| { |
| trigger, |
| isOpen: this.isOpen, |
| onChange: (shouldOpen: boolean) => { |
| if (!shouldOpen) { |
| this.isOpen = false; |
| this.highlightIdx = 0; |
| } |
| }, |
| position: PopupPosition.Bottom, |
| closeOnEscape: true, |
| closeOnOutsideClick: true, |
| className: 'pf-combobox__popup', |
| }, |
| popupMenu, |
| ); |
| } |
| |
| private renderFooter() { |
| return m('.pf-combobox__footer', [ |
| m(Keycap, '↑↓'), |
| ' navigate ', |
| m(HotkeyGlyphs, {hotkey: 'Enter'}), |
| ' select ', |
| m(HotkeyGlyphs, {hotkey: 'Escape'}), |
| ' dismiss', |
| ]); |
| } |
| } |
| |
| interface SuggestionItemAttrs extends m.Attributes { |
| readonly isHighlighted?: boolean; |
| readonly icon?: string; |
| } |
| |
| function SuggestionItem(): m.Component<SuggestionItemAttrs> { |
| let prevHighlighted = false; |
| return { |
| view({attrs, children}: m.CVnode<SuggestionItemAttrs>) { |
| const {prefix, isHighlighted, className, ...htmlAttrs} = attrs; |
| return m(MenuItem, { |
| ...htmlAttrs, |
| className: classNames( |
| 'pf-combobox__item', |
| isHighlighted && 'pf-highlight', |
| className, |
| ), |
| label: [prefix, children], |
| }); |
| }, |
| oncreate({attrs, dom}: m.VnodeDOM<SuggestionItemAttrs>) { |
| prevHighlighted = attrs.isHighlighted ?? false; |
| if (attrs.isHighlighted) { |
| dom.scrollIntoView({block: 'nearest'}); |
| } |
| }, |
| onupdate({attrs, dom}: m.VnodeDOM<SuggestionItemAttrs>) { |
| const isHighlighted = attrs.isHighlighted ?? false; |
| const justHighlighted = isHighlighted && !prevHighlighted; |
| prevHighlighted = isHighlighted; |
| if (justHighlighted) { |
| dom.scrollIntoView({block: 'nearest'}); |
| } |
| }, |
| }; |
| } |
| |
| function formatSegments(segments: FuzzySegment[]): m.Children { |
| return segments.map(({matching, value}) => |
| matching ? m('b', value) : value, |
| ); |
| } |
| |
| function buildOptionsList( |
| value: string, |
| suggestions: readonly ComboboxSuggestion[], |
| ): { |
| filtered: {value: string; content: m.Children; icon?: string}[]; |
| all: {value: string; content: m.Children; icon?: string}[]; |
| } { |
| const filtered = buildFilteredItems(value, suggestions); |
| const exact = buildExactMatchItem(value, suggestions); |
| const all = buildAllItems(suggestions); |
| |
| return { |
| filtered: [...filtered, ...exact], |
| all, |
| }; |
| } |
| |
| function buildFilteredItems( |
| value: string, |
| suggestions: readonly ComboboxSuggestion[], |
| ) { |
| if (value === '') { |
| return []; |
| } else { |
| // Do the filtering and add a single list of options up the top which |
| // contains the filtered suggestions list. |
| const normalize = (s: ComboboxSuggestion | string) => |
| typeof s === 'string' ? {value: s} : s; |
| const norm = suggestions.map(normalize); |
| const fuzzy = new FuzzyFinder(norm, (s) => s.value); |
| return fuzzy.find(value).map(({item, segments}) => ({ |
| value: item.value, |
| content: formatSegments(segments), |
| icon: item.icon, |
| })); |
| } |
| } |
| |
| function buildExactMatchItem( |
| value: string, |
| suggestions: readonly ComboboxSuggestion[], |
| ) { |
| if (value === '') { |
| return []; |
| } |
| |
| const exactMatch = suggestions.some((s) => s.value === value); |
| if (exactMatch) { |
| return []; |
| } else { |
| return [{value, content: `Use '${value}'`}]; |
| } |
| } |
| |
| function buildAllItems(suggestions: readonly ComboboxSuggestion[]) { |
| return suggestions.map((s) => ({ |
| value: s.value, |
| content: s.value, |
| icon: s.icon, |
| })); |
| } |