Commerce

VariantPicker

Sizes as buttons and colours as swatches, sold-out ones crossed out: radio groups for the keyboard, hidden inputs for forms.

#commerce#forms#select#picker#interactive

Demo

Preview
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.qwe
main.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>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On VariantPicker: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.