mirror of
https://github.com/vitaliiromanenko/popcorn-md.git
synced 2026-07-22 07:45:25 +00:00
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:
parent
ca1a588740
commit
a5dd8ec4db
6 changed files with 127 additions and 1596 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,4 +39,4 @@ skills-lock.json
|
|||
changes.diff
|
||||
|
||||
# Exclude aider files
|
||||
.aider*
|
||||
.aider*
|
||||
1579
main.js
1579
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
34
styles.css
34
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue