Finance

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.

#finance#coop#forms#vote#interactive

Demo

Preview

Board of Directors

Choose up to 3.

Voting closes March 14, 2026 at 5:00 PM, at the General Assembly.

Board of Directors: candidates

0 of 3 chosen

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

Thread 0 posts

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

No posts yet. The first one opens the thread.