grade/report/singleview/amd/src/singleview.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Allow navigation through table cells using Ctrl + arrow keys and handle override toggles.
 *
 * @module    gradereport_singleview/singleview
 * @copyright The Open University
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

const selectors = {
    cell: 'td.cell, th.cell',
    col: 'td, th',
    keyboardHandled: 'table input, table select, table a',
    navigableCell: 'input:not([type="hidden"]):not([disabled]), select, a',
    override: 'input[name^=override_]',
    row: 'tr',
    // Dyanmic selectors.
    input: (interest) => `input[name$='${interest}'][data-uielement='text']`,
    select: (interest) => `select[name$='${interest}']`,
};

let initialized = false;

/**
 * Initializes the module, setting up event listeners for table cell navigation and override toggles.
 */
export function init() {
    if (initialized) {
        return;
    }
    initialized = true;

    // Add ctrl+arrow controls for navigation.
    // Use capturing phase to intercept events before they reach anchor elements.
    document.addEventListener('keydown', keydownHandler, true);

    // Handle override toggles.
    document.querySelectorAll(selectors.override).forEach(input => {
        input.addEventListener('change', () => {
            updateOverrideToggle(input);
        });
    });
}

/**
 * Handles control+arrow table navigation.
 *
 * @private
 * @param {KeyboardEvent} event The keydown event.
 */
function keydownHandler(event) {
    if (!event.ctrlKey) {
        return;
    }

    // Check if it's an arrow key.
    if (!['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key)) {
        return;
    }

    const activeElement = document.activeElement;
    if (!activeElement.matches(selectors.keyboardHandled)) {
        return;
    }

    let next = null;
    switch (event.key) {
        case 'ArrowLeft':
            next = getPrevCell(activeElement.closest(selectors.col));
            break;
        case 'ArrowUp':
            next = getAboveCell(activeElement.closest(selectors.col));
            break;
        case 'ArrowRight':
            next = getNextCell(activeElement.closest(selectors.col));
            break;
        case 'ArrowDown':
            next = getBelowCell(activeElement.closest(selectors.col));
            break;
    }

    // Immediately prevent default behavior and stop propagation.
    event.preventDefault();
    event.stopImmediatePropagation();

    if (next) {
        next.querySelector(selectors.navigableCell)?.focus();
    }
}

/**
 * Handles changes to override toggles.
 *
 * @private
 * @param {HTMLInputElement} input The override toggle input element.
 */
function updateOverrideToggle(input) {
    const checked = input.checked;
    const [, itemid, userid] = input.getAttribute('name').split('_');
    const interest = `_${itemid}_${userid}`;

    // Handle text inputs.
    document.querySelectorAll(selectors.input(interest)).forEach(
        text => {
            text.disabled = !checked;
        }
    );

    // Handle select elements.
    document.querySelectorAll(selectors.select(interest)).forEach(
        select => {
            select.disabled = !checked;
        }
    );
}

/**
 * Helper function to get the next cell in the table.
 *
 * @private
 * @param {HTMLElement} cell The cell of the table.
 * @returns {HTMLElement|null} The next navigable cell or null if none found.
 */
function getNextCell(cell) {
    const checkElement = cell || document.activeElement;
    const next = checkElement.nextElementSibling?.matches(selectors.cell) ? checkElement.nextElementSibling : null;
    if (!next) {
        return null;
    }
    // Continue until we find a navigable cell.
    if (!next.querySelector(selectors.navigableCell)) {
        return getNextCell(next);
    }

    return next;
}

/**
 * Helper function to get the previous cell in the table.
 *
 * @private
 * @param {HTMLElement} cell The cell of the table.
 */
function getPrevCell(cell) {
    const checkElement = cell || document.activeElement;
    const prev = checkElement.previousElementSibling?.matches(selectors.cell) ? checkElement.previousElementSibling : null;
    if (!prev) {
        return null;
    }
    // Continue until we find a navigable cell.
    if (!prev.querySelector(selectors.navigableCell)) {
        return getPrevCell(prev);
    }

    return prev;
}

/**
 * Helper function to get the cell above the current cell in the table.
 *
 * @private
 * @param {HTMLElement} cell The current table cell element.
 * @returns {HTMLElement|null} The cell above or null if none found.
 */
function getAboveCell(cell) {
    const checkElement = cell || document.activeElement;
    const tr = checkElement.closest(selectors.row).previousElementSibling;
    const columnIndex = getColumnIndex(checkElement);
    if (!tr) {
        return null;
    }
    const next = tr.querySelectorAll(selectors.col)[columnIndex];
    // Continue until we find a navigable cell.
    if (!next?.querySelector(selectors.navigableCell)) {
        return getAboveCell(next);
    }

    return next;
}

/**
 * Helper function to get the cell below the current cell in the table.
 *
 * @private
 * @param {HTMLElement} cell The current table cell element.
 * @returns {HTMLElement|null} The cell below or null if none found.
 */
function getBelowCell(cell) {
    const checkElement = cell || document.activeElement;
    const tr = checkElement.closest('tr').nextElementSibling;
    const columnIndex = getColumnIndex(checkElement);
    if (!tr) {
        return null;
    }
    const next = tr.querySelectorAll('td, th')[columnIndex];
    // Continue until we find a navigable cell.
    if (!next?.querySelector(selectors.navigableCell)) {
        return getBelowCell(next);
    }

    return next;
}

/**
 * Helper function to get the column index of a cell.
 *
 * @param {HTMLElement} cell The cell of the table.
 * @returns {number} The index of the cell within its row.
 */
function getColumnIndex(cell) {
    const rowNode = cell.closest(selectors.row);
    if (!rowNode || !cell) {
        return -1;
    }
    const cells = Array.from(rowNode.querySelectorAll(selectors.col));

    return cells.indexOf(cell);
}