Commerce

CartItem

One line of a cart: picture, name and options, a quantity control, the line's total and Remove, as inputs a form sends.

#commerce#cart#price#forms#input#interactive

Demo

Preview

Trail runner

  • Size: 42
  • Black
$178.00$89.00 each

Quantity 2 (on_change); at most 5.

Wool beanie

  • One size
Only a few left
24,00 €

Insulated bottle, 750 ml

Out of stock
$97.50$32.50 each

The second two are in a plain form: the quantities are inputs named qty[...], and Remove is a submit button named remove.

Get it

It needs theme.qwe, as every component does, and price.qwe; 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/price.qwe$ curl -O https://quillolang.org/components/ui/cart_item.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/cart_item.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/cart_item.qwe
component CartItemDemoLive() {    state qty = 2    state gone = false    <div.demo-stack>        {!gone && <CartItem id="runner" name="Trail runner" options={[{ label: "Size", value: "42" }, "Black"]} price={89} quantity={2} limit={5}                            image="https://picsum.photos/seed/runner/200/200" href="#cart_item"                            on_change={(n) => { qty = n }} on_remove={() => { gone = true }}/>}        <p.demo-note>{gone ? "Removed (on_remove)." : `Quantity ${qty} (on_change); at most 5.`}</p>    </div>}view CartItemDemo() =    <div.demo-stack>        <CartItemDemoLive/>        <form method="post" action="/cart" @submit={(e) => { e.preventDefault() }}>            <CartItem id="beanie" name="Wool beanie" options={["One size"]} price={24} currency="EUR" locale="de-DE" quantity={1}                      image="https://picsum.photos/seed/beanie/200/200" field="qty[beanie]" remove_field="remove" stock="low"/>            <CartItem id="bottle" name="Insulated bottle, 750 ml" price={32.5} quantity={3} size="sm"                      image="https://picsum.photos/seed/bottle/200/200" field="qty[bottle]" remove_field="remove" stock="out"/>        </form>        <p.demo-note>The second two are in a plain form: the quantities are inputs named qty[...], and Remove is a submit button named remove.</p>    </div>

Source

