CartItem
One line of a cart: picture, name and options, a quantity control, the line's total and Remove, as inputs a form sends.
Demo
Quantity 2 (on_change); at most 5.
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.qwemain.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>}Related
CurrencyInputAn amount with its currency's symbol, thousands grouped as it is typed and the decimals the currency has, sent as plain digits.5 views · 5 downloadsQuantityInputA compact count with − and + either side, kept to its limits and step, with the arrow keys, PageUp/PageDown, Home and End.5 views · 6 downloadsCartSummarySubtotal, shipping, tax, a discount and the total added up, with a Checkout button as a link, a form or a callback.6 views · 7 downloadsCouponInputA promo code box with Apply: on_apply(code) says whether it took, and an applied code shows with Remove.6 views · 5 downloadsPlanToggleMonthly or yearly billing as a pill of two radios, with the saving shown on the yearly side.6 views · 4 downloadsProductCardA product in a shop's grid: picture, name, price with the discount, stars, a badge and Add to cart, as a plain form or a callback.6 views · 6 downloads
Thread
Thread 0 posts
On CartItem: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.