// A poll: participants add options and vote for one. This is the starting
// draft of every new widget document; see docs/widgets.md for the API.
defineWidget({
api: 1,
name: "Smoke poll",
init(state, args) {
state.createText("question", (args && args.question) || "What do you think?");
state.createList("options");
state.createMap("votes"); // participant ID → option ID
},
actions: {
addOption: {
doc: "Add an option. args: {text: string}",
run(state, { text }, ctx) {
if (!text || !String(text).trim()) throw new Error("an option needs some text");
const o = state.get("options").push();
o.createText("text", String(text).trim());
o.set("by", ctx.me.id);
},
},
removeOption: {
doc: "Remove an option. args: {option: id}",
run(state, { option }) {
const o = state.get("options").find(option);
if (!o) throw new Error("no such option");
state.get("options").remove(o);
},
},
vote: {
doc: "Vote for one option, replacing your vote. args: {option: id}",
run(state, { option }, ctx) {
if (!state.get("options").find(option)) throw new Error("no such option");
state.get("votes").set(ctx.me.id, option);
},
},
unvote: {
doc: "Withdraw your vote.",
run(state, _, ctx) {
state.get("votes").delete(ctx.me.id);
},
},
setQuestion: {
doc: "Change the question. args: {text: string}",
run(state, { text }) {
state.get("question").update(String(text));
},
},
},
summary(state) {
const lines = tally(state).map(({ option, n }) => `- ${option.get("text")} — ${n} vote${n === 1 ? "" : "s"} \`${option.id}\``);
return `**${state.get("question")}**\n\n${lines.join("\n") || "_No options yet._"}`;
},
view({ root, state, ctx, act, subscribe }) {
const style = document.createElement("style");
style.textContent = `
body { padding: .75rem 1rem; }
h3 { margin: 0 0 .5rem; font-size: 1rem; }
.option { display: flex; align-items: center; gap: .5rem; margin: .25rem 0; }
.option button.vote { flex: 1; text-align: left; position: relative; overflow: hidden; }
.option button.vote .bar { position: absolute; inset: 0 auto 0 0; background: #1c7ed622; }
.option button.vote span { position: relative; }
.option.chosen button.vote { border-color: #1c7ed6; font-weight: 600; }
.remove { border: 0; background: none; color: #888; cursor: pointer; }
form { display: flex; gap: .5rem; margin-top: .5rem; }
form input { flex: 1; }
`;
const list = h("div");
const question = h("h3");
const input = h("input", { placeholder: "Add an option", maxLength: 200 });
const form = h("form", {
onsubmit: (e) => {
e.preventDefault();
if (!input.value.trim()) return;
act("addOption", { text: input.value });
input.value = "";
},
}, input, h("button", {}, "Add"));
root.append(style, question, list, form);
const render = () => {
question.textContent = String(state.get("question"));
const mine = state.get("votes").get(ctx.me.id);
const counts = tally(state);
const total = counts.reduce((sum, c) => sum + c.n, 0);
list.replaceChildren(...counts.map(({ option, n }) => {
const chosen = option.id === mine;
const bar = h("div", { className: "bar" });
bar.style.width = total ? `${(100 * n) / total}%` : "0";
return h("div", { className: chosen ? "option chosen" : "option" },
h("button", {
className: "vote",
title: chosen ? "Withdraw your vote" : "Vote for this",
onclick: () => act(chosen ? "unvote" : "vote", { option: option.id }),
}, bar, h("span", {}, `${option.get("text")} · ${n}`)),
h("button", { className: "remove", title: "Remove", onclick: () => act("removeOption", { option: option.id }) }, "✕"));
}));
};
subscribe(render);
render();
},
});
// tally counts the votes for each option. Votes for removed options are
// ignored, and come back if the option is restored.
function tally(state) {
const votes = state.get("votes");
return state.get("options").items().map((option) => ({
option,
n: votes.keys().filter((p) => votes.get(p) === option.id).length,
}));
}
function h(tag, props, ...children) {
const el = Object.assign(document.createElement(tag), props);
el.append(...children);
return el;
}