course/amd/src/local/activitychooser/dialoguedom.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/>.

import {addIconToContainer} from 'core/loadingicon';
import Carousel from 'theme_boost/bootstrap/carousel';
import Notification from 'core/notification';
import Pending from 'core/pending';
import selectors from 'core_course/local/activitychooser/selectors';
import Tab from 'theme_boost/bootstrap/tab';
import * as Templates from 'core/templates';


/**
 * The activity changer dialogue DOM manipulation module.
 *
 * @module     core_course/local/activitychooser/dialoguedom
 * @copyright  2025 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
export default class ChooserDialogueDOM {

    constructor(dialogue, modalBody, exporter) {
        this.modalBody = modalBody;
        this.exporter = exporter;
        // Temporal variable while migrating methods.
        this.dialogue = dialogue;
    }

    /**
     * Get the search input element.
     *
     * @return {HTMLElement} The search input element.
     */
    getSearchInputElement() {
        return this.modalBody.querySelector(selectors.actions.search);
    }

    /**
     * Get the closest chooser option element.
     *
     * @param {HTMLElement} element
     * @return {HTMLElement|null} element
     */
    getClosestChooserOption(element) {
        return element.closest(selectors.regions.chooserOption.container);
    }

    /**
     * Check if the search tab is active.
     *
     * @return {Boolean} True if the search tab is active, false otherwise.
     */
    isFavoutiteTabActive() {
        const favouriteTab = this.modalBody.querySelector(selectors.regions.favouriteTabNav);
        return favouriteTab && favouriteTab.classList.contains('active');
    }

    /**
     * Show the search results.
     *
     * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
     */
    async refreshSearchResults(searchResultsData) {
        const searchResultsContainer = this.modalBody.querySelector(selectors.regions.searchResults);
        const clearSearchButton = this.modalBody.querySelector(selectors.actions.clearSearch);

        await this.renderSearchResults(searchResultsContainer, searchResultsData);
        const chooserOptionsContainer = searchResultsContainer.querySelector(selectors.regions.chooserOptions);
        const firstSearchResultItem = chooserOptionsContainer.querySelector(selectors.regions.chooserOption.container);
        if (firstSearchResultItem) {
            // Set the first result item to be focusable.
            this.toggleFocusableChooserOption(firstSearchResultItem, true);
            // Register keyboard events on the created search result items.
        }
        clearSearchButton.classList.remove('d-none');

        // Results are rendered in the all activities tab, so we need to hide the category content.
        const tabContent = searchResultsContainer.closest(selectors.regions.tabContent);
        const categoryContent = tabContent.querySelector(selectors.regions.categoryContent);
        categoryContent.classList.add('d-none');
    }

    /**
     * Clear the search results.
     */
    cleanSearchResults() {
        const searchResultsContainer = this.modalBody.querySelector(selectors.regions.searchResults);
        const clearSearchButton = this.modalBody.querySelector(selectors.actions.clearSearch);
        searchResultsContainer.innerHTML = '';
        clearSearchButton.classList.add('d-none');

        // Results are rendered in the all activities tab, so we need to show the category content again.
        const tabContent = searchResultsContainer.closest(selectors.regions.tabContent);
        const categoryContent = tabContent.querySelector(selectors.regions.categoryContent);
        categoryContent.classList.remove('d-none');
    }

    /**
     * Render the search results in a defined container
     *
     * @private
     * @method renderSearchResults
     * @param {HTMLElement} searchResultsContainer The container where the data should be rendered
     * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
     */
    async renderSearchResults(searchResultsContainer, searchResultsData) {
        const templateData = this.exporter.getSearchResultData(searchResultsData);
        // Build up the html & js ready to place into the help section.
        const {html, js} = await Templates.renderForPromise(
            'core_course/local/activitychooser/search_results',
            templateData
        );
        await Templates.replaceNodeContents(searchResultsContainer, html, js);
    }

    /**
     * Show the "All activities" tab.
     *
     * @method showAllActivitiesTab
     * @return {HTMLElement} The "All activities" tab element.
     */
    showAllActivitiesTab() {
        const navTab = this.modalBody.querySelector(selectors.regions.allTabNav);

        if (navTab.classList.contains('active')) {
            return navTab;
        }

        const pendingPromise = new Pending('core_course/activitychooser:alltab');
        navTab.addEventListener('shown.bs.tab', pendingPromise.resolve, {once: true});

        Tab.getOrCreateInstance(navTab).show();
        return navTab;
    }

    /**
     * Update the starred icons in the chooser modal.
     *
     * @method updateItemStarredIcons
     * @param {String} internal The internal name of the module.
     * @param {Boolean} favourite Whether the module is a favourite or not.
     */
    updateItemStarredIcons(internal, favourite) {
        const favouriteButtons = this.modalBody.querySelectorAll(
            `${selectors.elements.moduleItem(internal)} ${selectors.actions.optionActions.manageFavourite}`
        );
        Array.from(favouriteButtons).forEach((element) => {
            element.classList.toggle('text-muted', !favourite);
            element.classList.toggle('text-primary', favourite);
            element.dataset.favourited = favourite;
            element.setAttribute('aria-pressed', favourite);
            element.querySelector(selectors.elements.favouriteIconActive)?.classList.toggle('d-none', !favourite);
            element.querySelector(selectors.elements.favouriteIconInactive)?.classList.toggle('d-none', favourite);

            const iconSelectsor = favourite ? selectors.elements.favouriteIconActive : selectors.elements.favouriteIconInactive;
            const favouriteIcon = element.querySelector(iconSelectsor);
            element.setAttribute('aria-label', favouriteIcon?.getAttribute('data-action-label') || '');
        });
    }

    /**
     * Refresh the favourite content.
     *
     * @param {Array} mappedModules The modules to be displayed in the favourite tab.
     */
    async refreshFavouritesTabContent(mappedModules) {
        const templateData = await this.exporter.getFavouriteTabData(mappedModules);
        const favouriteArea = this.modalBody.querySelector(selectors.regions.favouriteTab);
        const {html, js} = await Templates.renderForPromise(
            'core_course/local/activitychooser/tabcontent',
            templateData,
        );
        await Templates.replaceNodeContents(favouriteArea, html, js);
    }

    /**
     * Toggle the display of the favourite tab.
     *
     * The favourite tab is only displayed when there are favourite modules
     * or when it is the active tab.
     *
     * @param {Boolean} displayed Whether we want to show or hide the favourite tab
     */
    toggleFavouriteTabDisplay(displayed) {
        const favouriteTabNav = this.modalBody.querySelector(selectors.regions.favouriteTabNav);

        let moveFocusTo;
        if (!displayed && favouriteTabNav.classList.contains('active')) {
            moveFocusTo = this.showAllActivitiesTab();
        }

        favouriteTabNav?.classList.toggle('d-none', !displayed);
        favouriteTabNav.tabIndex = displayed ? 0 : -1;
        // The disabled class is used by Boostrap Tab for keyboard navigation.
        if (displayed) {
            favouriteTabNav.classList.remove('disabled');
        } else {
            favouriteTabNav.classList.add('disabled');
        }

        if (moveFocusTo) {
            moveFocusTo.focus();
        }
        this.initActiveTabNavigation();
    }

    /**
     * Given an event from the main module 'page' navigate to it's help section via a carousel.
     *
     * @method showModuleHelp
     * @param {Object} moduleData Data of the module to carousel to
     * @param {Modal} modal The modal object
     */
    showModuleHelp(moduleData, modal) {
        const carousel = this.modalBody.querySelector(selectors.regions.carousel);
        // If we have a real footer then we need to change temporarily.
        if (moduleData.showFooter === true) {
            modal.setFooter(Templates.render(
                'core_course/local/activitychooser/footer_partial',
                moduleData
            ));
        }
        const help = carousel.querySelector(selectors.regions.help);
        help.innerHTML = '';
        help.classList.add('m-auto');

        // Add a spinner.
        const spinnerPromise = addIconToContainer(help);

        // Used later...
        let transitionPromiseResolver = null;
        const transitionPromise = new Promise(resolve => {
            transitionPromiseResolver = resolve;
        });

        // Build up the html & js ready to place into the help section.
        const contentPromise = Templates.renderForPromise(
            'core_course/local/activitychooser/help',
            moduleData
        );

        // Wait for the content to be ready, and for the transition to be complet.
        Promise.all([contentPromise, spinnerPromise, transitionPromise])
            .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
            .then(() => {
                help.querySelector(selectors.regions.chooserSummary.header).focus();
                return help;
            })
            .catch(Notification.exception);

        // Move to the next slide, and resolve the transition promise when it's done.
        carousel.addEventListener(
            'slid.bs.carousel',
            () => {
                transitionPromiseResolver();
            },
            {once: true}
        );
        // Trigger the transition between 'pages'.
        Carousel.getInstance(carousel).next();
    }

    /**
     * Hide the help section of the chooser.
     *
     * @param {String|null} internal The internal name of the module to return to, if any.
     */
    hideModuleHelp(internal = null) {
        const carousel = this.modalBody.querySelector(selectors.regions.carousel);
        // Trigger the transition between 'pages'.
        Carousel.getInstance(carousel).prev();
        if (internal === null) {
            return;
        }
        carousel.addEventListener(
            'slid.bs.carousel',
            () => {
                this.focusChooserOption(internal);
            },
            {once: true}
        );
    }

    /**
     * Focus on a specific activity inside the active tab (if present).
     *
     * @private
     * @method focusChooserOption
     * @param {String} internal The internal name of the module.
     */
    focusChooserOption(internal) {
        const currentTabNav = this.modalBody.querySelector(selectors.elements.activetab);
        const activeSectionId = currentTabNav.getAttribute("href");
        const sectionChooserOptions = this.modalBody.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
        const newCurrent = sectionChooserOptions.querySelector(selectors.regions.getModuleSelector(internal));

        if (!newCurrent) {
            throw new Error(`Invalid chooser option to focus on: ${internal}`);
        }

        // Chooser can only have one element focusable at a time, so we disable them all first.
        this.disableFocusAllChooserOptions(currentTabNav);
        this.toggleFocusableChooserOption(newCurrent, true);

        // Little hack: we want the element considered a focus-visible element.
        // But the focus method does not trigger the focus-visible class. There's an
        // experimental "{focusVisible: true}" option in the focus method, but it's not
        // supported in all browsers yet so we need to fake an editable element.
        newCurrent.contentEditable = true;
        newCurrent.focus();
        newCurrent.contentEditable = false;
    }

    /**
     * Initialise the active tab navigation.
     */
    initActiveTabNavigation() {
        const activeSectionId = this.modalBody.querySelector(selectors.elements.activetab).getAttribute("href");
        const sectionChooserOptions = this.modalBody.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
        const firstChooserOption = sectionChooserOptions?.querySelector(selectors.regions.chooserOption.container);
        if (!firstChooserOption) {
            return;
        }
        this.toggleFocusableChooserOption(firstChooserOption, true);
    }

    /**
     * Initialise all Boostrap components.
     */
    initBootstrapComponents() {
        this.modalBody.querySelectorAll(selectors.elements.tab).forEach((navTab) => {
            // Init the Bootstrap Tab navigation.
            Tab.getOrCreateInstance(navTab);
        });

        // Set up the carousel.
        const carousel = this.modalBody.querySelector(selectors.regions.carousel);
        new Carousel(carousel, {
            interval: false,
            pause: true,
            keyboard: false
        });
    }

    /**
     * Disable the focus of all chooser options in a specific container (section).
     *
     * @method disableFocusAllChooserOptions
     * @param {HTMLElement} tabNav The tab navigation element (from the shown.bs.ta event).
     */
    disableFocusAllChooserOptions(tabNav) {
        const tabId = tabNav.getAttribute("href");
        const chooserOptions = this.modalBody.querySelector(
            selectors.regions.getSectionChooserOptions(tabId)
        );

        if (chooserOptions === null) {
            return;
        }

        const allChooserOptions = chooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
        allChooserOptions.forEach((chooserOption) => {
            this.toggleFocusableChooserOption(chooserOption, false);
        });
    }

    /**
     * Add or remove a chooser option from the focus order.
     *
     * @private
     * @method toggleFocusableChooserOption
     * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
     * @param {Boolean} isFocusable Whether the chooser element is focusable or not
     */
    toggleFocusableChooserOption(chooserOption, isFocusable) {
        const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
        const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
        const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);

        if (isFocusable) {
            // Set tabindex to 0 to add current chooser option element to the focus order.
            chooserOption.tabIndex = 0;
            chooserOptionLink.tabIndex = 0;
            chooserOptionHelp.tabIndex = 0;
            chooserOptionFavourite.tabIndex = 0;
        } else {
            // Set tabindex to -1 to remove the previous chooser option element from the focus order.
            chooserOption.tabIndex = -1;
            chooserOptionLink.tabIndex = -1;
            chooserOptionHelp.tabIndex = -1;
            chooserOptionFavourite.tabIndex = -1;
        }
    }

    /**
     * Move the focus to the previous chooser option element.
     *
     * @param {HTMLElement} current The current chooser option element
     */
    focusNextChooserOption(current) {
        this.moveChooserOptionFocus(
            current,
            (currentOption) => currentOption.nextElementSibling ?? currentOption,
        );
    }

    /**
     * Move the focus to the previous chooser option element.
     *
     * @param {HTMLElement} current The current chooser option element
     */
    focusPreviousChooserOption(current) {
        this.moveChooserOptionFocus(
            current,
            (currentOption) => currentOption.previousElementSibling ?? currentOption,
        );
    }

    /**
     * Move the focus to the first chooser option element.
     *
     * @param {HTMLElement} current The current chooser option element
     */
    focusFirstChooserOption(current) {
        this.moveChooserOptionFocus(
            current,
            (currentOption, container) => container.firstElementChild ?? currentOption,
        );
    }

    /**
     * Move the focus to the last chooser option element.
     *
     * @param {HTMLElement} current The current chooser option element
     */
    focusLastChooserOption(current) {
        this.moveChooserOptionFocus(
            current,
            (currentOption, container) => container.lastElementChild ?? currentOption,
        );
    }

    /**
     * Move the focus to the next chooser option element.
     *
     * @private
     * @param {HTMLElement} current The current chooser option element
     * @param {Function} getNextFocus Function to get the next focusable element
     */
    moveChooserOptionFocus(current, getNextFocus) {
        const currentOption = this.getClosestChooserOption(current);
        const container = current.closest(selectors.regions.chooserOptions);

        if (!container || !currentOption) {
            throw new Error('Invalid chooser options container or current option');
        }

        const newFocusOption = getNextFocus(currentOption, container);
        if (!newFocusOption) {
            return;
        }

        this.toggleFocusableChooserOption(currentOption, false);
        this.toggleFocusableChooserOption(newFocusOption, true);
        newFocusOption.focus();
    }
}