Answers sent
Astro UI
Approval Card.
Questions an agent asks, one step at a time, before acting.
AgentComponent2 dependencies Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import ApprovalCard from "../components/viewership/approval-card/ApprovalCard.astro"; --- <ApprovalCard />
Props 4
| Prop | Type | Default | Description |
|---|---|---|---|
questions | { q: string; type: "radio" | "check"; options: string[] }[] | · | One step per question. Free-text answer always allowed. |
labels | Partial<ApprovalLabels> | · | Text overrides. |
resettable | boolean | true | Show Start over after sending. |
autoAdvance | boolean | true | Advance after a pointer or Space pick, not arrow keys. |
Events
| Event | Detail | When |
|---|---|---|
vs-approval-submit | CustomEvent<ApprovalAnswer[]> | Fires on send. Skipped steps are empty. |
vs-approval-change | CustomEvent<{ index: number; answer: ApprovalAnswer }> | Fires when a step's answer changes. |
vs-approval-dismiss | CustomEvent<{ index: number }> | Fires on close, or Skip on the last step. |
Source 1 file
---
/**
* ApprovalCard: questions an agent asks a person before acting.
*
* Adapted from Beautiful UI ApprovalCard (MIT, © 2026 Shane Levine).
*
* One question at a time. Options are native radios and checkboxes. Single
* choice auto-advances on pointer or Space, not on arrow keys. Every
* question also takes a free-text answer.
*
* Events (bubbling, on the root):
* vs-approval-submit detail: ApprovalAnswer[] (one per question)
* vs-approval-change detail: { index, answer: ApprovalAnswer }
* vs-approval-dismiss detail: { index } (the question showing when closed)
*
* @example
* <ApprovalCard questions={[{ q: "Publish today?", type: "radio", options: ["Yes", "Tomorrow"] }]} />
*/
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
import GlideMenu from "../glide-menu/GlideMenu.astro";
export interface ApprovalQuestion {
/** The question; it is the heading of its step. */
q: string;
/** "radio" = pick one, "check" = pick any. */
type: "radio" | "check";
options: string[];
}
/** What `vs-approval-submit` reports for each question. */
export interface ApprovalAnswer {
question: string;
/** Picked options, by label. */
selected: string[];
/** Picked options, by index. */
indices: number[];
/** Free-text answer ("" when empty). */
custom: string;
}
export interface ApprovalLabels {
skip: string;
continue: string;
send: string;
customPlaceholder: string;
customLabel: string;
sentMessage: string;
startOver: string;
dismiss: string;
reopen: string;
previous: string;
next: string;
/** Screen-reader step text. {n} and {total} are replaced. */
step: string;
}
type Props = HTMLAttributes<"div"> & {
questions: ApprovalQuestion[];
labels?: Partial<ApprovalLabels>;
/** Show "Start over" after sending. */
resettable?: boolean;
/** Single-choice answers advance on their own (pointer or Space). */
autoAdvance?: boolean;
};
const { questions = [], labels, resettable = true, autoAdvance = true, class: className, ...rest } = Astro.props;
const l: ApprovalLabels = {
skip: "Skip",
continue: "Continue",
send: "Send",
customPlaceholder: "Something else…",
customLabel: "Something else",
sentMessage: "Answers sent",
startOver: "Start over",
dismiss: "Dismiss",
reopen: "Open approval",
previous: "Previous question",
next: "Next question",
step: "Question {n} of {total}",
...labels,
};
const uid = `vs-approval-${Math.random().toString(36).slice(2, 8)}`;
const total = questions.length;
const checkPath = '<path d="M20 6L9 17l-5-5" />';
---
<div
class:list={["vs-approval-card", className]}
data-auto-advance={autoAdvance ? "" : undefined}
data-labels={JSON.stringify({ continue: l.continue, send: l.send, step: l.step })}
{...rest}
>
<div class="card" data-card>
<button type="button" class="dismiss" aria-label={l.dismiss} data-dismiss>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
<div class="pad">
<div class="viewport" data-viewport>
<div class="track" data-track>
{questions.map((question, qi) => {
const titleId = `${uid}-q${qi}`;
const kind = question.type === "radio" ? "radio" : "checkbox";
return (
<div class="question" data-question={qi} data-type={question.type} data-active={qi === 0 ? "" : undefined}>
<div class="title" id={titleId}>{question.q}</div>
<GlideMenu class="options">
<div class="group" role={kind === "radio" ? "radiogroup" : "group"} aria-labelledby={titleId}>
{question.options.map((option, i) => (
<label class="option" data-menu-row>
<input class="native" type={kind} name={`${titleId}-opt`} value={String(i)} data-option={option} />
<span class:list={["mark", `mark--${kind}`]} aria-hidden="true">
{kind === "radio" ? (
<span class="dot"></span>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
)}
</span>
<span class="option-label">{option}</span>
</label>
))}
</div>
<label class="option option--custom" data-menu-row>
<input class="custom" type="text" placeholder={l.customPlaceholder} aria-label={`${l.customLabel}: ${question.q}`} autocomplete="off" data-custom />
</label>
</GlideMenu>
</div>
);
})}
</div>
</div>
</div>
<div class="footer">
<div class="pager">
<button type="button" class="pager-btn" aria-label={l.previous} disabled data-prev>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 15l-6-6-6 6"></path></svg>
</button>
<span class="counter" aria-hidden="true" data-counter>{`1 / ${total}`}</span>
<span class="vh" data-step-text>{l.step.replace("{n}", "1").replace("{total}", String(total))}</span>
<button type="button" class="pager-btn" aria-label={l.next} disabled={total <= 1} data-next>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg>
</button>
</div>
<div class="actions">
<Button variant="ghost" size="sm" data-skip>{l.skip}</Button>
<Button variant="accent" size="sm" disabled data-continue>{total <= 1 ? l.send : l.continue}</Button>
</div>
</div>
</div>
<div class="sent" role="status" tabindex="-1" hidden data-sent>
<span class="sent-pill">
<span class="sent-check" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
</span>
{l.sentMessage}
</span>
{resettable && <button type="button" class="start-over" data-reset>{l.startOver}</button>}
</div>
<button type="button" class="reopen" hidden data-reopen>{l.reopen}</button>
<span class="vh" aria-live="polite" data-live></span>
</div>
<script>
interface ApprovalAnswer {
question: string;
selected: string[];
indices: number[];
custom: string;
}
const ROLL_MS = 400;
const ADVANCE_MS = 480;
/** Odometer: each character that changes rolls up (or down). Built inline so it needs no scoped CSS. */
function rollCounter(el: HTMLElement, value: string, animate: boolean) {
const from = el.dataset.value ?? el.textContent ?? value;
el.dataset.value = value;
if (!animate || from === value) {
el.textContent = value;
return;
}
const fromN = parseInt(from, 10);
const toN = parseInt(value, 10);
const down = Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN;
el.replaceChildren();
const cols: HTMLElement[] = [];
for (let i = 0; i < value.length; i++) {
const o = from[i] ?? "";
const n = value[i] ?? "";
if (o === n) {
el.append(n);
continue;
}
const box = document.createElement("span");
box.style.cssText = "display:inline-block;position:relative;overflow:hidden;height:1em;line-height:1em;vertical-align:-0.05em";
const col = document.createElement("span");
col.style.cssText = `display:flex;flex-direction:column;transition:transform 350ms cubic-bezier(0.4,0,0.2,1);transform:translateY(${down ? "-1em" : "0"})`;
for (const ch of down ? [n, o] : [o, n]) {
const cell = document.createElement("span");
cell.style.cssText = "height:1em;line-height:1em";
cell.textContent = ch;
col.append(cell);
}
box.append(col);
el.append(box);
cols.push(col);
}
requestAnimationFrame(() =>
requestAnimationFrame(() => cols.forEach((col) => (col.style.transform = `translateY(${down ? "0" : "-1em"})`))),
);
window.setTimeout(() => {
if (el.dataset.value === value) el.textContent = value;
}, ROLL_MS);
}
function init(root: HTMLElement) {
const card = root.querySelector<HTMLElement>("[data-card]")!;
const viewport = root.querySelector<HTMLElement>("[data-viewport]")!;
const track = root.querySelector<HTMLElement>("[data-track]")!;
const questions = [...root.querySelectorAll<HTMLElement>("[data-question]")];
const prev = root.querySelector<HTMLButtonElement>("[data-prev]")!;
const next = root.querySelector<HTMLButtonElement>("[data-next]")!;
const skip = root.querySelector<HTMLButtonElement>("[data-skip]")!;
const cont = root.querySelector<HTMLButtonElement>("[data-continue]")!;
const counter = root.querySelector<HTMLElement>("[data-counter]")!;
const stepText = root.querySelector<HTMLElement>("[data-step-text]")!;
const sent = root.querySelector<HTMLElement>("[data-sent]")!;
const reset = root.querySelector<HTMLButtonElement>("[data-reset]");
const reopen = root.querySelector<HTMLButtonElement>("[data-reopen]")!;
const dismiss = root.querySelector<HTMLButtonElement>("[data-dismiss]")!;
const live = root.querySelector<HTMLElement>("[data-live]")!;
const labels = JSON.parse(root.dataset.labels || "{}") as { continue: string; send: string; step: string };
const autoAdvance = root.hasAttribute("data-auto-advance");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const total = questions.length;
if (!total) return;
let qi = 0;
let measured = false;
let advanceTimer: number | undefined;
let intent = 0; // timestamp of the last pointer / Space activation
const inputs = (q: HTMLElement) => [...q.querySelectorAll<HTMLInputElement>("input.native")];
const customOf = (q: HTMLElement) => q.querySelector<HTMLInputElement>("[data-custom]")!;
const answerOf = (q: HTMLElement): ApprovalAnswer => {
const picked = inputs(q).filter((i) => i.checked);
return {
question: q.querySelector(".title")?.textContent ?? "",
selected: picked.map((i) => i.dataset.option ?? ""),
indices: picked.map((i) => Number(i.value)),
custom: customOf(q).value.trim(),
};
};
const hasAnswer = (q: HTMLElement) => {
const a = answerOf(q);
return a.indices.length > 0 || a.custom.length > 0;
};
/* the viewport clips to the active question; the track slides it into place */
const sync = (animate: boolean) => {
const item = questions[qi]!;
const anim = animate && !reduce.matches;
viewport.toggleAttribute("data-animate", anim);
track.toggleAttribute("data-animate", anim);
viewport.style.height = `${item.offsetHeight}px`;
track.style.transform = `translate3d(0, ${-item.offsetTop}px, 0)`;
};
const updateFooter = () => {
const last = qi === total - 1;
prev.disabled = qi <= 0;
next.disabled = last;
cont.disabled = !hasAnswer(questions[qi]!);
cont.textContent = last ? labels.send : labels.continue;
};
const render = (animate: boolean) => {
questions.forEach((q, i) => {
const active = i === qi;
q.toggleAttribute("data-active", active);
q.inert = !active;
});
const value = `${qi + 1} / ${total}`;
rollCounter(counter, value, animate && !reduce.matches);
stepText.textContent = labels.step.replace("{n}", String(qi + 1)).replace("{total}", String(total));
updateFooter();
sync(animate);
};
const goTo = (index: number) => {
window.clearTimeout(advanceTimer);
const target = Math.min(Math.max(index, 0), total - 1);
if (target === qi) return;
const hadFocus = questions[qi]!.contains(document.activeElement);
qi = target;
render(measured);
live.textContent = stepText.textContent ?? "";
// Focus would be stranded in the now-inert question; carry it along.
if (hadFocus) {
const q = questions[qi]!;
const into = inputs(q).find((i) => i.checked) ?? inputs(q)[0] ?? customOf(q);
into.focus({ preventScroll: true });
}
};
const send = () => {
window.clearTimeout(advanceTimer);
card.hidden = true;
sent.hidden = false;
root.dispatchEvent(new CustomEvent<ApprovalAnswer[]>("vs-approval-submit", { bubbles: true, detail: questions.map(answerOf) }));
(reset ?? sent).focus();
};
const advance = () => {
if (qi === total - 1) send();
else goTo(qi + 1);
};
const close = () => {
window.clearTimeout(advanceTimer);
card.hidden = true;
reopen.hidden = false;
root.dispatchEvent(new CustomEvent("vs-approval-dismiss", { bubbles: true, detail: { index: qi } }));
reopen.focus();
};
const emitChange = (q: HTMLElement) => {
updateFooter();
const index = questions.indexOf(q);
root.dispatchEvent(new CustomEvent("vs-approval-change", { bubbles: true, detail: { index, answer: answerOf(q) } }));
};
questions.forEach((q) => {
const custom = customOf(q);
const radio = q.dataset.type === "radio";
q.addEventListener("pointerdown", () => (intent = performance.now()));
q.addEventListener("keydown", (event) => {
const target = event.target as HTMLElement;
if (event.key === " " && target.matches("input.native")) intent = performance.now();
if (event.key === "Enter" && (target === custom || target.matches("input.native")) && hasAnswer(q)) {
event.preventDefault();
advance();
}
});
q.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
if (!input.matches("input.native")) return;
if (radio && input.checked) custom.value = "";
emitChange(q);
});
// Auto-advance only on a deliberate pick (pointer or Space), never while arrowing
// through options. Listening to click also catches re-picking the checked option.
q.addEventListener("click", (event) => {
const input = event.target as HTMLElement;
if (!radio || !autoAdvance || !input.matches("input.native")) return;
if (performance.now() - intent > 800) return;
window.clearTimeout(advanceTimer);
advanceTimer = window.setTimeout(advance, ADVANCE_MS);
});
custom.addEventListener("input", () => {
window.clearTimeout(advanceTimer);
if (radio && custom.value) inputs(q).forEach((i) => (i.checked = false));
emitChange(q);
});
});
prev.addEventListener("click", () => goTo(qi - 1));
next.addEventListener("click", () => goTo(qi + 1));
skip.addEventListener("click", () => (qi === total - 1 ? close() : goTo(qi + 1)));
cont.addEventListener("click", advance);
dismiss.addEventListener("click", close);
reopen.addEventListener("click", () => {
reopen.hidden = true;
card.hidden = false;
render(false);
dismiss.focus();
});
reset?.addEventListener("click", () => {
questions.forEach((q) => {
inputs(q).forEach((i) => (i.checked = false));
customOf(q).value = "";
});
qi = 0;
sent.hidden = true;
card.hidden = false;
render(false);
questions[0]!.querySelector<HTMLInputElement>("input")?.focus();
});
// Questions can reflow (fonts, container width): keep the viewport fitted.
const ro = new ResizeObserver(() => {
if (!card.hidden) sync(false);
});
questions.forEach((q) => ro.observe(q));
root.setAttribute("data-enhanced", "");
render(false);
measured = true;
document.addEventListener(
"astro:before-swap",
() => {
window.clearTimeout(advanceTimer);
ro.disconnect();
},
{ once: true },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-approval-card:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-approval-card {
width: 100%;
max-width: 320px;
}
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ── card ── */
.card {
position: relative;
overflow: hidden;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
animation: vs-fade-up 380ms var(--vs-ease-out) both;
}
.dismiss {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--vs-radius-control);
background: transparent;
color: var(--vs-ink-3);
cursor: pointer;
transition:
background-color 100ms ease-out,
color 100ms ease-out;
}
.dismiss:hover {
background: var(--vs-hover);
color: var(--vs-ink);
}
.pad {
padding: 12px;
}
/* ── question stack ── */
.viewport {
overflow: hidden;
}
.viewport[data-animate] {
transition: height 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.track {
display: flex;
flex-direction: column;
gap: 26px;
will-change: transform;
}
.track[data-animate] {
transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Before the script measures, only the first question renders (no height flash). */
.vs-approval-card:not([data-enhanced]) .question:not(:first-child) {
display: none;
}
[data-enhanced] .question:not([data-active]) {
opacity: 0;
pointer-events: none;
}
[data-enhanced] .track[data-animate] .question {
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.title {
padding-right: 28px;
font-size: 14px;
font-weight: 500;
color: var(--vs-ink);
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
}
.group {
display: flex;
flex-direction: column;
gap: 4px;
}
.option {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 4px;
border-radius: var(--vs-radius-control);
text-align: left;
cursor: pointer;
transition: background-color 100ms ease-out;
}
/* inset ring: the sliding viewport clips anything drawn outside the row */
.option:has(.native:focus-visible) {
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
}
.native {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.mark {
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1.5px var(--vs-line-strong);
color: transparent;
transition:
background-color 200ms ease-out,
color 200ms ease-out,
box-shadow 200ms ease-out;
}
.mark--radio {
border-radius: 50%;
}
.mark--checkbox {
border-radius: 5px;
}
.native:checked + .mark {
background: var(--vs-ink);
color: var(--vs-canvas);
box-shadow: none;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vs-canvas);
transform: scale(0);
transition: transform 200ms ease-out;
}
.native:checked + .mark .dot {
transform: scale(1);
}
.option-label {
font-size: 13px;
line-height: 1;
color: var(--vs-ink-2);
transition: color 200ms ease-out;
}
.native:checked ~ .option-label {
color: var(--vs-ink);
}
.option--custom {
cursor: text;
}
.custom {
min-width: 0;
height: 16px;
flex: 1;
padding: 0 0 0 6px;
border: none;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
outline: none;
}
.custom::placeholder {
color: var(--vs-ink-3);
}
.option--custom:has(.custom:focus-visible) {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-accent) 55%, transparent);
}
/* ── footer ── */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px;
}
.pager {
display: flex;
align-items: center;
gap: 1px;
color: var(--vs-ink-3);
}
/* 24px hit area around the 18px upstream glyph box */
.pager-btn {
display: flex;
width: 24px;
height: 24px;
margin: 0 -2px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
color: inherit;
cursor: pointer;
transition: color 100ms ease-out;
}
.pager-btn:enabled:hover {
color: var(--vs-ink);
}
.pager-btn:disabled {
opacity: 0.3;
cursor: default;
}
.counter {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.1px;
line-height: 1;
white-space: pre;
color: var(--vs-ink-3);
}
.actions {
display: flex;
align-items: center;
gap: 6px;
margin-right: -2px;
}
/* ── sent / closed ── */
.sent {
display: flex;
align-items: center;
gap: 12px;
outline: none;
animation: vs-pop-in 260ms var(--vs-ease-out) both;
}
.sent[hidden] {
display: none;
}
.sent-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 4px;
border-radius: var(--vs-radius-pill);
background: var(--vs-green-tint);
color: var(--vs-green);
font-size: 12.5px;
font-weight: 500;
}
.sent-check {
display: flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--vs-green);
color: var(--vs-surface);
}
.start-over {
min-height: 24px;
padding: 0 2px;
border: none;
background: transparent;
color: var(--vs-ink-3);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: color var(--vs-duration-fast) ease-out;
}
.start-over:hover {
color: var(--vs-ink);
}
.reopen {
padding: 8px 12px;
border: none;
border-radius: var(--vs-radius-control);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-btn);
color: var(--vs-ink);
font: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--vs-duration-fast) ease-out;
}
.reopen:hover {
background: var(--vs-hover);
}
:is(.dismiss, .pager-btn, .start-over, .reopen):focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
@media (prefers-reduced-motion: reduce) {
.card,
.sent {
animation: none;
}
.mark,
.dot,
.option-label {
transition: none;
}
}
</style> Ported from Beautiful UI, MIT. Notices.