VariantPicker
Sizes as buttons and colours as swatches, sold-out ones crossed out: radio groups for the keyboard, hidden inputs for forms.
Demo
Colour:Black
Size:M
Chosen: colour=black, size=M (on_change gives the whole map).
WidthChoose width
MaterialChoose material
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/variant_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/variant_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/variant_picker.qwe
let variant_picker_demo_groups = [ { name: "colour", label: "Colour", kind: "swatches", values: [ { value: "black", label: "Black", color: "#222" }, { value: "sand", label: "Sand", color: "#d8c7a0" }, { value: "forest", label: "Forest", color: "#2f6b3a" }, { value: "red", label: "Red", color: "#c62a2f", sold_out: true }]}, { name: "size", label: "Size", values: ["XS", "S", "M", { value: "L", hint: "Fits large" }, { value: "XL", sold_out: true }]},]component VariantPickerDemoLive() { state picked = { colour: "black", size: "M" } <div.demo-stack> <VariantPicker groups={variant_picker_demo_groups} chosen={{ colour: "black", size: "M" }} on_change={(c) => { picked = c }}/> <p.demo-note>Chosen: {join(keys(picked) | map((k) => `${k}=${picked[k]}`), ", ")} (on_change gives the whole map).</p> </div>}view VariantPickerDemo() = <div.demo-stack style="gap: 28px"> <VariantPickerDemoLive/> <VariantPicker size="sm" required={true} groups={[ { name: "width", label: "Width", values: ["Narrow", "Regular", "Wide"] }, { name: "material", label: "Material", kind: "swatches", values: [{ value: "oak", label: "Oak", color: "#c9a26b" }, { value: "walnut", label: "Walnut", color: "#5a3a22" }] }]}/> </div>Source
ui/variant_picker.qwe
// VariantPicker: a product's choices: sizes as buttons, colours as swatches,// with what cannot be had crossed out. Each group is a radio group for screen// readers and the arrow keys, and keeps a hidden input named after it, so a// plain <form> sends the choices.//// <VariantPicker groups={[// { name: "colour", label: "Colour", kind: "swatches", values: [// { value: "black", color: "#222" }, { value: "sand", label: "Sand", color: "#d8c7a0" },// { value: "red", color: "#c62a2f", sold_out: true }]},// { name: "size", label: "Size", values: ["S", "M", "L", { value: "XL", sold_out: true }]}]}// chosen={{ colour: "black", size: "M" }} on_change={(c) => { picked = c }}/>//// groups: a list of { name, label, kind?, values }: kind "buttons" (default)// or "swatches"; values strings, or { value, label?, color?, sold_out?,// hint? }: color the swatch's CSS colour (an image: "url(...)"), hint a few// words shown after the label when chosen ("Fits large"). chosen: a map of// group name to value, to start with. required: a group with nothing chosen// says "Choose a size". on_change(chosen): inside one of your components,// given the whole map each time a choice changes. size: "sm" or "md".import ui/theme.qwe use ui_clslet variant_picker_css = css`.ui-variant { display: grid; gap: 16px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-variant-group { display: grid; gap: 8px; }.ui-variant-head { display: flex; gap: 6px; font-size: 13px; }.ui-variant-label { font-weight: 500; }.ui-variant-picked { color: var(--ui-muted); }.ui-variant-need { color: var(--ui-danger); }.ui-variant-list { display: flex; flex-wrap: wrap; gap: 8px; }.ui-variant-opt { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 38px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; transition: border-color .15s, background .15s, color .15s, box-shadow .15s; }.ui-variant-opt:hover { border-color: var(--ui-strong); }.ui-variant-opt[aria-checked="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-variant-opt:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-variant-opt[aria-checked="true"]:focus-visible { box-shadow: inset 0 0 0 1px var(--ui-accent), var(--ui-focus); }.ui-variant-out { color: var(--ui-muted); cursor: not-allowed; background: var(--ui-subtle); }.ui-variant-out::after { content: ""; position: absolute; left: 10%; right: 10%; top: 50%; height: 1px; background: currentColor; transform: rotate(-20deg); }.ui-variant-swatch { width: 34px; min-width: 0; height: 34px; padding: 3px; border-radius: 50%; }.ui-variant-swatch span { display: block; width: 100%; height: 100%; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); }.ui-variant-swatch[aria-checked="true"] { background: var(--ui-surface); border-color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-variant-swatch.ui-variant-out span { opacity: .4; }.ui-variant-swatch.ui-variant-out::after { left: 15%; right: 15%; transform: rotate(-45deg); }.ui-variant-sm .ui-variant-opt { height: 32px; min-width: 36px; padding: 0 10px; font-size: 13px; }.ui-variant-sm .ui-variant-swatch { width: 28px; height: 28px; }@media (prefers-reduced-motion: reduce) { .ui-variant-opt { transition: none; } }`// A value as a map of { value, label, color?, sold_out, hint? }.fn variant_picker_value(v) -> map = type_of(v) == "map" ? { ...v, value: str(v.value), label: v.label ?? str(v.value), sold_out: v.sold_out ?? false } : { value: str(v), label: str(v), sold_out: false }component VariantPicker(groups: list = [], chosen: map = {}, required: bool = false, size: str = "md", on_change = null, class: str? = null) { style global variant_picker_css state picked = chosen let pick = (g, v: str) => { picked = picked | set(g.name, v) on_change && on_change(picked) } <div class={ui_cls(["ui-variant", size == "sm" && "ui-variant-sm", class])}> {for g in groups => { let vals = g.values | map(variant_picker_value) let cur = get(picked, g.name) let cur_val = find(vals, (v) => v.value == cur) let swatches = get(g, "kind") == "swatches" let gid = `ui-variant-${g.name}` <div.ui-variant-group key={g.name}> <div.ui-variant-head id={`${gid}-label`}> <span.ui-variant-label>{g.label}{cur_val != null && ":"}</span> {cur_val != null ? <span.ui-variant-picked>{cur_val.label}{get(cur_val, "hint") != null && ` · ${cur_val.hint}`}</span> : required && <span.ui-variant-need>Choose {lower(g.label)}</span>} </div> <div.ui-variant-list role="radiogroup" aria-labelledby={`${gid}-label`} aria-required={required ? "true" : null} @keydown={(e) => { let open = vals | filter((v) => !v.sold_out) let n = len(open) if n == 0 { return null } let at = index_of(open | map((v) => v.value), cur) ?? -1 let to = match e.key { "ArrowRight" | "ArrowDown" => (at + 1) % n "ArrowLeft" | "ArrowUp" => (at + n - 1 + (at < 0 ? 1 : 0)) % n "Home" => 0 "End" => n - 1 _ => null } if to != null { e.preventDefault() pick(g, open[to].value) let el = e.target.closest(".ui-variant-list").querySelector(`[data-v="${open[to].value}"]`) el && el.focus() } }}> {for [i, v] in enumerate(vals) => { let on = v.value == cur let first_open = (find(vals, (x) => !x.sold_out) ?? { value: null }).value == v.value <button type="button" role="radio" key={v.value} data-v={v.value} class={ui_cls(["ui-variant-opt", swatches && "ui-variant-swatch", v.sold_out && "ui-variant-out"])} aria-checked={on ? "true" : "false"} aria-disabled={v.sold_out ? "true" : null} aria-label={swatches || v.sold_out ? `${v.label}${v.sold_out ? ", sold out" : ""}` : null} title={swatches ? v.label : (v.sold_out ? "Sold out" : null)} tabindex={on || (cur_val == null && first_open) ? "0" : "-1"} style={swatches ? `--c: ${get(v, "color") ?? "transparent"}` : null} @click={() => { if !v.sold_out { pick(g, v.value) } }}> {swatches ? <span aria-hidden="true"></span> : v.label} </button> }} </div> <input type="hidden" name={g.name} value={cur ?? ""}/> </div> }} </div>}Related
PaymentMethodsWays to pay as radio cards with their marks: card (with the brands taken), PayPal, Apple Pay, Google Pay, bank, cash.6 views · 5 downloadsPlanToggleMonthly or yearly billing as a pill of two radios, with the saving shown on the yearly side.6 views · 4 downloadsShippingOptionsShipping methods as radio cards, each with its price and how long it takes; a plain form sends the choice.7 views · 7 downloadsBirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsCountryPickerA drop-down of every country with its flag and ISO code, favourites first, sending the two-letter code with a form.5 views · 7 downloadsFontPickerA drop-down of fonts, each option drawn in itself, with a preview line in the one chosen.5 views · 7 downloads
Thread
Thread 0 posts
On VariantPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.