ui/cart_item.qwe
// CartItem: one line of a cart: the picture, the name and options chosen, a// quantity control, the line's total and a Remove button. The quantity is a real// <input type="number"> named `field`, so a plain <form> sends it; the total is// worked out again as it changes. Prices are written as Price writes them (this// file needs price.qwe).////   <CartItem id="sku-1" name="Trail runner" options={["Size 42", "Black"]} price={89} quantity={2}//             image="/p/1.jpg" field="qty[sku-1]" remove_field="remove"/>//   <CartItem id="sku-2" name="Beanie" price={24} on_change={(n) => { qty = n }} on_remove={() => { gone = true }}/>//// id: the product's id. name: its name; href makes it a link. image, alt: its// picture. options: what was chosen, strings or { label, value } ("Size: 42").// price: for one, a number; currency, locale, cents as for Price. quantity:// how many (1). limit: the most allowed (the + button stops there). field: the// quantity input's name. remove_field: makes Remove a submit button of that// name, with the id as its value, for a plain form; without it Remove calls// on_remove(). stock: "low" or "out" says so under the name. size: "sm" or "md".// on_change(quantity): inside one of your components, given the new number.// on_remove(): called when Remove is clicked (without remove_field).import ui/price.qwe use Price, price_textimport ui/theme.qwe use ui_clslet cart_item_css = css`.ui-cart-item { display: grid; grid-template-columns: 80px 1fr auto; gap: 14px; align-items: start; padding: 14px 0; border-bottom: 1px solid var(--ui-border);  font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-cart-item-media { width: 80px; aspect-ratio: 1; overflow: hidden; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-cart-item-media img { display: block; width: 100%; height: 100%; object-fit: cover; }.ui-cart-item-info { display: grid; gap: 4px; min-width: 0; }.ui-cart-item-name { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }.ui-cart-item-name a { color: inherit; text-decoration: none; }.ui-cart-item-name a:hover { color: var(--ui-accent); }.ui-cart-item-opts { display: flex; flex-wrap: wrap; gap: 4px 10px; margin: 0; padding: 0; list-style: none; font-size: 13px; color: var(--ui-muted); }.ui-cart-item-stock { font-size: 12px; font-weight: 500; color: var(--ui-warning); }.ui-cart-item-out .ui-cart-item-stock { color: var(--ui-danger); }.ui-cart-item-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-top: 6px; }.ui-cart-item-qty { display: inline-flex; align-items: stretch; height: 32px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overflow: hidden; }.ui-cart-item-qty:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-cart-item-qty button { width: 30px; border: 0; background: var(--ui-subtle); color: var(--ui-fg); font: 500 15px/1 var(--ui-font); cursor: pointer; }.ui-cart-item-qty button:hover:not(:disabled) { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-cart-item-qty button:disabled { color: var(--ui-muted); opacity: .5; cursor: not-allowed; }.ui-cart-item-qty input { width: 44px; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 14px var(--ui-font); text-align: center;  font-variant-numeric: tabular-nums; appearance: textfield; -moz-appearance: textfield; }.ui-cart-item-qty input::-webkit-inner-spin-button, .ui-cart-item-qty input::-webkit-outer-spin-button { appearance: none; margin: 0; }.ui-cart-item-remove { padding: 4px 0; border: 0; background: none; color: var(--ui-muted); font: 13px var(--ui-font); text-decoration: underline; cursor: pointer; border-radius: 3px; }.ui-cart-item-remove:hover { color: var(--ui-danger); }.ui-cart-item-remove:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-cart-item-total { display: grid; justify-items: end; gap: 2px; text-align: right; }.ui-cart-item-each { font-size: 12px; color: var(--ui-muted); white-space: nowrap; }.ui-cart-item-sm { grid-template-columns: 56px 1fr auto; gap: 10px; padding: 10px 0; }.ui-cart-item-sm .ui-cart-item-media { width: 56px; }.ui-cart-item-sm .ui-cart-item-name { font-size: 14px; }.ui-cart-item-sm .ui-cart-item-qty { height: 28px; }.ui-cart-item-sm .ui-cart-item-qty button { width: 26px; }.ui-cart-item-sm .ui-cart-item-qty input { width: 36px; font-size: 13px; }@media (max-width: 480px) { .ui-cart-item { grid-template-columns: 64px 1fr; } .ui-cart-item-total { grid-column: 2; justify-items: start; text-align: left; } }`component CartItem(name: str, price, id: str? = null, image: str? = null, alt: str? = null, href: str? = null,                   options: list = [], quantity: int = 1, limit: int? = null, currency: str = "USD", locale: str? = null,                   cents: bool = true, field: str? = null, remove_field: str? = null, stock: str = "in", size: str = "md",                   on_change = null, on_remove = null, class: str? = null) {    style global cart_item_css    state qty = quantity    state shown = 0    let total = type_of(price) == "str" ? price : dec(price) * qty    let set_to = (n: int) => {        let c = n < 1 ? 1 : limit != null && n > limit ? limit : n        qty = c        shown += 1        on_change && on_change(c)    }    let qid = `ui-cart-item-${id ?? re.replace(lower(name), "[^a-z0-9]+", "-")}`    <div class={ui_cls(["ui-cart-item", stock == "out" && "ui-cart-item-out", size == "sm" && "ui-cart-item-sm", class])} data-product={id}>        <div.ui-cart-item-media>{image != null && <img src={image} alt={alt ?? ""} loading="lazy"/>}</div>        <div.ui-cart-item-info>            <h3.ui-cart-item-name>{href != null ? <a href={href}>{name}</a> : name}</h3>            {len(options) > 0 && <ul.ui-cart-item-opts>                {for o in options => <li key={type_of(o) == "map" ? o.label : o}>{type_of(o) == "map" ? `${o.label}: ${o.value}` : o}</li>}            </ul>}            {stock == "low" && <span.ui-cart-item-stock>Only a few left</span>}            {stock == "out" && <span.ui-cart-item-stock>Out of stock</span>}            <div.ui-cart-item-row>                <span.ui-cart-item-qty>                    <button type="button" tabindex="-1" aria-label={`Fewer ${name}`} aria-controls={qid} disabled={qty <= 1}                            @click={() => { set_to(qty - 1) }}>−</button>                    <input type="number" id={qid} name={field} value={shown >= 0 ? qty : null} min="1" max={limit} step="1"                           inputmode="numeric" aria-label={`Quantity of ${name}`}                           @change={(e) => { set_to(int(e.value) ?? 1) }}/>                    <button type="button" tabindex="-1" aria-label={`More ${name}`} aria-controls={qid}                            disabled={limit != null && qty >= limit} @click={() => { set_to(qty + 1) }}>+</button>                </span>                {remove_field != null                    ? <button.ui-cart-item-remove type="submit" name={remove_field} value={id} aria-label={`Remove ${name}`}>Remove</button>                    : <button.ui-cart-item-remove type="button" aria-label={`Remove ${name}`} @click={() => { on_remove && on_remove() }}>Remove</button>}            </div>        </div>        <div.ui-cart-item-total>            <Price amount={total} currency={currency} locale={locale} cents={cents}/>            {qty > 1 && <span.ui-cart-item-each>{price_text(price, currency, locale, cents)} each</span>}        </div>    </div>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.