// 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(); }; })();