Files
yellowjacket/frontend/src/components/combobox/combobox.ts
T
yonlu ebaa856e0c fix: align combobox height to operator select via grid stretch
- Grid rows use align-items: stretch so all cells share the select's height
- Combobox host, wrapper, and input all set height: 100% to fill the cell
- Remove button uses align-self: center to stay centered instead of stretching
2026-03-21 13:46:26 -04:00

328 lines
12 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
/**
* `<yj-combobox>` — Typeable dropdown with autocomplete filtering and
* keyboard navigation. Accepts a flat `options` string array, filters as
* the user types, and emits `combobox-change` when a value is selected.
*
* Key implementation detail: option `<li>` elements use `@mousedown` with
* `e.preventDefault()` so that the input's `blur` event does not close the
* dropdown before the click registers.
*/
@customElement('yj-combobox')
export class YjCombobox extends LitElement {
// ── Public reactive properties ──────────────────────────────────
/** Full list of selectable options. */
@property({ type: Array })
options: string[] = [];
/** Currently selected value (reflects to attribute for CSS hooks). */
@property({ type: String, reflect: true })
value = '';
/** Placeholder text shown when the input is empty. */
@property({ type: String })
placeholder = '';
/** Disables input and dropdown interaction. */
@property({ type: Boolean })
disabled = false;
// ── Internal state ──────────────────────────────────────────────
/** Text currently in the input — drives filtering. */
@state()
private filterText = '';
/** Whether the dropdown is visible. */
@state()
private open = false;
/** Index into `filteredOptions` for keyboard highlight (-1 = none). */
@state()
private highlightedIndex = -1;
// ── Computed ────────────────────────────────────────────────────
/** Options that match the current filterText (case-insensitive substring). */
private get filteredOptions(): string[] {
const opts = this.options ?? [];
if (!this.filterText) return opts;
const needle = this.filterText.toLowerCase();
return opts.filter((o) => o.toLowerCase().includes(needle));
}
// ── Styles ──────────────────────────────────────────────────────
static override styles = [
designTokens,
css`
:host {
display: inline-block;
width: 100%;
height: 100%;
}
.combobox-wrapper {
position: relative;
display: inline-block;
width: 100%;
height: 100%;
}
input {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 4px;
padding: 4px 8px;
font-size: var(--yj-text-md);
font-family: inherit;
width: 100%;
height: 100%;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--yj-accent, #ffd43b);
}
input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 200px;
overflow-y: auto;
background: var(--yj-bg-surface, #282828);
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-top: none;
border-radius: 0 0 4px 4px;
margin: 0;
padding: 0;
list-style: none;
}
.dropdown li {
padding: 4px 8px;
cursor: pointer;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-md);
}
.dropdown li:hover,
.dropdown li.highlighted {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
`,
];
// ── Lifecycle ───────────────────────────────────────────────────
override connectedCallback() {
super.connectedCallback();
// Initialise filterText from the external value so an existing
// selection is visible immediately.
this.filterText = this.value;
}
override updated(changed: Map<string, unknown>) {
super.updated(changed);
// Sync filterText when the parent sets `value` programmatically
// (e.g. when pre-populating the editor with saved rules).
if (changed.has('value') && !this.open) {
this.filterText = this.value;
}
// Scroll the highlighted option into view.
if (changed.has('highlightedIndex') && this.highlightedIndex >= 0) {
const items = this.shadowRoot?.querySelectorAll('.dropdown li');
items?.[this.highlightedIndex]?.scrollIntoView({
block: 'nearest',
});
}
}
// ── Event handlers ──────────────────────────────────────────────
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.filterText = input.value;
this.open = true;
this.highlightedIndex = -1;
}
private handleFocus() {
// Clear filter so the full option list is visible on focus.
this.filterText = '';
this.open = true;
this.highlightedIndex = -1;
}
private handleBlur() {
// Use rAF as a safety net — mousedown on an option calls
// preventDefault() which should keep focus, but some browsers are
// inconsistent. The tiny delay lets any pending mousedown handler
// fire first.
requestAnimationFrame(() => {
this.open = false;
// Commit free-form text: if the user typed something that
// isn't in the option list, accept it as the value anyway.
const typed = this.filterText.trim();
if (typed && typed !== this.value) {
this.value = typed;
this.filterText = typed;
this.dispatchEvent(
new CustomEvent('combobox-change', {
bubbles: true,
composed: true,
detail: { value: typed },
}),
);
} else {
// Restore display text to the confirmed value.
this.filterText = this.value;
}
});
}
private handleKeydown(e: KeyboardEvent) {
const opts = this.filteredOptions;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!this.open) {
this.open = true;
this.highlightedIndex = 0;
} else if (opts.length > 0) {
this.highlightedIndex =
(this.highlightedIndex + 1) % opts.length;
}
break;
case 'ArrowUp':
e.preventDefault();
if (opts.length > 0 && this.open) {
this.highlightedIndex =
(this.highlightedIndex - 1 + opts.length) %
opts.length;
}
break;
case 'Enter':
if (
this.open &&
this.highlightedIndex >= 0 &&
this.highlightedIndex < opts.length
) {
e.preventDefault();
this.selectOption(opts[this.highlightedIndex]!);
} else if (this.filterText.trim()) {
// Commit free-form text on Enter even without a
// highlighted option.
e.preventDefault();
this.selectOption(this.filterText.trim());
}
break;
case 'Escape':
e.preventDefault();
this.open = false;
this.filterText = this.value;
break;
case 'Tab':
// Close dropdown but let default Tab navigation proceed.
this.open = false;
this.filterText = this.value;
break;
default:
break;
}
}
// ── Selection ───────────────────────────────────────────────────
private selectOption(opt: string) {
this.value = opt;
this.filterText = opt;
this.open = false;
this.highlightedIndex = -1;
this.dispatchEvent(
new CustomEvent('combobox-change', {
bubbles: true,
composed: true,
detail: { value: opt },
}),
);
}
// ── Render ──────────────────────────────────────────────────────
override render() {
const opts = this.filteredOptions;
return html`
<div class="combobox-wrapper">
<input
.value=${this.filterText}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@keydown=${this.handleKeydown}
?disabled=${this.disabled}
placeholder=${this.placeholder}
autocomplete="off"
role="combobox"
aria-expanded=${this.open}
aria-autocomplete="list"
/>
${this.open && opts.length > 0
? html`
<ul class="dropdown" role="listbox">
${opts.map(
(opt, i) => html`
<li
role="option"
aria-selected=${i === this.highlightedIndex}
class=${i === this.highlightedIndex
? 'highlighted'
: ''}
@mousedown=${(e: Event) => {
e.preventDefault();
this.selectOption(opt);
}}
>
${opt}
</li>
`,
)}
</ul>
`
: nothing}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'yj-combobox': YjCombobox;
}
}