laya-browser / code /finetune /webgym /widgets.js
cklxx's picture
v19s: WebChain real-site trajectories, format v5, webgym x7 + DAgger, harness fixes; replaces v17s
454b3e6 verified
Raw History Blame Contribute Delete
20.8 kB
// webgym widget library: every widget keeps its committed value in window.__gym.state, exactly like a real site
// (typing into an autocomplete field does NOT commit a value until an option is chosen).
(function () {
const G = window.__gym = window.__gym || { state: {}, fields: {} };
const $ = (s, r = document) => r.querySelector(s);
const el = (tag, attrs = {}, ...kids) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "text") e.textContent = v; else if (k.startsWith("on")) e.addEventListener(k.slice(2), v); else e.setAttribute(k, v);
}
for (const k of kids) e.append(k);
return e;
};
const norm = s => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
const close = d => { d.hidden = true; };
// ---------------------------------------------------------------- autocomplete (inline or behind a trigger button)
G.combo = function (host, f) {
const id = f.key;
const input = el("input", { id: id + "-input", "data-g": id + "-input", type: "text", autocomplete: "off", role: "combobox",
"aria-autocomplete": "list", "aria-expanded": "false", "aria-controls": id + "-list", placeholder: f.placeholder || "" });
const label = el("label", { for: id + "-input", text: f.label });
const list = el("ul", { id: id + "-list", role: "listbox", class: "list" }); list.hidden = true;
const commit = opt => {
G.state[id] = opt.key; input.value = opt.short; list.hidden = true; input.setAttribute("aria-expanded", "false");
if (trigger) { trigger.querySelector(".v").textContent = opt.short; close(dialog); }
};
const render = () => {
const q = norm(input.value.trim()); list.innerHTML = "";
if (!f.suggest) { G.state[id] = input.value.trim() ? "text:" + input.value.trim() : null; return; }
if (q.length < 2) { list.hidden = true; input.setAttribute("aria-expanded", "false"); return; }
const hits = f.options.filter(o => norm(o.label).includes(q) || norm(o.alias || "").includes(q)).slice(0, 6);
hits.forEach((o, i) => list.append(el("li", { role: "option", tabindex: "-1", "data-g": id + "-opt-" + o.key, class: "opt",
onclick: () => commit(o) }, o.label)));
list.hidden = !hits.length; input.setAttribute("aria-expanded", String(!!hits.length));
};
input.addEventListener("input", render);
input.addEventListener("blur", () => setTimeout(() => { list.hidden = true; input.setAttribute("aria-expanded", "false"); }, 250));
input.addEventListener("focus", () => { if (input.value.trim().length >= 2 && f.suggest) render(); });
input.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); const first = list.querySelector("[role=option]"); if (first && !list.hidden) first.click(); }
});
let trigger = null, dialog = null;
if (f.trigger) { // Google-style: a button shows the value; clicking it opens a dialog with the real input
trigger = el("button", { type: "button", "data-g": id + "-trigger", "aria-haspopup": "dialog", class: "trig" },
el("span", { class: "k", text: f.label + " " }), el("span", { class: "v", text: f.empty || "" }));
dialog = el("div", { role: "dialog", "aria-label": f.label, class: "dlg", "data-g": id + "-dialog" }, label, input, list,
el("button", { type: "button", "data-g": id + "-close", onclick: () => close(dialog), text: "Close" }));
dialog.hidden = true;
trigger.addEventListener("click", () => { dialog.hidden = false; input.value = ""; input.focus(); });
host.append(trigger, dialog);
} else host.append(label, input, list);
G.fields[id] = { kind: "combo", trigger: !!f.trigger, suggest: !!f.suggest };
G.state[id] = null;
if (f.value != null) { const o = f.options.find(o => o.key === f.value); if (o) commit(o); else if (!f.suggest) { input.value = String(f.value).replace(/^text:/, ""); G.state[id] = f.value; } }
};
// ---------------------------------------------------------------- one-of: radio group, segmented buttons, native select, custom dropdown
G.choice = function (host, f) {
if (f.value != null) f = Object.assign({}, f, { default: f.value });
const id = f.key; G.state[id] = f.default == null ? null : f.default; G.fields[id] = { kind: "choice", impl: f.impl };
const legend = f.label;
if (f.impl === "radio") {
const fs = el("fieldset", {}, el("legend", { text: legend }));
f.options.forEach(o => {
const r = el("input", { type: "radio", name: id, id: id + "-" + o.key, value: o.key, "data-g": id + "-" + o.key });
if (o.key === f.default) r.checked = true;
r.addEventListener("change", () => { G.state[id] = o.key; });
fs.append(el("span", { class: "ro" }, r, el("label", { for: id + "-" + o.key, text: o.label })));
});
host.append(fs);
} else if (f.impl === "segmented") {
const g = el("div", { role: "group", "aria-label": legend, class: "seg" });
f.options.forEach(o => {
const b = el("button", { type: "button", "data-g": id + "-" + o.key, "aria-pressed": String(o.key === f.default), text: o.label });
b.addEventListener("click", () => { G.state[id] = o.key; g.querySelectorAll("button").forEach(x => x.setAttribute("aria-pressed", String(x === b))); });
g.append(b);
});
host.append(el("span", { class: "k", text: legend + ": " }), g);
} else if (f.impl === "select") {
const s = el("select", { id: id + "-select", "data-g": id + "-select" });
if (f.default == null) { const ph = el("option", { value: "", text: f.placeholder || "Select..." }); ph.selected = true; ph.disabled = true; s.append(ph); }
f.options.forEach(o => { const op = el("option", { value: o.key, text: o.label }); if (o.key === f.default) op.selected = true; s.append(op); });
s.addEventListener("change", () => { G.state[id] = s.value || null; });
host.append(el("label", { for: id + "-select", text: legend }), s);
} else { // custom dropdown: button showing the value -> listbox of options
const cur = () => { const o = f.options.find(o => o.key === G.state[id]); return o ? o.label : (f.placeholder || "Select..."); };
const b = el("button", { type: "button", "data-g": id + "-trigger", "aria-haspopup": "listbox", "aria-expanded": "false",
"aria-label": legend + ": " + cur(), text: cur() + " ▾" });
const lb = el("div", { role: "listbox", "aria-label": legend, class: "list" }); lb.hidden = true;
f.options.forEach(o => lb.append(el("div", { role: "option", "data-g": id + "-" + o.key, tabindex: "-1", class: "opt",
onclick: () => { G.state[id] = o.key; b.textContent = o.label + " ▾"; b.setAttribute("aria-label", legend + ": " + o.label);
lb.hidden = true; b.setAttribute("aria-expanded", "false"); } }, o.label)));
b.addEventListener("click", () => { lb.hidden = !lb.hidden; b.setAttribute("aria-expanded", String(!lb.hidden)); });
host.append(el("span", { class: "k", text: legend + " " }), b, lb);
}
};
// ---------------------------------------------------------------- date picker (month grid) or a typed date field
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
G.fmt = d => `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
G.date = function (host, f) {
const id = f.key; G.state[id] = null; G.fields[id] = { kind: "date", impl: f.impl, done: !!f.done };
const init = f.value && /^\d{4}-\d{2}-\d{2}$/.test(f.value) ? f.value : null;
if (f.impl === "selects") { // three native selects: month / day / year (typical for a date of birth)
const wrap = el("div", { role: "group", "aria-label": f.label, class: "dsel" }, el("span", { class: "k", text: f.label + " " }));
const mk = (part, opts, ph) => { const s = el("select", { "data-g": id + "-" + part, "aria-label": f.label + " " + part });
s.append(el("option", { value: "", text: ph })); opts.forEach(([v, t]) => s.append(el("option", { value: v, text: t }))); return s; };
const sm = mk("month", MONTHS.map((m, i) => [String(i + 1).padStart(2, "0"), m]), "Month");
const sd = mk("day", Array.from({ length: 31 }, (_, i) => [String(i + 1).padStart(2, "0"), String(i + 1)]), "Day");
const y0 = (f.years || [1940, 2010])[0], y1 = (f.years || [1940, 2010])[1];
const sy = mk("year", Array.from({ length: y1 - y0 + 1 }, (_, i) => [String(y1 - i), String(y1 - i)]), "Year");
const upd = () => { G.state[id] = sm.value && sd.value && sy.value ? `${sy.value}-${sm.value}-${sd.value}` : null; };
[sm, sd, sy].forEach(x => x.addEventListener("change", upd));
if (init) { sy.value = init.slice(0, 4); sm.value = init.slice(5, 7); sd.value = init.slice(8, 10); upd(); }
wrap.append(sm, sd, sy); host.append(wrap);
return;
}
if (f.impl === "typed") {
const i = el("input", { id: id + "-input", "data-g": id + "-input", type: "text", placeholder: "MM/DD/YYYY" });
if (init) { i.value = `${init.slice(5, 7)}/${init.slice(8, 10)}/${init.slice(0, 4)}`; G.state[id] = init; }
i.addEventListener("input", () => {
const m = i.value.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) || i.value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) { G.state[id] = null; return; }
const d = i.value.includes("/") ? new Date(+m[3], +m[1] - 1, +m[2]) : new Date(+m[1], +m[2] - 1, +m[3]);
G.state[id] = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
});
host.append(el("label", { for: id + "-input", text: f.label }), i);
return;
}
let view = new Date(f.start[0], f.start[1], 1), picked = null;
const shown = el("span", { class: "v", text: f.empty || "" });
if (init) { picked = init; G.state[id] = init; const d0 = new Date(+init.slice(0, 4), +init.slice(5, 7) - 1, +init.slice(8, 10)); shown.textContent = G.fmt(d0).slice(0, -6); view = new Date(d0.getFullYear(), d0.getMonth(), 1); }
const trig = el("button", { type: "button", "data-g": id + "-trigger", "aria-haspopup": "dialog", class: "trig" },
el("span", { class: "k", text: f.label + " " }), shown);
const dlg = el("div", { role: "dialog", "aria-label": f.label + " calendar", class: "dlg cal", "data-g": id + "-dialog" }); dlg.hidden = true;
const title = el("strong", {});
const grid = el("div", { role: "grid", class: "grid" });
const draw = () => {
title.textContent = MONTHS[view.getMonth()] + " " + view.getFullYear(); grid.innerHTML = "";
const n = new Date(view.getFullYear(), view.getMonth() + 1, 0).getDate();
for (let day = 1; day <= n; day++) {
const d = new Date(view.getFullYear(), view.getMonth(), day), iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const b = el("button", { type: "button", "data-g": id + "-day-" + iso, "aria-label": G.fmt(d), "aria-pressed": String(picked === iso), text: String(day) });
b.addEventListener("click", () => {
picked = iso; shown.textContent = G.fmt(d).slice(0, -6); draw();
if (!f.done) { G.state[id] = iso; dlg.hidden = true; }
});
grid.append(b);
}
};
const nav = (k, lab) => el("button", { type: "button", "data-g": id + "-" + k, "aria-label": lab, text: k === "prev" ? "‹" : "›",
onclick: () => { view = new Date(view.getFullYear(), view.getMonth() + (k === "prev" ? -1 : 1), 1); draw(); } });
dlg.append(el("div", { class: "hdr" }, nav("prev", "Previous month"), title, nav("next", "Next month")), grid,
el("button", { type: "button", "data-g": id + "-close", "aria-label": "Close calendar", text: "\u2715", onclick: () => { dlg.hidden = true; } }));
if (f.done) dlg.append(el("button", { type: "button", "data-g": id + "-done", text: f.done,
onclick: () => { if (picked) G.state[id] = picked; dlg.hidden = true; } }));
trig.addEventListener("click", () => { dlg.hidden = false; draw(); });
host.append(trig, dlg);
};
// ---------------------------------------------------------------- counts: native select, stepper, or a popup with steppers
G.count = function (host, f) {
if (f.value != null && !isNaN(+f.value)) f = Object.assign({}, f, { default: +f.value });
const id = f.key; G.state[id] = f.default; G.fields[id] = { kind: "count", impl: f.impl };
const noun = n => n + " " + (n === 1 ? f.one : f.many);
if (f.impl === "select") {
const s = el("select", { id: id + "-select", "data-g": id + "-select" });
for (let n = f.min; n <= f.max; n++) { const o = el("option", { value: String(n), text: noun(n) }); if (n === f.default) o.selected = true; s.append(o); }
s.addEventListener("change", () => { G.state[id] = +s.value; });
host.append(el("label", { for: id + "-select", text: f.label }), s);
return;
}
const val = el("span", { class: "v", "aria-live": "polite", text: String(f.default) });
const step = d => { G.state[id] = Math.max(f.min, Math.min(f.max, G.state[id] + d)); val.textContent = String(G.state[id]);
if (sum) sum.textContent = noun(G.state[id]); };
const box = el("div", { class: "step" }, el("span", { class: "k", text: f.label + " " }),
el("button", { type: "button", "data-g": id + "-dec", "aria-label": "Decrease " + f.many, text: "−", onclick: () => step(-1) }), val,
el("button", { type: "button", "data-g": id + "-inc", "aria-label": "Increase " + f.many, text: "+", onclick: () => step(1) }));
let sum = null;
if (f.impl === "popup") {
sum = el("span", { class: "v", text: noun(f.default) });
const dlg = el("div", { role: "dialog", "aria-label": f.label, class: "dlg", "data-g": id + "-dialog" }, box,
el("button", { type: "button", "data-g": id + "-done", text: "Done", onclick: () => { dlg.hidden = true; } }));
dlg.hidden = true;
const trig = el("button", { type: "button", "data-g": id + "-trigger", "aria-haspopup": "dialog", class: "trig" }, sum);
trig.addEventListener("click", () => { dlg.hidden = false; });
host.append(trig, dlg);
} else host.append(box);
};
// ---------------------------------------------------------------- boolean: native checkbox or an ARIA switch
G.toggle = function (host, f) {
const id = f.key; const init = f.value != null ? String(f.value) === "true" : !!f.default;
G.state[id] = init; G.fields[id] = { kind: "toggle", impl: f.impl };
if (f.impl === "switch") {
const b = el("button", { type: "button", role: "switch", "aria-checked": String(init), "data-g": id + "-switch", class: "sw", text: f.label });
b.addEventListener("click", () => { G.state[id] = !G.state[id]; b.setAttribute("aria-checked", String(G.state[id])); });
host.append(b);
} else {
const c = el("input", { type: "checkbox", id: id + "-check", "data-g": id + "-check" }); c.checked = init;
c.addEventListener("change", () => { G.state[id] = c.checked; });
host.append(el("span", { class: "ro" }, c, el("label", { for: id + "-check", text: f.label })));
}
};
// ---------------------------------------------------------------- free text: input (text / email / tel) or textarea
G.text = function (host, f) {
const id = f.key; G.state[id] = f.value ? String(f.value) : null; G.fields[id] = { kind: "text", type: f.type };
const i = f.type === "textarea" ? el("textarea", { id: id + "-input", "data-g": id + "-input", rows: "3", placeholder: f.placeholder || "" })
: el("input", { id: id + "-input", "data-g": id + "-input", type: f.type || "text", autocomplete: "off", placeholder: f.placeholder || "" });
if (f.value) i.value = String(f.value);
i.addEventListener("input", () => { G.state[id] = i.value.trim() || null; });
host.append(el("label", { for: id + "-input", text: f.label }), i);
};
// ---------------------------------------------------------------- time slots: buttons, unavailable ones disabled
G.slots = function (host, f) {
const id = f.key; G.state[id] = f.value || null; G.fields[id] = { kind: "slots" };
const g = el("div", { role: "group", "aria-label": f.label, class: "slots" });
const btns = f.options.map(o => {
const b = el("button", { type: "button", "data-g": id + "-" + o.key, "aria-pressed": String(o.key === G.state[id]), text: o.label,
"aria-label": o.label + (o.disabled ? " (unavailable)" : "") });
if (o.disabled) b.disabled = true;
b.addEventListener("click", () => { G.state[id] = o.key; btns.forEach(x => x.setAttribute("aria-pressed", String(x === b))); });
return b;
});
g.append(...btns); host.append(el("span", { class: "k", text: f.label }), g);
};
// ---------------------------------------------------------------- many-of: checkbox group or toggle chips
G.multi = function (host, f) {
const id = f.key; const init = f.value ? String(f.value).split("|").filter(Boolean) : [];
G.state[id] = init.slice().sort(); G.fields[id] = { kind: "multi", impl: f.impl };
const set = (k, on) => { const st = new Set(G.state[id]); on ? st.add(k) : st.delete(k); G.state[id] = [...st].sort(); };
if (f.impl === "chips") {
const g = el("div", { role: "group", "aria-label": f.label, class: "chips" });
f.options.forEach(o => { const b = el("button", { type: "button", "data-g": id + "-" + o.key, "aria-pressed": String(init.includes(o.key)), text: o.label });
b.addEventListener("click", () => { const on = b.getAttribute("aria-pressed") !== "true"; b.setAttribute("aria-pressed", String(on)); set(o.key, on); }); g.append(b); });
host.append(el("span", { class: "k", text: f.label }), g);
} else {
const fs = el("fieldset", {}, el("legend", { text: f.label }));
f.options.forEach(o => { const c = el("input", { type: "checkbox", id: id + "-" + o.key, "data-g": id + "-" + o.key }); c.checked = init.includes(o.key);
c.addEventListener("change", () => set(o.key, c.checked)); fs.append(el("span", { class: "ro" }, c, el("label", { for: id + "-" + o.key, text: o.label }))); });
host.append(fs);
}
};
// ---------------------------------------------------------------- validation messages under a field host
G.error = function (host, msg) {
let p = host.querySelector(".err");
if (!msg) { if (p) p.remove(); return; }
if (!p) { p = el("p", { class: "err", role: "alert" }); host.append(p); }
p.textContent = msg;
};
G.empty = v => v === null || v === undefined || v === "" || v === false || (Array.isArray(v) && !v.length);
G.serialize = st => Object.entries(st).filter(([k, v]) => v !== null && v !== undefined).map(([k, v]) => [k, Array.isArray(v) ? v.join("|") : String(v)]);
// ---------------------------------------------------------------- plumbing for the expert
G.status = () => {
const open = [...document.querySelectorAll("[role=dialog],[role=listbox]")].filter(d => !d.hidden && d.offsetParent !== null)
.map(d => d.getAttribute("data-g") || d.id || d.getAttribute("aria-label"));
return { state: G.state, fields: G.fields, open, url: location.href };
};
G.node = sel => { const e = document.querySelector(sel); const c = window.__jevFast; return e && c ? (c.ids.get(e) || null) : null; };
G.nodeText = (sel, text) => {
const c = window.__jevFast; if (!c) return null;
const e = [...document.querySelectorAll(sel)].find(x => x.textContent.trim() === text);
return e ? (c.ids.get(e) || null) : null;
};
G.where = sel => { // -1 above the viewport, 0 inside, 1 below, null missing/hidden
const e = document.querySelector(sel); if (!e || !e.checkVisibility()) return null; const r = e.getBoundingClientRect();
const cy = r.y + r.height / 2; // jev offers an element only when its center is inside the viewport
return cy < 0 ? -1 : cy >= innerHeight ? 1 : 0;
};
G.attr = (sel, a) => { const e = document.querySelector(sel); return e ? e.getAttribute(a) : null; };
G.visible = sel => {
const e = document.querySelector(sel); if (!e) return false; const r = e.getBoundingClientRect();
return r.width > 0 && r.bottom > 0 && r.top < innerHeight && e.checkVisibility();
};
})();