Extract selected item scrolling directive

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 00:04:23 +02:00
parent 359df2d1ff
commit 423bcbae34
4 changed files with 42 additions and 30 deletions
+3 -8
View File
@@ -2,6 +2,7 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppAction } from "../actions";
import { formatShortcut } from "../keyboardShortcuts";
import { scrollWhenSelected } from "./scrollWhenSelected";
import { actionPaletteStyles } from "./shared";
@customElement("action-palette")
@@ -33,7 +34,7 @@ export class ActionPalette extends LitElement {
</header>
<div class="options">
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => { this.run(action); }}>
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
<span class="main">
<strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
@@ -53,16 +54,10 @@ export class ActionPalette extends LitElement {
}
protected override updated(changed: PropertyValues) {
if (changed.has("actions") || changed.has("queryText")) {
if (!changed.has("actions") && !changed.has("queryText")) return;
const maxIndex = Math.max(0, this.filteredActions().length - 1);
if (this.selectedIndex > maxIndex) this.selectedIndex = maxIndex;
}
if (changed.has("selectedIndex") || changed.has("actions") || changed.has("queryText")) this.scrollSelectedIntoView();
}
private scrollSelectedIntoView() {
this.renderRoot.querySelector<HTMLElement>(".options button.selected")?.scrollIntoView({ block: "nearest" });
}
private filteredActions(): AppAction[] {
const query = this.queryText.trim().toLowerCase();
+3 -10
View File
@@ -1,5 +1,6 @@
import { LitElement, html, type PropertyValues } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { scrollWhenSelected } from "./scrollWhenSelected";
import { autocompleteStyles, type CompletionItem } from "./shared";
@customElement("autocomplete-menu")
@@ -13,7 +14,7 @@ export class AutocompleteMenu extends LitElement {
return html`
<div class="menu">
${this.items.map((item, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} @mousedown=${(event: MouseEvent) => { event.preventDefault(); this.onPick?.(item); }}>
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, item)} @mousedown=${(event: MouseEvent) => { event.preventDefault(); this.onPick?.(item); }}>
<strong>${item.insertText}</strong>
<span>${item.detail}</span>
${item.description !== undefined && item.description !== "" ? html`<small>${item.description}</small>` : null}
@@ -23,13 +24,5 @@ export class AutocompleteMenu extends LitElement {
`;
}
protected override updated(changed: PropertyValues) {
if (changed.has("selectedIndex") || changed.has("items")) this.scrollSelectedIntoView();
}
private scrollSelectedIntoView() {
this.renderRoot.querySelector<HTMLElement>("button.selected")?.scrollIntoView({ block: "nearest" });
}
static override styles = autocompleteStyles;
}
+3 -10
View File
@@ -1,6 +1,7 @@
import { LitElement, html, type PropertyValues } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { CommandOption } from "../api";
import { scrollWhenSelected } from "./scrollWhenSelected";
import { commandPickerStyles } from "./shared";
@customElement("command-picker")
@@ -21,7 +22,7 @@ export class CommandPicker extends LitElement {
</header>
<div class="options" @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }} tabindex="0">
${this.options.map((option, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}>
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, option.value)} @click=${() => this.onPick?.(option.value)}>
<span>${option.label}</span>
${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null}
</button>
@@ -36,14 +37,6 @@ export class CommandPicker extends LitElement {
this.renderRoot.querySelector<HTMLElement>(".options")?.focus();
}
protected override updated(changed: PropertyValues) {
if (changed.has("selectedIndex") || changed.has("options")) this.scrollSelectedIntoView();
}
private scrollSelectedIntoView() {
this.renderRoot.querySelector<HTMLElement>(".options button.selected")?.scrollIntoView({ block: "nearest" });
}
private handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
@@ -0,0 +1,31 @@
import { Directive, PartType, directive, type Part, type PartInfo } from "lit/directive.js";
class ScrollWhenSelectedDirective extends Directive {
private readonly isElementPart: boolean;
private wasSelected = false;
private previousKey: unknown;
constructor(partInfo: PartInfo) {
super(partInfo);
this.isElementPart = partInfo.type === PartType.ELEMENT;
}
override update(part: Part, [selected, key]: [boolean, unknown?]) {
if (!this.isElementPart) throw new Error("scrollWhenSelected must be used on an element");
if (selected && (!this.wasSelected || key !== this.previousKey)) {
const element = (part as Part & { element?: Element }).element;
requestAnimationFrame(() => {
if (element instanceof HTMLElement) element.scrollIntoView({ block: "nearest" });
});
}
this.wasSelected = selected;
this.previousKey = key;
return undefined;
}
override render(_selected: boolean, _key?: unknown) {
return undefined;
}
}
export const scrollWhenSelected = directive(ScrollWhenSelectedDirective);