200 lines
7.1 KiB
JavaScript
200 lines
7.1 KiB
JavaScript
/**
|
|
* TV-Style Keyboard Navigation
|
|
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
|
|
*/
|
|
|
|
export class KeyboardNavigation {
|
|
constructor() {
|
|
this.currentFocus = null;
|
|
this.isEnabled = false;
|
|
|
|
// Selectors for focusable items
|
|
this.selectors = [
|
|
'.video-card',
|
|
'.hero__btn',
|
|
'.slider-btn',
|
|
'#topSearchBtn'
|
|
];
|
|
}
|
|
|
|
init() {
|
|
this.isEnabled = true;
|
|
document.addEventListener('keydown', this.handleKey.bind(this));
|
|
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
|
|
// Initial focus?
|
|
// Usually wait for user to press a key to enter "Keyboard Mode"
|
|
// so we don't show focus rings to mouse users.
|
|
}
|
|
|
|
handleMouseMove() {
|
|
// If mouse moves, likely user is using mouse.
|
|
// Optional: clear focus to avoid conflict?
|
|
// For now, let's keep them separate or just let hover take precedence.
|
|
if (this.currentFocus) {
|
|
this.currentFocus.blur();
|
|
this.currentFocus.classList.remove('keyboard-focused');
|
|
this.currentFocus = null;
|
|
}
|
|
}
|
|
|
|
handleKey(e) {
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
e.preventDefault(); // Prevent default page scroll
|
|
|
|
if (!this.currentFocus) {
|
|
this.focusFirstVisible();
|
|
return;
|
|
}
|
|
|
|
let nextTarget = null;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
nextTarget = this.moveHorizontal(1);
|
|
break;
|
|
case 'ArrowLeft':
|
|
nextTarget = this.moveHorizontal(-1);
|
|
break;
|
|
case 'ArrowUp':
|
|
nextTarget = this.moveVertical(-1);
|
|
break;
|
|
case 'ArrowDown':
|
|
nextTarget = this.moveVertical(1);
|
|
break;
|
|
}
|
|
|
|
if (nextTarget) {
|
|
this.setFocus(nextTarget);
|
|
}
|
|
} else if (e.key === 'Enter') {
|
|
if (this.currentFocus) {
|
|
this.currentFocus.click();
|
|
}
|
|
}
|
|
}
|
|
|
|
focusFirstVisible() {
|
|
// Find first video card in viewport
|
|
const candidates = document.querySelectorAll('.video-card');
|
|
if (candidates.length > 0) {
|
|
this.setFocus(candidates[0]);
|
|
}
|
|
}
|
|
|
|
setFocus(el) {
|
|
if (this.currentFocus) {
|
|
this.currentFocus.classList.remove('keyboard-focused');
|
|
// Trigger mouseleave logic if needed to reset z-index?
|
|
}
|
|
|
|
this.currentFocus = el;
|
|
el.classList.add('keyboard-focused');
|
|
el.focus({ preventScroll: true }); // Native focus
|
|
|
|
// Smooth scroll into view
|
|
el.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center',
|
|
inline: 'center'
|
|
});
|
|
}
|
|
|
|
moveHorizontal(direction) {
|
|
// 1. Try siblings first (if in a list)
|
|
// If direction is 1 (Right), look for nextElementSibling
|
|
if (!this.currentFocus) return null;
|
|
|
|
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
const currentIndex = allFocusable.indexOf(this.currentFocus);
|
|
|
|
if (currentIndex === -1) return null;
|
|
|
|
const nextIndex = currentIndex + direction;
|
|
if (nextIndex >= 0 && nextIndex < allFocusable.length) {
|
|
// Simple DOM order check
|
|
// BUT for sliders, DOM order matches visual order usually.
|
|
// Check if they are in the same container?
|
|
// If dragging across rows, Horizontal arrow shouldn't jump rows if possible?
|
|
// But flattening functionality is easier: just go to next DOM element.
|
|
|
|
// Refinement: If next element is in a DIFFERENT slider row, only jump if it's logically close?
|
|
// Ideally Right Arrow should stay in row.
|
|
|
|
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
const nextEl = allFocusable[nextIndex];
|
|
const nextRect = nextEl.getBoundingClientRect();
|
|
|
|
// Heuristic: If vertical distance is large, it's a new row.
|
|
// If delta Y > height/2, maybe block horizontal nav?
|
|
const verticalDist = Math.abs(currentRect.top - nextRect.top);
|
|
if (verticalDist > currentRect.height * 0.5) {
|
|
// New row. Should arrow keys wrap?
|
|
// User said "scrollable to the right". Usually means stay in row or wrap.
|
|
// Let's allow wrapping for now, or strict row logic?
|
|
// Strict Row Logic is better for TV.
|
|
// If I am at end of row, right arrow does nothing or goes to "Next" button?
|
|
|
|
// Let's rely on simple DOM order for now as "good enough" for v1
|
|
// except if the user specifically requested "scrollable right".
|
|
// If I press Right at end of row, and it jumps to next row, that's okay.
|
|
}
|
|
return nextEl;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
moveVertical(direction) {
|
|
// Find closest element in the visual direction
|
|
if (!this.currentFocus) return null;
|
|
|
|
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
const centerX = currentRect.left + currentRect.width / 2;
|
|
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
|
|
// Filter elements that are strictly Above/Below
|
|
const candidates = allFocusable.filter(el => {
|
|
if (el === this.currentFocus) return false;
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
if (direction === 1) { // Down
|
|
return rect.top >= currentRect.bottom - (currentRect.height * 0.2); // permit slight overlap
|
|
} else { // Up
|
|
return rect.bottom <= currentRect.top + (currentRect.height * 0.2);
|
|
}
|
|
});
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
// Find the one with minimum distance
|
|
// Distance = Vertical Diff + Horizontal Diff penalty
|
|
let bestCandidate = null;
|
|
let minDistance = Infinity;
|
|
|
|
candidates.forEach(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
const elCenterX = rect.left + rect.width / 2;
|
|
const elCenterY = rect.top + rect.height / 2;
|
|
|
|
// Vertical distance (primary)
|
|
const vDist = Math.abs(rect.top - currentRect.top);
|
|
|
|
// Horizontal alignment penalty
|
|
const hDist = Math.abs(elCenterX - centerX);
|
|
|
|
// Weighted distance: Vertical matter, but horizontally closest is best within that band.
|
|
// Actually, we usually want the "row immediately below".
|
|
// So sort by Vertical distance first.
|
|
|
|
// Simple Euclidean distance?
|
|
const dist = Math.sqrt(Math.pow(vDist, 2) + Math.pow(hDist, 2));
|
|
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
bestCandidate = el;
|
|
}
|
|
});
|
|
|
|
return bestCandidate;
|
|
}
|
|
}
|