Fix detail-view focus scope and entry targets
This commit is contained in:
+51
-10
@@ -62,7 +62,7 @@
|
||||
<div v-else class="page-slider">
|
||||
<div class="page-slider-track" :class="{ 'is-detail-open': isDetailOpen }">
|
||||
<!-- Browse/Search page (left panel) -->
|
||||
<main class="main-content page-slider-panel">
|
||||
<main class="main-content page-slider-panel" data-nav-scope="browse">
|
||||
<!-- Movies and Series views - both always rendered for smooth transitions -->
|
||||
<div v-if="!searchQuery" class="view-container">
|
||||
<Transition name="view-zoom" mode="out-in">
|
||||
@@ -152,7 +152,7 @@
|
||||
</main>
|
||||
|
||||
<!-- Detail page (right panel) -->
|
||||
<main class="main-content page-slider-panel page-slider-detail-panel">
|
||||
<main class="main-content page-slider-panel page-slider-detail-panel" data-nav-scope="detail">
|
||||
<MediaDetail
|
||||
v-if="detailItemForRender"
|
||||
v-show="isDetailOpen"
|
||||
@@ -191,7 +191,7 @@ import {
|
||||
fetchRoots,
|
||||
} from "./api"
|
||||
import { useSettings } from "./composables/useSettings"
|
||||
import { useKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||
import Header from "./components/Header.vue"
|
||||
import CollageHero from "./components/CollageHero.vue"
|
||||
@@ -200,7 +200,7 @@ import MediaDetail from "./components/MediaDetail.vue"
|
||||
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
|
||||
|
||||
// Initialize keyboard navigation
|
||||
const { getFocusState, restoreFocusState, focusAt, focusElement } = useKeyboardNavigation()
|
||||
const { getFocusState, restoreFocusState, focusElement } = useKeyboardNavigation()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -616,6 +616,7 @@ function handleEscapeKey(event: KeyboardEvent) {
|
||||
onMounted(() => {
|
||||
void refreshPlayerStatus()
|
||||
void refreshResumePositions()
|
||||
setActiveNavigationScope(isDetailOpen.value ? "detail" : "browse")
|
||||
document.addEventListener("keydown", handleEscapeKey)
|
||||
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
|
||||
})
|
||||
@@ -759,6 +760,45 @@ function handleActorSearch(actorName: string) {
|
||||
updateSearchQuery(actorName)
|
||||
}
|
||||
|
||||
function focusDetailEntryTarget(item: MediaItem): boolean {
|
||||
const detailPanel = document.querySelector('[data-nav-scope="detail"]') as HTMLElement | null
|
||||
if (!detailPanel) return false
|
||||
|
||||
let target: HTMLElement | null = null
|
||||
|
||||
if (item.type === "movies") {
|
||||
// Best release is rendered first and mapped to row 2 / col 0.
|
||||
target = detailPanel.querySelector(
|
||||
'[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
} else if (item.type === "series") {
|
||||
// Initial episode tile (first season, first episode) maps to row 2 / col 0.
|
||||
target = detailPanel.querySelector(
|
||||
'.episode-tile[data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
}
|
||||
|
||||
if (target) {
|
||||
focusElement(target)
|
||||
return true
|
||||
}
|
||||
|
||||
const navButtons = Array.from(document.querySelectorAll<HTMLElement>(".header-nav-item.active"))
|
||||
const detailsNav = navButtons.find((button) => button.textContent?.trim() === "Details")
|
||||
if (detailsNav?.hasAttribute("data-nav-focusable")) {
|
||||
focusElement(detailsNav)
|
||||
return true
|
||||
}
|
||||
|
||||
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
|
||||
if (firstFocusable) {
|
||||
focusElement(firstFocusable)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert raw data to MediaItem format
|
||||
function movieToMediaItem(movie: Movie): MediaItem {
|
||||
// Get resolution from first torrent if available
|
||||
@@ -1058,13 +1098,14 @@ function rehydrateSearchResult(result: SearchResultItem): MediaItem {
|
||||
// Watch for detail page entry/exit to manage focus
|
||||
watch(selectedItem, (item, oldItem) => {
|
||||
if (item && !oldItem) {
|
||||
// Skip auto-focus if we have a specific episode to focus on (from search)
|
||||
if (item.type === "series" && focusEpisode.value) {
|
||||
return
|
||||
}
|
||||
// Entering detail page - focus Play button (row 2, col 0) after transition
|
||||
focusAt(2, 0, 150)
|
||||
// Entering detail page - constrain navigation to detail panel immediately,
|
||||
// then shift focus into it while the slide transition runs.
|
||||
setActiveNavigationScope("detail")
|
||||
setTimeout(() => {
|
||||
focusDetailEntryTarget(item)
|
||||
}, 20)
|
||||
} else if (!item && oldItem) {
|
||||
setActiveNavigationScope("browse")
|
||||
// Leaving detail page (browser back, Escape, etc.) - restore focus to the item card
|
||||
const page = currentView.value === "series" ? "series" : "movies"
|
||||
restoreFocusForPage(page)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="series-fullscreen">
|
||||
<div ref="seriesRootRef" class="series-fullscreen">
|
||||
<!-- Hero section with backdrop or season collage -->
|
||||
<section class="series-hero">
|
||||
<div class="hero-bg">
|
||||
@@ -204,6 +204,8 @@ const emit = defineEmits<{
|
||||
openFolder: [string, string | null | undefined]
|
||||
}>()
|
||||
|
||||
const seriesRootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Focus on matched episode when provided
|
||||
watch(
|
||||
() => props.focusEpisode,
|
||||
@@ -222,9 +224,9 @@ watch(
|
||||
if (episodeIndex >= 0) {
|
||||
// Find the episode tile element using nav attributes
|
||||
const selector = `[data-nav-row="${seasonIndex + 2}"][data-nav-col="${episodeIndex}"]`
|
||||
const element = document.querySelector(selector) as HTMLElement | null
|
||||
const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
|
||||
element.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type OutOfBoundsNavigationHandler = (
|
||||
// Global focus state
|
||||
const focusedElement = ref<HTMLElement | null>(null)
|
||||
const isNavigating = ref(false)
|
||||
const activeNavigationScope = ref<string | null>(null)
|
||||
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
|
||||
const desiredCol = ref<number | null>(null)
|
||||
// Track if global handlers are installed
|
||||
@@ -341,11 +342,26 @@ function handleSyncedRowResize() {
|
||||
|
||||
function ensureElementVisibleVertically(element: HTMLElement) {
|
||||
if (element.hasAttribute("data-nav-release-item")) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
})
|
||||
const rootStyle = window.getComputedStyle(document.documentElement)
|
||||
const headerHeight = parseFloat(rootStyle.getPropertyValue("--header-height") || "0")
|
||||
const topMargin = headerHeight + 24
|
||||
const bottomMargin = 24
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
if (rect.top < topMargin) {
|
||||
window.scrollBy({
|
||||
top: rect.top - topMargin,
|
||||
behavior: "smooth",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight - bottomMargin) {
|
||||
window.scrollBy({
|
||||
top: rect.bottom - (window.innerHeight - bottomMargin),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -388,12 +404,34 @@ function resolveOutOfBoundsNavigation(
|
||||
|
||||
const outOfBoundsHandlers = new Set<OutOfBoundsNavigationHandler>()
|
||||
|
||||
function getElementNavigationScope(element: HTMLElement): string | null {
|
||||
const owner = element.closest<HTMLElement>("[data-nav-scope]")
|
||||
return owner?.getAttribute("data-nav-scope") || null
|
||||
}
|
||||
|
||||
function isElementInActiveScope(element: HTMLElement): boolean {
|
||||
const scope = activeNavigationScope.value
|
||||
if (!scope) return true
|
||||
const elementScope = getElementNavigationScope(element)
|
||||
// Elements without a scope (for example global header controls) stay reachable.
|
||||
if (!elementScope) return true
|
||||
return elementScope === scope
|
||||
}
|
||||
|
||||
function clearFocusedElement() {
|
||||
if (!focusedElement.value) return
|
||||
focusedElement.value.classList.remove("nav-focused")
|
||||
focusedElement.value.blur()
|
||||
focusedElement.value = null
|
||||
}
|
||||
|
||||
function getFocusableElements(): FocusableElement[] {
|
||||
const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`)
|
||||
const result: FocusableElement[] = []
|
||||
|
||||
elements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (!isElementInActiveScope(htmlEl)) return
|
||||
if (htmlEl.offsetParent === null) return
|
||||
|
||||
const rect = htmlEl.getBoundingClientRect()
|
||||
@@ -594,6 +632,7 @@ function findNextElement(
|
||||
|
||||
function focusElement(element: HTMLElement | null) {
|
||||
if (!element) return
|
||||
if (!isElementInActiveScope(element)) return
|
||||
|
||||
if (focusedElement.value && focusedElement.value !== element) {
|
||||
focusedElement.value.classList.remove("nav-focused")
|
||||
@@ -670,10 +709,17 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
isNavigating.value = true
|
||||
|
||||
let current = focusedElement.value
|
||||
if (current && !isElementInActiveScope(current)) {
|
||||
current = null
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) {
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement.hasAttribute(FOCUSABLE_ATTR) &&
|
||||
isElementInActiveScope(activeElement)
|
||||
) {
|
||||
current = activeElement
|
||||
}
|
||||
}
|
||||
@@ -702,12 +748,23 @@ function handleEnterKey(event: KeyboardEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (focusedElement.value) {
|
||||
if (focusedElement.value && isElementInActiveScope(focusedElement.value)) {
|
||||
event.preventDefault()
|
||||
focusedElement.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveNavigationScope(scope: string | null) {
|
||||
if (activeNavigationScope.value === scope) return
|
||||
activeNavigationScope.value = scope
|
||||
desiredCol.value = null
|
||||
resetSyncedRows(true)
|
||||
|
||||
if (focusedElement.value && !isElementInActiveScope(focusedElement.value)) {
|
||||
clearFocusedElement()
|
||||
}
|
||||
}
|
||||
|
||||
export function installKeyboardNavigation() {
|
||||
if (handlersInstalled) return
|
||||
handlersInstalled = true
|
||||
@@ -721,7 +778,7 @@ export function installKeyboardNavigation() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null
|
||||
if (focusable) {
|
||||
if (focusable && isElementInActiveScope(focusable)) {
|
||||
desiredCol.value = null
|
||||
focusElement(focusable)
|
||||
}
|
||||
@@ -731,7 +788,7 @@ export function installKeyboardNavigation() {
|
||||
if (event.button !== 1) return
|
||||
const target = event.target as HTMLElement
|
||||
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null
|
||||
if (focusable) {
|
||||
if (focusable && isElementInActiveScope(focusable)) {
|
||||
desiredCol.value = null
|
||||
focusElement(focusable)
|
||||
}
|
||||
@@ -739,7 +796,7 @@ export function installKeyboardNavigation() {
|
||||
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.hasAttribute(FOCUSABLE_ATTR)) {
|
||||
if (target.hasAttribute(FOCUSABLE_ATTR) && isElementInActiveScope(target)) {
|
||||
if (focusedElement.value && focusedElement.value !== target) {
|
||||
focusedElement.value.classList.remove("nav-focused")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user