feat(ui): add keyboard navigation to search results

Implement arrow key navigation for movie search results.

- Arrow Up/Down moves focus between movie cards
- Enter triggers selection of the focused card
- Escape closes the search modal
- Focused card scrolls into view for long result lists

Add .movie-item class for consistent styling with hover/focus states.
This commit is contained in:
VitaliiRomanenko 2026-06-21 11:42:59 +03:00
parent ca1a588740
commit a5dd8ec4db
6 changed files with 127 additions and 1596 deletions

2
.gitignore vendored
View file

@ -39,4 +39,4 @@ skills-lock.json
changes.diff
# Exclude aider files
.aider*
.aider*

1579
main.js

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@ import { SearchMovie } from "../models/SearchResult";
export function createMovieCard(movie: SearchMovie, genresMap: Record<number, string>): HTMLElement {
const card = activeDocument.createElement("div");
card.className = "card";
card.className = "movie-item";
card.id = movie.id.toString();
//#region Photo

View file

@ -3,6 +3,8 @@ import { createMovieCard } from "./MovieCard";
export class SearchView {
private movieListEl: HTMLElement;
private focusedIndex: number = -1;
private movieCards: HTMLElement[] = [];
constructor(movieListEl: HTMLElement) {
this.movieListEl = movieListEl;
@ -11,9 +13,15 @@ export class SearchView {
showLoading(): void {
this.movieListEl.empty();
this.movieListEl.createEl('span', { text: "Loading..." });
this.movieCards = [];
this.focusedIndex = -1;
}
showResults(result: SearchResult, genresMap: Record<number, string>, onMovieClick: (movieId: number) => void): void {
showResults(
result: SearchResult,
genresMap: Record<number, string>,
onMovieClick: (movieId: number) => void
): void {
this.movieListEl.empty();
if (result.results.length === 0) {
this.movieListEl.createDiv({ text: "Nothing found(" });
@ -21,15 +29,64 @@ export class SearchView {
}
result.results.forEach((movie: SearchMovie) => {
const card = createMovieCard(movie, genresMap);
card.setAttribute('tabindex', '0');
card.addEventListener('click', () => {
onMovieClick(movie.id);
});
this.movieListEl.appendChild(card);
this.movieCards.push(card);
});
}
showError(): void {
this.movieListEl.empty();
this.movieListEl.createDiv({ text: "Oops, something went wrong. Please try again." });
this.movieCards = [];
this.focusedIndex = -1;
}
focusNext(): void {
if (this.movieCards.length === 0) {
return;
};
this.clearFocus();
this.focusedIndex = Math.min(this.focusedIndex + 1, this.movieCards.length - 1);
this.applyFocus();
}
focusPrevious(): void {
if (this.movieCards.length === 0) {
return;
};
this.clearFocus();
this.focusedIndex = Math.max(this.focusedIndex - 1, 0);
this.applyFocus();
}
getFocusedMovieId(): number | null {
if (this.focusedIndex >= 0 && this.focusedIndex < this.movieCards.length) {
const id = this.movieCards[this.focusedIndex]?.id;
return id ? parseInt(id) : null;
}
return null;
}
private clearFocus(): void {
if (this.focusedIndex >= 0 && this.focusedIndex < this.movieCards.length){
this.movieCards[this.focusedIndex]?.classList.remove('fucused');
}
}
private applyFocus(): void {
if (this.focusedIndex >= 0 && this.focusedIndex < this.movieCards.length){
this.movieCards[this.focusedIndex]?.classList.add('fucused');
this.movieCards[this.focusedIndex]?.focus();
this.movieCards[this.focusedIndex]?.scrollIntoView({
block: 'center',
behavior: 'smooth'
});
}
}
}

View file

@ -7,6 +7,7 @@ import { SearchView } from "./SearchView";
export class SearchModal extends Modal {
private controller: SearchController;
private view!: SearchView;
private input!: HTMLInputElement;
constructor(app: App, settings: PopcornMDSettings, plugin: PopcornMD) {
super(app);
@ -20,19 +21,47 @@ export class SearchModal extends Modal {
const container = contentEl.createDiv('searchModal-container');
container.createEl('h1', { text: "Popcorn-MD" });
const input = container.createEl("input", {
this.input = container.createEl("input", {
type: "search",
placeholder: "Enter movie name or IMDb id"
});
const movieList = container.createDiv("movie-list");
this.view = new SearchView(movieList);
input.addEventListener("keydown", (event: KeyboardEvent) => {
// Key handler for navigation (listen on the whole container)
container.addEventListener("keydown", (event: KeyboardEvent) => {
switch (event.key){
case "ArrowDown":
event.preventDefault();
this.view.focusNext(); // Fix typo here
break;
case "ArrowUp":
event.preventDefault();
this.view.focusPrevious();
break;
case "Escape":
this.close();
break;
case "Enter":
this.handleKeyboardSelection()
break;
}
});
// Separate handler for the search input
this.input.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleSearch(event);
input.blur();
} else if (event.key === "Escape") {
this.close();
// this.input.blur();
}
// Let arrow keys propagate to container
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.stopPropagation(); // Don't let container handle it here
// Trigger the container's event listener manually
container.dispatchEvent(new KeyboardEvent('keydown', { key: event.key }));
}
});
}
@ -62,6 +91,13 @@ export class SearchModal extends Modal {
}
}
private handleKeyboardSelection(): void{
const movieId = this.view.getFocusedMovieId();
if (movieId !== null) {
void this.handleMovieSelection(movieId);
}
}
private async handleMovieSelection(movieId: number) {
let note: TFile | null = null;
try {
@ -77,3 +113,4 @@ export class SearchModal extends Modal {
this.close();
}
}

View file

@ -14,28 +14,44 @@
.searchModal-container .movie-list {
margin-top: 10px;
overflow-x: auto;
scroll-behavior: smooth;
overscroll-behavior: contain;
}
.card {
.movie-item {
display: flex;
cursor: pointer;
font-size: 1rem;
flex-direction: row;
margin: 5px 0px;
margin: 10px 0px;
box-sizing: border-box;
padding: 10px;
position: relative;
border: 1px solid transparent;
border-radius: 4px;
background-color: var(--background-secondary);
}
.card .movie-photo {
.movie-item:hover {
outline: 2px solid var(--interactive-accent);
outline-offset: -2px;
}
.movie-item.focused {
outline: 2px solid var(--interactive-accent);
outline-offset: -2px;
}
.movie-item .movie-photo {
flex-shrink: 0;
padding: 10px;
}
.card .movie-photo img{
.movie-item .movie-photo img{
width: 80px;
height: 120px;
}
.card .movie-info {
.movie-item .movie-info {
flex-grow: 1;
flex-shrink: 1;
padding: 5px;
@ -43,18 +59,18 @@
overflow: hidden;
}
.card .movie-info h2{
.movie-item .movie-info h2{
margin-top: 2px;
margin-bottom: 3px;
margin-right: 3px;
display: inline-block;
}
.card .movie-info span.original-name{
.movie-item .movie-info span.original-name{
font-size: 0.8em;
color: #7f7f7f;
display: inline-block;
}
.card .movie-info p {
.movie-item .movie-info p {
font-size: 0.7em;
margin-top: 3px;
text-align: justify;
@ -63,7 +79,7 @@
max-width: 100%;
}
.card .genres .genre {
.movie-item .genres .genre {
display: inline-block;
border-radius: 3px;
padding: .2em .5em .3em;