diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index b542a4086..51a6f0390 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -83,9 +83,13 @@ 'runbot/static/src/css/table_group.css', 'runbot/static/src/css/runbot.css', - 'runbot/static/src/js/polyfill_command_api.js', + 'web/static/src/module_loader.js', + 'web/static/src/core/py_js/*.js', 'runbot/static/lib/jquery/jquery.js', 'runbot/static/lib/bootstrap/js/bootstrap.bundle.js', + 'runbot/static/src/js/polyfill_command_api.js', + 'runbot/static/src/js/elements/*', + 'runbot/static/src/js/templating.js', 'runbot/static/src/js/table_filter.js', 'runbot/static/src/js/table_group.js', 'runbot/static/src/js/runbot.js', diff --git a/runbot/static/src/js/elements/base_button.js b/runbot/static/src/js/elements/base_button.js new file mode 100644 index 000000000..32a37af7e --- /dev/null +++ b/runbot/static/src/js/elements/base_button.js @@ -0,0 +1,59 @@ +/** + * A lightweight base class for accessible custom buttons in the Light DOM. + * Automatically manages ARIA semantics, keyboard navigation (Enter/Space), + * and focus states without relying on Shadow DOM or native wrapper elements. + */ +export class BaseButton extends HTMLElement { + static get observedAttributes() { + return ["disabled"]; + } + + connectedCallback() { + // 1. Set semantic role and focusability directly on the host tag + if (!this.hasAttribute("role")) { + this.setAttribute("role", "button"); + } + if (!this.hasAttribute("tabindex") && !this.hasAttribute("disabled")) { + this.setAttribute("tabindex", "0"); + } + + // 2. Attach keyboard and click listeners + this.addEventListener("keydown", this._onKeyDown); + this.addEventListener("click", this._onClick); + } + + disconnectedCallback() { + this.removeEventListener("keydown", this._onKeyDown); + this.removeEventListener("click", this._onClick); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "disabled") { + const isDisabled = newValue !== null; + this.setAttribute("aria-disabled", isDisabled ? "true" : "false"); + if (isDisabled) { + this.removeAttribute("tabindex"); + } else { + this.setAttribute("tabindex", "0"); + } + } + } + + _onKeyDown(event) { + if (this.hasAttribute("disabled")) { + return; + } + // Handle Space and Enter for keyboard accessibility + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + this.click(); + } + } + + _onClick(event) { + if (this.hasAttribute("disabled")) { + event.stopImmediatePropagation(); + event.preventDefault(); + } + } +} diff --git a/runbot/static/src/js/elements/build_options_dropdown.js b/runbot/static/src/js/elements/build_options_dropdown.js new file mode 100644 index 000000000..de66e4853 --- /dev/null +++ b/runbot/static/src/js/elements/build_options_dropdown.js @@ -0,0 +1,51 @@ +import { render } from "../templating"; +import { BaseButton } from "./base_button"; + +const lazyInteractionEvents = ["mouseenter", "focus", "touchstart", "click"]; + +function extractDataset(el) { + const dataset = {}; + for (const { name } of [...el.attributes]) { + if (name.startsWith("data-")) { + const rawValue = el.getAttribute(name); + let value; + try { + value = JSON.parse(rawValue); + } catch { + value = rawValue; + } + dataset[name.slice(5)] = value; + } + } + return dataset; +} + +class BuildOptionsDropdown extends BaseButton { + constructor() { + super(); + this.template = document.getElementById("build-options-dropdown-menu"); + this.data = extractDataset(this); + } + + connectedCallback() { + super.connectedCallback(); + this.classList.add("dropdown-toggle"); + this.setAttribute("data-bs-toggle", "dropdown"); + this.setAttribute("aria-expanded", "false"); + const lazyBuildMenuHandler = () => { + if (this.nextElementSibling?.classList.contains("dropdown-menu")) { + return; + } + const renderedMenu = render(this.template.content.cloneNode(true), this.data); + this.after(renderedMenu); + for (const eventType of lazyInteractionEvents) { + this.removeEventListener(eventType, lazyBuildMenuHandler); + } + } ; + for (const eventType of lazyInteractionEvents) { + this.addEventListener(eventType, lazyBuildMenuHandler, { once: true }); + } + } +} + +customElements.define("build-options-dropdown", BuildOptionsDropdown); diff --git a/runbot/static/src/js/templating.js b/runbot/static/src/js/templating.js new file mode 100644 index 000000000..ed99a9f4c --- /dev/null +++ b/runbot/static/src/js/templating.js @@ -0,0 +1,112 @@ +import { evaluateExpr } from "@web/core/py_js/py"; + +/** + * Client-side lightweight QWeb rendering engine. + * @param {DocumentFragment|HTMLElement} rootNode - The template fragment or element to render. + * @param {Object} context - The data context (parsed from the element's dataset). + * @returns {DocumentFragment|HTMLElement} The rendered DOM node. + */ +export function render(rootNode, context) { + // --- LOOPS (t-foreach / t-as) --- + // Loops must be processed first so that individual nodes exist before evaluating their conditions. + for (const loopEl of [...rootNode.querySelectorAll("[t-foreach]")]) { + const arrayExpr = loopEl.getAttribute("t-foreach"); + const itemKey = loopEl.getAttribute("t-as") || "item"; + + // Evaluate the expression via py_js to retrieve the target array + const arrayData = evaluateExpr(arrayExpr, context); + + if (Array.isArray(arrayData)) { + for (const [index, subItem] of arrayData.entries()) { + const liClone = loopEl.cloneNode(true); + liClone.removeAttribute("t-foreach"); + liClone.removeAttribute("t-as"); + + // Create a sub-context for the current iteration (inherits parent context + loop variable + index) + const loopContext = { + ...context, + [itemKey]: subItem, + [`${itemKey}_index`]: index, + }; + + // Recursive call to render conditions and bindings inside this specific looped node + render(liClone, loopContext); + + loopEl.parentNode.insertBefore(liClone, loopEl); + } + } + + // Remove the original template node that served as the blueprint + loopEl.remove(); + } + + // --- CONDITIONS (t-if / t-else) --- + for (const ifEl of [...rootNode.querySelectorAll("[t-if]")]) { + const expression = ifEl.getAttribute("t-if"); + const conditionMet = evaluateExpr(expression, context); + + // Check if the immediately following sibling is an "else" block + const nextEl = ifEl.nextElementSibling; + const hasElse = nextEl && nextEl.hasAttribute("t-else"); + + if (conditionMet) { + ifEl.removeAttribute("t-if"); + if (hasElse) { + nextEl.remove(); + } // IF is true -> destroy the adjacent ELSE block + } else { + if (hasElse) { + nextEl.removeAttribute("t-else"); // IF is false -> keep the ELSE block (and clean up its attribute) + } + ifEl.remove(); // Destroy the IF block + } + } + + // Remove any orphaned t-else blocks + for (const el of [...rootNode.querySelectorAll("[t-else]")]) { + el.remove(); + } + + // --- INTERPOLATION {{expression}} --- + + /** + * Helper function to replace all {{...}} expressions within a string. + * @param {string} str - The string to interpolate. + * @returns {string} + */ + const interpolate = (str) => { + if (!str || !str.includes("{{")) { + return str; + } + return str.replace(/\{\{\s*(.+?)\s*\}\}/g, (_, expr) => { + const val = evaluateExpr(expr, context); + return val !== undefined && val !== null ? val : ""; + }); + }; + + // Attribute interpolation (e.g., href="{{item.url}}", class="btn {{item.class}}", data-val="{{item.id}}") + // We collect the root node and all descendants to check every tag's attributes + const elements = [rootNode, ...rootNode.querySelectorAll("*")]; + for (const el of elements) { + if (!el.attributes) { + continue; + } + for (const attr of [...el.attributes]) { + if (attr.value.includes("{{")) { + el.setAttribute(attr.name, interpolate(attr.value)); + } + } + } + + // Text node and tag content interpolation (e.g., Hello {{item.title}} or
{{item.desc}}
) + // The TreeWalker naturally visits every text node embedded inside any tag structure + const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, null, false); + let textNode; + while ((textNode = walker.nextNode())) { + if (textNode.nodeValue.includes("{{")) { + textNode.nodeValue = interpolate(textNode.nodeValue); + } + } + + return rootNode; +} diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index 97a52feaa..5105cbb5e 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -219,6 +219,7 @@ + @@ -295,7 +296,7 @@ @@ -328,7 +329,7 @@ - + @@ -356,83 +357,84 @@ + +