From e2a2dbed6da1544cdd81dd980a4d1ea5aa604acb Mon Sep 17 00:00:00 2001 From: Johannes Emerich Date: Mon, 26 Aug 2024 12:12:42 +0200 Subject: [PATCH 1/4] Add basic spatial navigation using focus-shift This is vendoring focus-shift from https://github.com/dividat/focus-shift/tree/295664147d2253e86e65440b12663dddae9b48db --- controller/dune | 1 + controller/gui/vendor/focus-shift.js | 962 ++++++++++++++++++++++++++ controller/server/view/common/page.ml | 1 + 3 files changed, 964 insertions(+) create mode 100644 controller/gui/vendor/focus-shift.js diff --git a/controller/dune b/controller/dune index 5d996ef9..d2a054b0 100644 --- a/controller/dune +++ b/controller/dune @@ -4,6 +4,7 @@ (Changelog.html as Changelog.html) (gui/reset.css as static/reset.css) (gui/style.css as static/style.css) + (gui/vendor/focus-shift.js as static/vendor/focus-shift.js) (gui/client.js as static/client.js))) ; Disable missing-record-field-pattern warnings (partial matching), diff --git a/controller/gui/vendor/focus-shift.js b/controller/gui/vendor/focus-shift.js new file mode 100644 index 00000000..7fe70725 --- /dev/null +++ b/controller/gui/vendor/focus-shift.js @@ -0,0 +1,962 @@ +/** + * @overview focus-shift, library for spatial navigation with arrow keys + * + * https://github.com/dividat/focus-shift + * + * @copyright Dividat AG, 2024 + * @license MIT + */ + +function init() { + document.addEventListener("keydown", handleKeyDown) +} + +/** + * Handle any keydown event and decide whether it should be used for navigation. + * + * @param {KeyboardEvent} event + * @returns {void} + */ +function handleKeyDown(event) { + const direction = KEY_TO_DIRECTION[event.key] + + // Ignore irrelevant inputs + if ( + direction == null || + hasModifiers(event) || + isInputInteraction(direction, event) + ) { + return + } else { + const eventTarget = document.activeElement || document.body + const shiftFocusEvent = new CustomEvent("focus-shift:initiate", { + detail: { keyboardEvent: event }, + cancelable: true, + bubbles: true + }) + eventTarget.dispatchEvent(shiftFocusEvent) + + logging.group(`focus-shift: ${event.key}`) + if (shiftFocusEvent.defaultPrevented) { + logging.debug( + "Handling canceled via 'focus-shift:initiate' event", + shiftFocusEvent + ) + } else { + event.preventDefault() + handleUserDirection(KEY_TO_DIRECTION[event.key]) + } + logging.groupEnd() + } +} + +/** + * Handle a user's request for focus shift. + * + * @param {Direction} direction + * @returns {void} + */ +function handleUserDirection(direction) { + const container = getBlockingElement() + const activeElement = getActiveElement(container) + + if (activeElement == null) { + focusInitial(direction, container) + return + } + + const candidates = getFocusCandidates(direction, activeElement, container) + if (candidates.length > 0) { + performMove(direction, activeElement.getBoundingClientRect(), candidates) + } +} + +/** + * Apply the initial focus within the given container. + * + * Standard heuristics are used to determine which element should be the first to receive focus. + * + * 1. Look for elements with explicit tabindex attribute set, choose lowest index > 0 + * 2. If no tabindex was set, treat container as a 'linear' group + * + * @param {Direction} direction + * @param {Element} container + * @returns {void} + */ +function focusInitial(direction, container) { + // 1. tabindex + /** @type {Array} */ + const tabindexed = Array.from(container.querySelectorAll("[tabindex]")) + .filter(hasTabIndex) + .filter((elem) => elem.tabIndex > 0) + const markedElement = getMinimumBy(tabindexed, (elem) => elem.tabIndex) + if (markedElement != null) { + applyFocus(direction, makeVirtualOrigin(direction), markedElement) + return + } + + // 2. 'linear' group + focusLinear(direction, makeVirtualOrigin(direction), container) +} + +/** + * Get all focusable elements within the container. + * + * Only the top-most elements are returned, any descendants of focusable elements are omitted. + * + * @param {Element} container + * @returns {Element[]} + */ +function getFocusableElements(container) { + const selector = + '[data-focus-group], [tabindex], a[href], button, input, textarea, select, [contenteditable="true"], summary' + + // Find the focusable elements within the container + const focusableElements = Array.from( + container.querySelectorAll(selector) + ).filter(isFocusable) + // Reduce to the focusable elements highest up the tree + const topMostElements = focusableElements.filter((elem) => { + return ( + elem.parentElement != null && + (elem.parentElement.closest(selector) === container || + elem.parentElement.closest(selector) == null) + ) + }) + + return topMostElements +} + +/** + * Tests whether an element may be focused using the keyboard. + * + * An element is inert for the purposes of this library if one or more of the following apply: + * + * - it has negative tabindex, + * - it has been marked with `data-focus-skip`, + * - it is a descendant of an element marked with `data-focus-skip`, + * - it is a descendant of a closed `details` element, + * - it is `disabled`, + * - it is `inert`. + * + * Otherwise it counts as focusable. + * + * Properties are tested for before access, as the function may receive non-HTML elements. + * + * @param {Element} element + * @returns {boolean} - True if the element may be focused using the keyboard + */ +function isFocusable(element) { + // Has negative tabindex attribute explicitly set + if (parseInt(element.getAttribute("tabindex") || "", 10) <= -1) return false + // Is inert + if ("inert" in element && element.inert) return false + // Is disabled + if ("disabled" in element && element.disabled) return false + // Is or descends from skipped element + if ( + element.hasAttribute("data-focus-skip") || + element.closest("[data-focus-skip]") != null + ) + return false + // Descends from closed details element + if (hasClosedDetailsAncestor(element)) return false + + return true +} + +/** + * Tests whether the element is contained within a closed `details` element. + * + * `summary` elements are excluded (return value `false`) if they are the summary of the top-most + * closed `details` element. + * + * @param {Element} element + * @returns {boolean} - True if the element is hidden because of descending from closed `details` + */ +function hasClosedDetailsAncestor(element) { + if (element.parentElement == null) return false + + const parentElement = element.parentElement + if (element.tagName === "SUMMARY") { + return hasClosedDetailsAncestor(parentElement) + } else { + return parentElement.closest("details:not([open])") != null + } +} + +/** + * Get all candidates for receiving focus when moving from the active element in the given direction. + * + * @param {Direction} direction + * @param {Element} activeElement + * @param {Element} container + * @returns {Array} - All elements that lie in the direction of movement from the active element + */ +function getFocusCandidates(direction, activeElement, container) { + const activeRect = activeElement.getBoundingClientRect() + + let nextParent = activeElement || container + let candidateElements = [] + + do { + nextParent = + (nextParent.parentElement && + nextParent.parentElement.closest("[data-focus-group]")) || + container + + const annotatedElements = getFocusableElements(nextParent).map((e) => + annotate(direction, activeRect, e) + ) + + candidateElements = annotatedElements.filter(({ rect }) => { + switch (direction) { + case "left": + return Math.floor(rect.right) <= activeRect.left + case "up": + return Math.floor(rect.bottom) <= activeRect.top + case "right": + return Math.ceil(rect.left) >= activeRect.right + case "down": + return Math.ceil(rect.top) >= activeRect.bottom + } + }) + } while (candidateElements.length === 0 && nextParent !== container) + + return candidateElements +} + +/** + * Perform a move, guaranteeing that focus is going to change if `candidates` is non-empty. + * + * This function only selects the "best" from the list of candidates it is given. + * + * @param {Direction} direction + * @param {DOMRect} originRect - The bounding box of the element that has focus at the time the move is initiated + * @param {Array} candidates - The candidates from which to pick + * @returns {void} + */ +function performMove(direction, originRect, candidates) { + logging.debug("performMove", direction, originRect, candidates) + + const originPoint = makeOrigin(direction, originRect) + + const candidatesInDirectProjection = candidates.filter((candidate) => + isWithinProjection(direction, originRect, candidate.rect) + ) + + if (candidatesInDirectProjection.length > 0) { + candidates = candidatesInDirectProjection + } + + const bestCandidate = getMinimumBy(candidates, (candidate) => + euclidean(originPoint, candidate.point) + ) + if (bestCandidate != null) { + applyFocus(direction, originRect, bestCandidate.element) + } +} + +/** + * Apply focus to an element, descending into it if it is a group. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} target + * @returns {void} + */ +function applyFocus(direction, origin, target) { + logging.debug("applyFocus", direction, target) + + const parentGroup = target.closest("[data-focus-group]") + if ( + parentGroup != null && + parentGroup != target && + getGroupType(parentGroup) === "memorize" + ) { + const memorizingElement = /** @type {MemorizingElement} */ (parentGroup) + memorizingElement.lastFocused = target + } + + if (isGroup(target)) { + dispatchGroupFocus(direction, origin, target) + } else if ("focus" in target && typeof target.focus === "function") { + const preventScroll = target.hasAttribute("data-focus-prevent-scroll") + target.focus({ preventScroll: preventScroll }) + } +} + +// +// Containers and focus traps +// + +/** + * Get the top-most blocking element on the page. + * + * This returns `document.body` if no other blocking elements are found. + * + * You can give a trap index to your elements, higher indices block lower + * indices. Just `data-focus-trap` is equivalent to `data-focus-trap="0"`. + * + * NOTE Because the web APIs are lacking, we have to determine the order of + * blocking elements heuristically. See open spec issues: + * + * - https://github.com/whatwg/html/issues/897 + * - https://github.com/whatwg/html/issues/8783 + * - https://github.com/whatwg/html/issues/9075 + * + * To work around this limitation you can use explicit trap indices. + * + * @returns {Element} + */ +function getBlockingElement() { + /** @type {Element[]} */ + let trapElements = [] + try { + // Try top-layer pseudo class (2022+ browsers) + trapElements = Array.from(document.querySelectorAll(":modal")) + } catch (e) { + logging.debug("Browser does not support ':modal' selector, ignoring.") + } + // If none, use fallback selector + if (trapElements.length === 0) { + trapElements = Array.from( + document.querySelectorAll("dialog[open], [data-focus-trap]") + ) + } + + // If no explicit trap elements were found, body is the top element + return ( + getMinimumBy(trapElements, (elem) => -getTrapIndex(elem)) || document.body + ) +} + +/** + * Get the trap index for an element. + * + * - The numeric value of `data-focus-trap` if attribute is set + * - `0` if the element has a boolean `data-focus-trap` attribute + * - `0` if the element is an open dialog element + * - `-Infinity` otherwise + * + * @param {Element} element + * @returns {number} + */ +function getTrapIndex(element) { + const attribute = element.getAttribute("data-focus-trap") + if (typeof attribute === "string" && /\d+/.test(attribute)) { + return parseInt(attribute, 10) + } else if (element.hasAttribute("data-focus-trap")) { + return 0 + } else if ( + element.tagName === "DIALOG" && + "open" in element && + element.open + ) { + return 0 + } else { + return -Infinity + } +} + +/** + * Get the currently active element, only within the given container. + * + * It might be that the document element has an active element, but the + * container does not. In this case the function returns `null`. + * + * @param {Element} container + * @returns {Element | null} + */ +function getActiveElement(container) { + const activeElement = document.activeElement + if ( + // The activeElement may be `null` or `document.body` if no element has focus + // https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement#value + activeElement == null || + activeElement === container || + // Ignore the activeElement if it is not within container + !container.contains(activeElement) + ) { + return null + } + + return activeElement +} + +// +// Groups +// + +/** + * @typedef {'first' | 'last' | 'linear' | 'active' | 'memorize'} GroupType + */ + +/** + * Tests whether the element is annotated to be a group. + * + * @param {Element} element + * @returns {boolean} - True if the element is a group + */ +function isGroup(element) { + return getGroupType(element) != null +} + +/** + * Get the group type for an element, if any. + * + * @param {Element} element + * @returns {GroupType | null} - The group type, or `null` if element is not a group + */ +function getGroupType(element) { + if (!element.hasAttribute("data-focus-group")) { + return null + } + + const str = element.getAttribute("data-focus-group") + switch (str) { + case "first": + case "last": + case "linear": + case "active": + case "memorize": + return str + case "": + case null: + return "linear" + default: + console.warn(`Invalid focus group type: ${str}`) + return null + } +} + +/** + * Dispatches focus within a group. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + * @returns {void} + */ +function dispatchGroupFocus(direction, origin, group) { + const strategy = getGroupType(group) + switch (strategy) { + case "first": + focusFirstElement(direction, origin, group) + break + case "last": + focusLastElement(direction, origin, group) + break + case "active": + focusActiveElement(direction, origin, group) + break + case "linear": + focusLinear(direction, origin, group) + break + case "memorize": + focusMemorized(direction, origin, group) + break + } +} + +/** + * Focuses the first element in the given focus group. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + */ +function focusFirstElement(direction, origin, group) { + const focusables = getFocusableElements(group) + if (focusables.length > 0) { + applyFocus(direction, origin, focusables[0]) + } +} + +/** + * Focuses the last element in the given navigation group. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + */ +function focusLastElement(direction, origin, group) { + const focusables = getFocusableElements(group) + if (focusables.length > 0) { + applyFocus(direction, origin, focusables[focusables.length - 1]) + } +} + +/** + * Focuses the active element in the given navigation group. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + */ +function focusActiveElement(direction, origin, group) { + const activeElement = getFocusableElements(group).find((elem) => + elem.hasAttribute("data-focus-active") + ) + if (activeElement) { + applyFocus(direction, origin, activeElement) + } else { + focusFirstElement(direction, origin, group) + } +} + +/** + * Moves focus linearly in the direction of "travel". + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + */ +function focusLinear(direction, origin, group) { + const originPoint = makeOrigin(opposite(direction), origin) + const candidates = getFocusableElements(group).map((candidate) => + annotate(direction, origin, candidate) + ) + const bestCandidate = getMinimumBy(candidates, (candidate) => + euclidean(originPoint, candidate.point) + ) + if (bestCandidate != null) { + applyFocus(direction, origin, bestCandidate.element) + } +} + +/** + * Moves focus to the last focused element in the group. + * + * If a previously memorized element can not be found, behave as 'linear'. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {Element} group + */ +function focusMemorized(direction, origin, group) { + if (isMemorizing(group) && group.contains(group.lastFocused)) { + applyFocus(direction, origin, group.lastFocused) + } else { + focusLinear(direction, origin, group) + } +} + +/** + * @typedef {Element & { lastFocused: Element; }} MemorizingElement - An HTML element with an additional memorized element property + */ + +/** + * Type guard for memorizing elements. + * + * @param {Element} elem + * @returns {elem is MemorizingElement} + * */ +function isMemorizing(elem) { + return "lastFocused" in elem && elem.lastFocused instanceof Element +} + +// +// DOM and Events +// + +/** + * Tests whether the keyboard event announces any modifier keys. + * + * @param {KeyboardEvent} e + * @returns {boolean} + */ +function hasModifiers(e) { + return ( + e.shiftKey || + e.ctrlKey || + e.metaKey || + e.altKey || + e.getModifierState("CapsLock") + ) +} + +/** + * Tests whether the keyboard event is a form interaction that should not lead to focus shifts. + * + * Adapted from the Spatial Navigation Polyfill. + * + * Original Copyright (c) 2018-2019 LG Electronics Inc. + * Source: https://github.com/WICG/spatial-navigation/polyfill + * Licensed under the MIT license (MIT) + * + * @param {Direction} direction - The direction read from the keydown event + * @param {KeyboardEvent} event - The original keydown event + * @returns {boolean} + */ +function isInputInteraction(direction, event) { + const eventTarget = document.activeElement + + if ( + eventTarget instanceof HTMLInputElement || + eventTarget instanceof HTMLTextAreaElement + ) { + const targetType = eventTarget.getAttribute("type") + const isTextualInput = [ + "email", + "password", + "text", + "search", + "tel", + "url", + null + ].includes(targetType) + const isSpinnable = + targetType != null && + ["date", "month", "number", "time", "week"].includes(targetType) + + if (isTextualInput || isSpinnable || eventTarget.nodeName === "TEXTAREA") { + // If there is a selection, assume user action is an input interaction + if (eventTarget.selectionStart !== eventTarget.selectionEnd) { + return true + // If there is only the cursor, check if it is natural to leave the element in given direction + } else { + const cursorPosition = eventTarget.selectionStart + const isVerticalMove = direction === "up" || direction === "down" + + if (eventTarget.value.length === 0) { + // If field is empty, leave in any direction + return false + } else if (cursorPosition == null) { + // If cursor position was not given, we always exit unless we see a "spinning" input + return isSpinnable && isVerticalMove + } else if (cursorPosition === 0) { + // Cursor at beginning + return direction === "right" || (isSpinnable && isVerticalMove) + } else if (cursorPosition === eventTarget.value.length) { + // Cursor at end + return direction === "left" || (isSpinnable && isVerticalMove) + } else { + // Cursor in middle + return ( + direction === "left" || + direction === "right" || + (isSpinnable && isVerticalMove) + ) + } + } + } else { + return false + } + } else { + return false + } +} + +/** + * Type guard for tabindexed elements. + * + * @param {Element} elem + * @returns {elem is Element & { tabIndex: number; }} + * */ +function hasTabIndex(elem) { + return "tabIndex" in elem && typeof elem.tabIndex === "number" +} + +// +// Geometry +// + +/** + * @typedef {'up' | 'right' | 'down' | 'left'} Direction + */ + +/** + * Returns the opposite direction. + * + * @param {Direction} direction + * @returns {Direction} + */ +function opposite(direction) { + switch (direction) { + case "left": + return "right" + case "up": + return "down" + case "right": + return "left" + case "down": + return "up" + } +} + +/** + * Make the target point for a move between origin and target rect in given direction. + * + * @param {Direction} direction + * @param {DOMRect} originRect + * @param {DOMRect} targetRect + * @returns {Point} + */ +function makeTarget(direction, originRect, targetRect) { + switch (direction) { + case "left": + return { + x: targetRect.right, + y: closestTo( + (originRect.top + originRect.bottom) / 2, + targetRect.top, + targetRect.bottom + ) + } + case "up": + return { + x: closestTo( + (originRect.left + originRect.right) / 2, + targetRect.left, + targetRect.right + ), + y: targetRect.bottom + } + case "right": + return { + x: targetRect.left, + y: closestTo( + (originRect.top + originRect.bottom) / 2, + targetRect.top, + targetRect.bottom + ) + } + case "down": + return { + x: closestTo( + (originRect.left + originRect.right) / 2, + targetRect.left, + targetRect.right + ), + y: targetRect.top + } + } +} + +/** + * Make the origin point for a move between origin and target rect in given direction. + * + * @param {Direction} direction + * @param {DOMRect} originRect + * @returns {Point} + */ +function makeOrigin(direction, originRect) { + switch (direction) { + case "left": + return { x: originRect.left, y: originRect.top + originRect.height / 2 } + case "up": + return { x: originRect.left + originRect.width / 2, y: originRect.top } + case "right": + return { x: originRect.right, y: originRect.top + originRect.height / 2 } + case "down": + return { x: originRect.left + originRect.width / 2, y: originRect.bottom } + } +} + +/** + * Make the virtual origin a movement would be expected to come from. + * + * This allows us to jump into the viewport from any of the four directions. + * + * │ + * ▼ ArrowDown + * ArrowRight ──►┌────────────────────────┐◄─ + * │ │ ArrowLeft + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * └────────────────────────┘ + * ▲ + * │ ArrowUp + * + * To keep it simple and based on own needs this assumes LTR text direction. + * It could try to determine the user agent's preferred direction instead. + * + * @param {Direction} direction + * @returns {DOMRect} - The region of the virtual origin + */ +function makeVirtualOrigin(direction) { + const width = window.innerWidth + const height = window.innerHeight + switch (direction) { + case "down": + case "right": + return DOMRect.fromRect({ x: 0, y: 0, width: 0, height: 0 }) + case "left": + return DOMRect.fromRect({ x: width, y: 0, width: 0, height: 0 }) + case "up": + return DOMRect.fromRect({ x: 0, y: height, width: 0, height: 0 }) + } +} + +/** + * Map the `key` property of a keyboard event to a `Direction`. + * + * @type {Object.} + */ +const KEY_TO_DIRECTION = { + ArrowUp: "up", + ArrowRight: "right", + ArrowDown: "down", + ArrowLeft: "left" +} + +/** + * @typedef {Object} AnnotatedElement - An HTML element annotated with spatial information, specific to a move + * @property {Element} element - The element + * @property {DOMRect} rect - The bounding box for the element + * @property {Point} point - The point defined as characteristic for the given move + */ + +/** + * Annotate an element with meta information for a given move. + * + * @param {Direction} direction + * @param {DOMRect} originRect + * @param {Element} element + * @returns {AnnotatedElement} + */ +function annotate(direction, originRect, element) { + const rect = element.getBoundingClientRect() + return { + element: element, + rect: rect, + point: makeTarget(direction, originRect, rect) + } +} + +/** + * @typedef {{ x: number; y: number; }} Point + */ + +/** + * Computes the Euclidean distance between two points. + * + * @param {Point} a + * @param {Point} b + * @returns {number} + */ +function euclidean(a, b) { + return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) +} + +/** + * Find the value closest to a given value that lies within the interval. + * + * @param {number} val - The value of interest + * @param {number} intervalLower - The lower boundary of the interval + * @param {number} intervalUpper - The upper boundary of the interval + * @returns {number} - The value within the interval that is closest to the value of interest + */ +function closestTo(val, intervalLower, intervalUpper) { + if (val >= intervalLower && val <= intervalUpper) { + return val + } else if (val > intervalUpper) { + return intervalUpper + } else { + return intervalLower + } +} + +/** + * Tests whether the candidate lies within the directed projection from the origin. + * + * @param {Direction} direction + * @param {DOMRect} origin + * @param {DOMRect} candidate + * @returns {boolean} - True if the candidate lies within the projection + */ +function isWithinProjection(direction, origin, candidate) { + switch (direction) { + case "left": + case "right": + return hasOverlap( + candidate.top, + candidate.bottom, + origin.top, + origin.bottom + ) + case "up": + case "down": + return hasOverlap( + candidate.left, + candidate.right, + origin.left, + origin.right + ) + default: + return false + } +} + +/** + * Tests whether two intervals overlap. + * + * @param {number} start1 - The start of the first interval + * @param {number} end1 - The end of the first interval + * @param {number} start2 - The start of the second interval + * @param {number} end2 - The end of the second interval + * @returns {boolean} + */ +function hasOverlap(start1, end1, start2, end2) { + return !(start1 > end2 || start2 > end1) +} + +// +// Generic utilities +// + +/** + * Returns the element in `array` for which `toNumeric` is minimal. + * + * @template T + * @param {Array} array + * @param {(item: T) => number} toNumber + * @returns {T | null} + */ +function getMinimumBy(array, toNumber) { + let minVal = Infinity + let min = null + let currentVal = Infinity + + for (let current of array) { + currentVal = toNumber(current) + + if (currentVal < minVal) { + minVal = currentVal + min = current + } + } + + return min +} + +const logging = /** @type {Console} */ ( + new Proxy(console, { + get: /** @type {(target: any, level: any) => any} */ ( + function (target, level) { + if ("FOCUS_SHIFT_DEBUG" in window && window.FOCUS_SHIFT_DEBUG) { + if (level in target && typeof target[level] === "function") { + return /** @type {(args: any[]) => void} */ ( + function (...args) { + target[level].apply(target, args) + } + ) + } else if (level in target) { + return target[level] + } + } else { + return function () {} + } + } + ) + }) +) + +init() diff --git a/controller/server/view/common/page.ml b/controller/server/view/common/page.ml index bfa3a107..bddcfed1 100644 --- a/controller/server/view/common/page.ml +++ b/controller/server/view/common/page.ml @@ -78,6 +78,7 @@ let html ?current_page ?header content = @ [ main ~a:[ a_class [ "d-Layout__Main" ] ] [ content ] + ; script ~a:[ a_src "/static/vendor/focus-shift.js" ] (txt "") ; script ~a:[ a_src "/static/client.js" ] (txt "") ]) ) From ab37ba382932c7c89b5d0973db4a38ae6098009b Mon Sep 17 00:00:00 2001 From: Johannes Emerich Date: Mon, 26 Aug 2024 16:26:56 +0200 Subject: [PATCH 2/4] Add annotations to guide focus-shift --- controller/server/view/common/page.ml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/controller/server/view/common/page.ml b/controller/server/view/common/page.ml index bddcfed1..b9069dac 100644 --- a/controller/server/view/common/page.ml +++ b/controller/server/view/common/page.ml @@ -36,14 +36,15 @@ let menu_label page = | Shutdown -> "Shutdown" let menu_item current_page page = + let is_active = current_page = Some page in let class_ = - "d-Menu__Item" :: - (if current_page = Some page then [ "d-Menu__Item--Active" ] else []) + "d-Menu__Item" :: (if is_active then [ "d-Menu__Item--Active" ] else []) in a - ~a:[ a_href (menu_link page) - ; a_class class_ - ] + ~a:( + a_href (menu_link page) :: a_class class_ :: + if is_active then [ a_user_data "focus-active" "" ] else [] + ) [ menu_icon page ; txt (menu_label page) ] @@ -66,8 +67,11 @@ let html ?current_page ?header content = (body ~a:[ a_class [ "d-Layout" ] ] (( aside - ~a:[ a_class [ "d-Layout__Aside" ] ] + ~a:[ a_class [ "d-Layout__Aside" ] + ; a_user_data "focus-group" "first" + ] [ nav + ~a:[ a_user_data "focus-group" "active" ] ([ Info; Network; Localization; SystemStatus; Changelog ] |> List.concat_map (fun page -> [ menu_item current_page page; txt " " ])) ; div From 58e0ba5a12aae1673b3e23e46b84a94c8d9b4fa8 Mon Sep 17 00:00:00 2001 From: Johannes Emerich Date: Thu, 29 Aug 2024 15:07:10 +0200 Subject: [PATCH 3/4] Update changelog for spatial navigation --- controller/Changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/controller/Changelog.md b/controller/Changelog.md index c2f645df..e1b3b7b5 100644 --- a/controller/Changelog.md +++ b/controller/Changelog.md @@ -1,3 +1,9 @@ +# [UNRELEASED] + +## Added + +- controller: Enable spatial navigation using the arrow keys + # [2024.7.0] - 2024-08-02 # [2024.7.0-VALIDATION] - 2024-07-02 From 5b5257a7067bc0ae5d26fc51417431aa8fc20e07 Mon Sep 17 00:00:00 2001 From: Johannes Emerich Date: Thu, 5 Sep 2024 22:17:51 +0200 Subject: [PATCH 4/4] Simplify menu grouping for spatial navigation We can let the entire sidebar be an 'active' focus group, so that moving from the 'Shutdown' item to the other item group is more natural. 'Shutdown' is an item that will simply never be active itself. --- controller/server/view/common/page.ml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/controller/server/view/common/page.ml b/controller/server/view/common/page.ml index b9069dac..3c38108b 100644 --- a/controller/server/view/common/page.ml +++ b/controller/server/view/common/page.ml @@ -68,10 +68,9 @@ let html ?current_page ?header content = ~a:[ a_class [ "d-Layout" ] ] (( aside ~a:[ a_class [ "d-Layout__Aside" ] - ; a_user_data "focus-group" "first" + ; a_user_data "focus-group" "active" ] [ nav - ~a:[ a_user_data "focus-group" "active" ] ([ Info; Network; Localization; SystemStatus; Changelog ] |> List.concat_map (fun page -> [ menu_item current_page page; txt " " ])) ; div