* Restyle widget cards in solid gray on one shared palette Cards, number boxes and off|on switches take their colors, font and radii from js/shared/theme.mjs. Controls are solid gray with no outlines on a near-black card, grays are neutral, faint text holds 4.5:1, and number boxes in a card are as tall as the row. A test keeps colour literals out of the three modules and checks text contrast. * Lift the card toward the node colour and brighten the off mark * Restyle the picture bar, its stepper and switch on the shared palette * Darken the card a step * Picture frame back to the lighter well; changelog covers the picture bar
108 lines
5.1 KiB
JavaScript
108 lines
5.1 KiB
JavaScript
// A switch with its choices side by side and a marker that slides to the
|
|
// chosen one: the mode row of Image Compare A/B, and its off | on choices.
|
|
// The choices share one width, so the marker only has to move. With `fit`
|
|
// each choice is as wide as its own name, and the marker is measured.
|
|
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
|
|
|
|
const CSS_ID = "ausboss-slide-switch-v1";
|
|
|
|
function ensureCss() {
|
|
if (document.getElementById(CSS_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = CSS_ID;
|
|
style.textContent = `
|
|
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};}
|
|
.ausboss-slide.fit{grid-auto-columns:max-content;}
|
|
.ausboss-slide.fit button{padding:0 7px;}
|
|
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:${RADIUS_XS}px;background:${T.brand};transition:transform .16s ease,width .16s ease;pointer-events:none;}
|
|
.ausboss-slide.none .ausboss-slide-mark{display:none;}
|
|
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 11px/1 ${FONT_UI};white-space:nowrap;cursor:pointer;transition:color .16s ease;}
|
|
.ausboss-slide button:hover{color:${T.inkHover};}
|
|
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:${T.onBrand};}
|
|
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:${T.ink};}
|
|
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:${T.switchOff};}
|
|
.ausboss-slide button:focus-visible{outline:1px solid ${T.brand};outline-offset:1px;}
|
|
@media (prefers-reduced-motion: reduce){.ausboss-slide-mark,.ausboss-slide button{transition:none;}}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// options: [{ value, label, title }]. `label` names the whole switch for
|
|
// screen readers. `offLeft` is for an off | on pair: while it is off the
|
|
// marker is grey, so "on" is the only teal state. onChange(value) runs for
|
|
// a click on a choice, also on the one that is already chosen.
|
|
export function makeSlideSwitch({ options, value, label = "", offLeft = false, fit = false, onChange, signal } = {}) {
|
|
ensureCss();
|
|
const root = document.createElement("div");
|
|
root.className = `ausboss-slide${offLeft ? " off-left" : ""}${fit ? " fit" : ""}`;
|
|
root.setAttribute("role", "radiogroup");
|
|
if (label) root.setAttribute("aria-label", label);
|
|
const mark = document.createElement("div");
|
|
mark.className = "ausboss-slide-mark";
|
|
if (!fit) mark.style.width = `calc((100% - 4px) / ${options.length})`;
|
|
root.append(mark);
|
|
const buttons = new Map();
|
|
let current;
|
|
// Choices of their own widths: the marker takes the chosen one's place
|
|
// and size, read from the page.
|
|
const measure = () => {
|
|
const button = buttons.get(current);
|
|
if (!fit || !button || !button.offsetWidth) return;
|
|
mark.style.width = `${button.offsetWidth}px`;
|
|
mark.style.transform = `translateX(${button.offsetLeft - 2}px)`;
|
|
};
|
|
const show = (next) => {
|
|
current = next;
|
|
const index = options.findIndex((option) => option.value === next);
|
|
root.classList.toggle("none", index < 0);
|
|
root.classList.toggle("is-off", index === 0);
|
|
if (fit) measure();
|
|
else mark.style.transform = `translateX(${Math.max(0, index) * 100}%)`;
|
|
for (const [key, button] of buttons) {
|
|
const on = key === next;
|
|
button.classList.toggle("on", on);
|
|
button.setAttribute("aria-checked", String(on));
|
|
}
|
|
};
|
|
for (const option of options) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.setAttribute("role", "radio");
|
|
button.textContent = option.label;
|
|
if (option.title) button.title = option.title;
|
|
// Stopped here so a press on the switch never starts a node drag.
|
|
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onChange?.(option.value);
|
|
}, { signal });
|
|
buttons.set(option.value, button);
|
|
root.append(button);
|
|
}
|
|
show(value);
|
|
if (fit && typeof ResizeObserver !== "undefined") {
|
|
// Nothing can be measured before the switch is on the page.
|
|
const seen = new ResizeObserver(measure);
|
|
seen.observe(root);
|
|
signal?.addEventListener("abort", () => seen.disconnect());
|
|
}
|
|
return {
|
|
root,
|
|
get: () => current,
|
|
// Move the marker. It does not call onChange: the caller decides what a
|
|
// click means and then says where the switch stands.
|
|
set: show,
|
|
// A choice's words can follow the node's state (which picture is on top).
|
|
describe(key, { label: text, title } = {}) {
|
|
const button = buttons.get(key);
|
|
if (!button) return;
|
|
if (text !== undefined) button.textContent = text;
|
|
if (title !== undefined) button.title = title;
|
|
},
|
|
disable(disabled) {
|
|
for (const button of buttons.values()) button.disabled = Boolean(disabled);
|
|
},
|
|
};
|
|
}
|