Improve tab navigation and gamepad D-pad support
Enhanced directional navigation logic to prioritize active tab panel content when moving from tab links, improving keyboard and controller accessibility. Added robust D-pad detection for gamepads, supporting both button and axis-based D-pads for broader controller compatibility. Updated settings tab logic to allow ArrowDown/ArrowRight to move focus from tabs to the active panel, with global fallback for improved accessibility.
This commit is contained in:
+100
-14
@@ -556,8 +556,15 @@ function findElementInDirection(direction) {
|
|||||||
y: currentRect.top + currentRect.height / 2
|
y: currentRect.top + currentRect.height / 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Detect if current element is in sidebar, header, or content area
|
||||||
|
const currentContainer = current.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content');
|
||||||
|
|
||||||
|
// Special case: if on a tab link in settings and going down/right, prioritize active panel content
|
||||||
|
const isTabLink = current.classList.contains('tab-link') || current.closest('.tabs, .tab-link');
|
||||||
|
const isActiveTab = current.classList.contains('active');
|
||||||
|
|
||||||
let bestIndex = state.focusIndex;
|
let bestIndex = state.focusIndex;
|
||||||
let bestDistance = Infinity;
|
let bestScore = Infinity;
|
||||||
|
|
||||||
state.focusableElements.forEach((element, index) => {
|
state.focusableElements.forEach((element, index) => {
|
||||||
if (element === current) return;
|
if (element === current) return;
|
||||||
@@ -568,31 +575,70 @@ function findElementInDirection(direction) {
|
|||||||
y: rect.top + rect.height / 2
|
y: rect.top + rect.height / 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Detect element's container
|
||||||
|
const elementContainer = element.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content');
|
||||||
|
const sameContainer = currentContainer === elementContainer;
|
||||||
|
|
||||||
|
// Check if element is in active tab panel
|
||||||
|
const inActivePanel = element.closest('.tab-panel.active');
|
||||||
|
|
||||||
// Check if element is in the correct direction
|
// Check if element is in the correct direction
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
|
let alignmentScore = 0;
|
||||||
|
let distanceInDirection = 0;
|
||||||
|
let distancePerpendicular = 0;
|
||||||
|
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case 'up':
|
case 'up':
|
||||||
isValid = center.y < currentCenter.y - 10;
|
isValid = center.y < currentCenter.y - 10;
|
||||||
|
distanceInDirection = currentCenter.y - center.y;
|
||||||
|
distancePerpendicular = Math.abs(center.x - currentCenter.x);
|
||||||
|
// Prioritize elements in the same vertical column
|
||||||
|
alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular;
|
||||||
break;
|
break;
|
||||||
case 'down':
|
case 'down':
|
||||||
isValid = center.y > currentCenter.y + 10;
|
isValid = center.y > currentCenter.y + 10;
|
||||||
|
distanceInDirection = center.y - currentCenter.y;
|
||||||
|
distancePerpendicular = Math.abs(center.x - currentCenter.x);
|
||||||
|
// Prioritize elements in the same vertical column
|
||||||
|
alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular;
|
||||||
break;
|
break;
|
||||||
case 'left':
|
case 'left':
|
||||||
isValid = center.x < currentCenter.x - 10;
|
isValid = center.x < currentCenter.x - 10;
|
||||||
|
distanceInDirection = currentCenter.x - center.x;
|
||||||
|
distancePerpendicular = Math.abs(center.y - currentCenter.y);
|
||||||
|
// Prioritize elements in the same horizontal row
|
||||||
|
alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular;
|
||||||
break;
|
break;
|
||||||
case 'right':
|
case 'right':
|
||||||
isValid = center.x > currentCenter.x + 10;
|
isValid = center.x > currentCenter.x + 10;
|
||||||
|
distanceInDirection = center.x - currentCenter.x;
|
||||||
|
distancePerpendicular = Math.abs(center.y - currentCenter.y);
|
||||||
|
// Prioritize elements in the same horizontal row
|
||||||
|
alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
const distance = Math.sqrt(
|
// Calculate score - lower is better
|
||||||
Math.pow(center.x - currentCenter.x, 2) +
|
// Heavily favor same container, then alignment, then distance
|
||||||
Math.pow(center.y - currentCenter.y, 2)
|
let score = distanceInDirection + alignmentScore * 3;
|
||||||
);
|
|
||||||
|
|
||||||
if (distance < bestDistance) {
|
// Special handling: if on active tab and going down/right, strongly prefer active panel content
|
||||||
bestDistance = distance;
|
if (isTabLink && isActiveTab && (direction === 'down' || direction === 'right')) {
|
||||||
|
if (inActivePanel) {
|
||||||
|
score = distanceInDirection * 0.1; // Extremely high priority for panel content
|
||||||
|
} else {
|
||||||
|
score += 5000; // Very large penalty for non-panel elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, strong bonus for staying in same container (sidebar, content, etc.)
|
||||||
|
else if (!sameContainer) {
|
||||||
|
score += 2000; // Large penalty for leaving container
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
bestIndex = index;
|
bestIndex = index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,16 +803,56 @@ function pollGamepad() {
|
|||||||
requestAnimationFrame(pollGamepad);
|
requestAnimationFrame(pollGamepad);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDpadFromButtons(gamepad) {
|
||||||
|
const up = !!gamepad.buttons[12]?.pressed;
|
||||||
|
const down = !!gamepad.buttons[13]?.pressed;
|
||||||
|
const left = !!gamepad.buttons[14]?.pressed;
|
||||||
|
const right = !!gamepad.buttons[15]?.pressed;
|
||||||
|
return { up, down, left, right, active: up || down || left || right, source: 'buttons' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDpadFromAxes(gamepad) {
|
||||||
|
const axes = gamepad.axes || [];
|
||||||
|
const candidates = [
|
||||||
|
{ x: 6, y: 7 },
|
||||||
|
{ x: 9, y: 10 },
|
||||||
|
{ x: 4, y: 5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { x, y } of candidates) {
|
||||||
|
if (axes.length <= Math.max(x, y)) continue;
|
||||||
|
const ax = axes[x] || 0;
|
||||||
|
const ay = axes[y] || 0;
|
||||||
|
if (Math.abs(ax) > 0.5 || Math.abs(ay) > 0.5) {
|
||||||
|
return {
|
||||||
|
up: ay < -0.5,
|
||||||
|
down: ay > 0.5,
|
||||||
|
left: ax < -0.5,
|
||||||
|
right: ax > 0.5,
|
||||||
|
active: true,
|
||||||
|
source: 'axes'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { up: false, down: false, left: false, right: false, active: false, source: 'axes' };
|
||||||
|
}
|
||||||
|
|
||||||
function handleGamepadInput(gamepad) {
|
function handleGamepadInput(gamepad) {
|
||||||
// D-pad and left stick for navigation
|
// D-pad and left stick for navigation
|
||||||
const leftX = gamepad.axes[0];
|
const leftX = gamepad.axes[0] || 0;
|
||||||
const leftY = gamepad.axes[1];
|
const leftY = gamepad.axes[1] || 0;
|
||||||
|
|
||||||
// D-pad buttons (indices may vary by controller)
|
// D-pad buttons/axes (indices may vary by controller)
|
||||||
const dpadUp = gamepad.buttons[12]?.pressed;
|
const buttonDpad = readDpadFromButtons(gamepad);
|
||||||
const dpadDown = gamepad.buttons[13]?.pressed;
|
const axisDpad = readDpadFromAxes(gamepad);
|
||||||
const dpadLeft = gamepad.buttons[14]?.pressed;
|
const dpad = axisDpad.active && (!buttonDpad.active || gamepad.mapping !== 'standard')
|
||||||
const dpadRight = gamepad.buttons[15]?.pressed;
|
? axisDpad
|
||||||
|
: buttonDpad;
|
||||||
|
const dpadUp = dpad.up;
|
||||||
|
const dpadDown = dpad.down;
|
||||||
|
const dpadLeft = dpad.left;
|
||||||
|
const dpadRight = dpad.right;
|
||||||
|
|
||||||
// Analog stick with deadzone
|
// Analog stick with deadzone
|
||||||
const stickUp = leftY < -CONFIG.STICK_DEADZONE;
|
const stickUp = leftY < -CONFIG.STICK_DEADZONE;
|
||||||
|
|||||||
@@ -312,6 +312,30 @@ function activateTab(tabName) {
|
|||||||
function initTabs() {
|
function initTabs() {
|
||||||
const links = document.querySelectorAll('.tab-link');
|
const links = document.querySelectorAll('.tab-link');
|
||||||
|
|
||||||
|
const getFocusableElements = (container) => {
|
||||||
|
if (!container) return [];
|
||||||
|
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
return Array.from(container.querySelectorAll(selector))
|
||||||
|
.filter(el => !el.disabled && el.getAttribute('aria-hidden') !== 'true' && el.offsetParent !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusFirstInActivePanel = () => {
|
||||||
|
const activePanel = document.querySelector('.tab-panel.active') || null;
|
||||||
|
const focusables = getFocusableElements(activePanel);
|
||||||
|
if (focusables.length > 0) {
|
||||||
|
focusables[0].focus({ preventScroll: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (activePanel) {
|
||||||
|
if (!activePanel.hasAttribute('tabindex')) {
|
||||||
|
activePanel.setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
activePanel.focus({ preventScroll: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Direct listeners (for accessibility focus handling)
|
// Direct listeners (for accessibility focus handling)
|
||||||
links.forEach((link, index) => {
|
links.forEach((link, index) => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
@@ -324,6 +348,18 @@ function initTabs() {
|
|||||||
}
|
}
|
||||||
activateTab(name);
|
activateTab(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Controller/keyboard: move from tab to panel content
|
||||||
|
link.addEventListener('keydown', (e) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||||
|
const moved = focusFirstInActivePanel();
|
||||||
|
if (moved) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delegation as a fallback if elements are re-rendered
|
// Delegation as a fallback if elements are re-rendered
|
||||||
@@ -341,6 +377,24 @@ function initTabs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global fallback: if focus is on sidebar tabs, move into active panel on down/right
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowRight') return;
|
||||||
|
|
||||||
|
const activeEl = document.activeElement;
|
||||||
|
const inTabs = activeEl && (activeEl.classList?.contains('tab-link') || activeEl.closest?.('.tabs'));
|
||||||
|
const inSidebar = activeEl && activeEl.closest?.('.sidebar');
|
||||||
|
|
||||||
|
if (inTabs || inSidebar) {
|
||||||
|
const moved = focusFirstInActivePanel();
|
||||||
|
if (moved) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Resolve initial tab: hash > storage > default 'general'
|
// Resolve initial tab: hash > storage > default 'general'
|
||||||
let initial = (location.hash || '').replace('#', '') || null;
|
let initial = (location.hash || '').replace('#', '') || null;
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
|
|||||||
Reference in New Issue
Block a user