CoopBallot
A board election ballot: candidates as cards with a short statement, up to N ticked and no more, sent as a plain form post.
Demo
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/coop_ballot.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/coop_ballot.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/coop_ballot.qwe
view CoopBallotDemo() = <CoopBallot id="coop-ballot-demo" action="#coop-ballot" title="Board of Directors" seats={3} hidden={{ election: "board-2026" }} closes="Voting closes March 14, 2026 at 5:00 PM, at the General Assembly." candidates={[ { id: "c-101", name: "Lourdes Villanueva", note: "Incumbent, Treasurer", statement: "Twelve years a member. I kept loan delinquency under 3% and want an online loan window for members abroad." }, { id: "c-102", name: "Ramon Esguerra", note: "Nominated by the Teachers' chapter", statement: "A public school teacher for 20 years. Salary deduction should be faster and cheaper for every school." }, { id: "c-103", name: "Grace Tan-Molina", statement: "Accountant. I will publish the monthly financial statements to every member, in plain words." }, { id: "c-104", name: "Benjie Dalisay", note: "Incumbent", statement: "Farmer and rice trader. More production loans at planting time, paid back at harvest." }, { id: "c-105", name: "Marites Abad", statement: "Runs the co-op store in Barangay San Isidro. Patronage refunds for store members too." }, ]}/>Source
ui/coop_ballot.qwe
// CoopBallot: a ballot for a co-op's board (or a committee) election: the// candidates as cards, each with a photo or initials and a short statement, to// tick up to as many as there are seats. Once that many are ticked the others// cannot be, until one is unticked, and a count says how many are chosen. It is a// plain <form method="post">: the ticked candidates are sent as `name` (one value// each, their ids), with any `hidden` fields, so the route reads them as a list.// (Without JavaScript nothing stops more being ticked: count them on the server// too.)//// <CoopBallot id="board-2026" action="/elections/board-2026/vote" title="Board of Directors" seats={3}// hidden={{ election: "board-2026" }} closes="Voting closes March 14, 2026, 5:00 PM"// candidates={[{ id: "c1", name: "Lourdes Villanueva", note: "Incumbent", statement: "Twelve years a member..." }, ...]}/>//// candidates: maps of { id, name, statement?, note? (a line under the name:// "Incumbent", "Nominated by the Teachers' chapter"), photo? }. seats: how many// may be chosen (1). least: how many must be before the ballot can be cast (1; 0// lets a blank ballot be cast). name: the field the choices are sent as// ("candidates"). chosen: ids ticked to start with. title: the heading// ("Board of Directors"); text: a line under it (by default "Choose up to N.").// closes: when voting ends, shown under the heading. action: where the form posts// ("/vote"). hidden: other fields to send, as { name: value }. submit: the// button's text ("Cast my ballot"). id: a prefix for its ids, unique on the page.import ui/theme.qwe use ui_clslet coop_ballot_css = css`.ui-coop-ballot { display: grid; gap: 16px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-coop-ballot-head { display: grid; gap: 3px; }.ui-coop-ballot .ui-coop-ballot-title { display: block; margin: 0; font-size: 18px; font-weight: 650; }.ui-coop-ballot-text, .ui-coop-ballot-closes { margin: 0; color: var(--ui-muted); }.ui-coop-ballot-closes { font-size: 13px; }.ui-coop-ballot-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin: 0; padding: 0; border: 0; min-width: 0; }.ui-coop-ballot-card { position: relative; display: grid; grid-template-columns: auto 1fr auto; align-items: start; gap: 4px 12px; padding: 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); cursor: pointer; transition: border-color .15s, box-shadow .15s, opacity .15s; }.ui-coop-ballot-card:hover { border-color: var(--ui-strong); }.ui-coop-ballot-card:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-coop-ballot-on { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 6%, var(--ui-surface)); }.ui-coop-ballot-off { opacity: .55; cursor: not-allowed; }.ui-coop-ballot-off:hover { border-color: var(--ui-border); }.ui-coop-ballot-photo, .ui-coop-ballot-initials { width: 44px; height: 44px; border-radius: 50%; }.ui-coop-ballot-photo { object-fit: cover; background: var(--ui-subtle); }.ui-coop-ballot-initials { display: grid; place-items: center; background: var(--ui-subtle); color: var(--ui-muted); font-weight: 650; }.ui-coop-ballot-on .ui-coop-ballot-initials { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-coop-ballot-who { display: grid; gap: 1px; min-width: 0; }.ui-coop-ballot-name { font-weight: 600; font-size: 15px; }.ui-coop-ballot-note { color: var(--ui-muted); font-size: 12px; }.ui-coop-ballot-check { width: 20px; height: 20px; margin: 2px 0 0; accent-color: var(--ui-accent); cursor: inherit; }.ui-coop-ballot-check:focus-visible { outline: none; }.ui-coop-ballot-statement { grid-column: 1 / -1; margin: 6px 0 0; color: var(--ui-fg); font-size: 13px; }.ui-coop-ballot-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding-top: 14px; border-top: 1px solid var(--ui-border); }.ui-coop-ballot-count { margin: 0; font-weight: 500; font-variant-numeric: tabular-nums; }.ui-coop-ballot-full { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-coop-ballot-submit { display: inline-flex; align-items: center; height: 40px; padding: 0 20px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-coop-ballot-submit:hover:not(:disabled) { filter: brightness(1.08); }.ui-coop-ballot-submit:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-coop-ballot-submit:disabled { opacity: .5; cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-coop-ballot-card { transition: none; } }`// "Lourdes Villanueva" as "LV".fn coop_ballot_initials(name: str) -> str { let words = split(trim(name), " ") | filter((w) => w != "") if len(words) == 0 { return "?" } let first = upper(slice(words[0], 0, 1)) len(words) == 1 ? first : first + upper(slice(words[len(words) - 1], 0, 1))}component CoopBallot(id: str, candidates: list = [], seats: int = 1, least: int = 1, name: str = "candidates", chosen: list = [], title: str = "Board of Directors", text: str? = null, closes: str? = null, action: str = "/vote", hidden: map = {}, submit: str = "Cast my ballot", class: str? = null) { style global coop_ballot_css state picked = chosen | map((c) => str(c)) let count = len(picked) let full = count >= seats let toggle = (cid: str, on: bool) => { if on && !contains(picked, cid) && len(picked) < seats { picked = [...picked, cid] } if !on { picked = picked | filter((p) => p != cid) } } <form class={ui_cls(["ui-coop-ballot", class])} method="post" action={action} aria-labelledby={`${id}-title`}> {for k in keys(hidden) => <input type="hidden" name={k} value={str(hidden[k])}/>} <div.ui-coop-ballot-head> <h3.ui-coop-ballot-title id={`${id}-title`}>{title}</h3> <p.ui-coop-ballot-text id={`${id}-text`}>{text ?? (seats == 1 ? "Choose one." : `Choose up to ${seats}.`)}</p> {closes != null && <p.ui-coop-ballot-closes>{closes}</p>} </div> <fieldset.ui-coop-ballot-list aria-describedby={`${id}-text`}> <legend.ui-sr>{title}: candidates</legend> {for c in candidates => { let cid = str(c.id) let on = contains(picked, cid) let off = full && !on <label key={cid} class={ui_cls(["ui-coop-ballot-card", on && "ui-coop-ballot-on", off && "ui-coop-ballot-off"])}> {c.photo != null ? <img.ui-coop-ballot-photo src={c.photo} alt=""/> : <span.ui-coop-ballot-initials aria-hidden="true">{coop_ballot_initials(c.name)}</span>} <span.ui-coop-ballot-who> <span.ui-coop-ballot-name>{c.name}</span> {c.note != null && <span.ui-coop-ballot-note>{c.note}</span>} </span> <input.ui-coop-ballot-check type="checkbox" id={`${id}-${cid}`} name={name} value={cid} checked={on} disabled={off} aria-describedby={c.statement != null ? `${id}-${cid}-statement` : null} @change={(e) => toggle(cid, e.checked)}/> {c.statement != null && <span.ui-coop-ballot-statement id={`${id}-${cid}-statement`}>{c.statement}</span>} </label> }} </fieldset> <div.ui-coop-ballot-foot> <div> <p.ui-coop-ballot-count aria-live="polite">{count} of {seats} chosen</p> {full && seats > 1 && <p.ui-coop-ballot-full>That is the most this ballot takes: untick one to choose another.</p>} </div> <button.ui-coop-ballot-submit type="submit" disabled={count < least}>{submit}</button> </div> </form>}Related
AssemblyNoticeA general assembly notice: date and time, the venue or online link, the quorum, a numbered agenda and an RSVP form.6 views · 6 downloadsMembershipApplicationA co-op membership application in steps: personal details, ID, beneficiaries adding up to 100%, the share pledge, and a review.7 views · 6 downloadsPatronageRefundCalcWorks out a co-op member's dividend on shares and patronage refund on interest paid or purchases, to the total, as they type.7 views · 4 downloadsAccountPickerChoose the account to pay from as radio cards with each one's masked number and available balance, those short of the amount marked.7 views · 7 downloadsCardControlsA card's controls beside its masked face: freeze it, set daily spending and cash limits, and allow online, ATM, contactless and abroad use.7 views · 6 downloadsCurrencyConverterAn amount and two currencies with a swap button, the rate both ways and the converted amount, crossed through the base from the rates given.8 views · 11 downloads
Thread
Thread 0 posts
On CoopBallot: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.