Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion runbot/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
59 changes: 59 additions & 0 deletions runbot/static/src/js/elements/base_button.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
51 changes: 51 additions & 0 deletions runbot/static/src/js/elements/build_options_dropdown.js
Original file line number Diff line number Diff line change
@@ -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);
112 changes: 112 additions & 0 deletions runbot/static/src/js/templating.js
Original file line number Diff line number Diff line change
@@ -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., <span>Hello {{item.title}}</span> or <div>{{item.desc}}</div>)
// 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;
}
Loading