Menu
Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f
AkurAI-Framework / site/frontend/ai/components/input/question_flow.js
Latest change 63ae119f1bdd5ddffd3f9a2b3b3216aa9d21449b - feat(ai): Input component batch — option_list, parameter_slider, preferences_panel, question_flow by Ólafur Búi Ólafsson
/**
* Question Flow — tool-UI input component.
* Payload: { questions: [{ id, prompt, type, options? }] }
* question.type: 'single' (radio) | 'multi' (checkboxes) | 'text' (free text)
* options for single/multi: [{ id, label }] or string[]
* Receipt: { component: 'question_flow', answers: { [id]: value } }
* value: string (single/text) | string[] (multi)
*/
function emit(el, detail) {
el.dispatchEvent(new CustomEvent('toolui:input', { bubbles: true, detail }));
}
function optId(opt) {
return (opt && typeof opt === 'object') ? String(opt.id ?? opt) : String(opt);
}
function optLabel(opt) {
return (opt && typeof opt === 'object') ? String(opt.label ?? opt.id ?? opt) : String(opt);
}
export const def = {
name: 'question_flow',
category: 'Input',
title: 'Question Flow',
match(p) {
return p != null && Array.isArray(p.questions) && p.questions.length > 0;
},
render(p, ctx) {
const { questions = [] } = p;
const answers = {};
let current = 0;
const root = document.createElement('div');
root.className = 'card';
root.style.cssText = 'display:grid;gap:16px;max-width:480px;';
// Progress bar
const progressWrap = document.createElement('div');
progressWrap.className = 'progress';
const progressBar = document.createElement('span');
progressBar.className = 'progress-bar';
progressBar.style.width = '0%';
progressWrap.appendChild(progressBar);
root.appendChild(progressWrap);
// Step counter
const stepLabel = document.createElement('div');
stepLabel.style.cssText =
'font-size:12px;color:var(--muted);text-align:right;margin-top:-8px;';
root.appendChild(stepLabel);
// Question area (rebuilt on each step)
const qArea = document.createElement('div');
qArea.style.cssText = 'display:grid;gap:12px;';
root.appendChild(qArea);
// Navigation
const nav = document.createElement('div');
nav.style.cssText = 'display:flex;justify-content:space-between;gap:10px;';
const backBtn = document.createElement('button');
backBtn.className = 'btn btn-secondary btn-sm';
backBtn.textContent = '← Back';
const nextBtn = document.createElement('button');
nextBtn.className = 'btn btn-primary btn-sm';
nav.appendChild(backBtn);
nav.appendChild(nextBtn);
root.appendChild(nav);
function getAnswer() {
const q = questions[current];
if (q.type === 'text') {
const inp = qArea.querySelector('input[type=text],textarea');
return inp ? inp.value.trim() : '';
} else if (q.type === 'multi') {
return [...qArea.querySelectorAll('input[type=checkbox]:checked')].map(i => i.value);
} else {
const checked = qArea.querySelector('input[type=radio]:checked');
return checked ? checked.value : null;
}
}
function highlightRow(row, checked) {
row.style.borderColor = checked ? 'var(--accent)' : '';
row.style.background = checked
? 'color-mix(in srgb,var(--accent) 8%,var(--panel))'
: '';
}
function renderStep() {
const q = questions[current];
const pct = Math.round((current / questions.length) * 100);
progressBar.style.width = pct + '%';
stepLabel.textContent = `${current + 1} of ${questions.length}`;
backBtn.disabled = current === 0;
nextBtn.textContent = current < questions.length - 1 ? 'Next →' : 'Finish';
// Clear previous content safely
while (qArea.firstChild) qArea.removeChild(qArea.firstChild);
const prompt = document.createElement('p');
prompt.style.cssText = 'margin:0;font-weight:600;font-size:15px;';
prompt.textContent = q.prompt;
qArea.appendChild(prompt);
if (q.type === 'text') {
const inp = document.createElement('input');
inp.type = 'text';
const prior = answers[q.id];
inp.value = prior != null ? String(prior) : '';
inp.placeholder = 'Your answer…';
qArea.appendChild(inp);
// focus for usability
setTimeout(() => inp.focus(), 0);
} else {
const isMulti = q.type === 'multi';
const nameAttr = 'tc-qf-' + q.id;
const prior = answers[q.id];
(q.options || []).forEach(opt => {
const id = optId(opt);
const row = document.createElement('label');
row.style.cssText =
'display:flex;align-items:center;gap:10px;padding:9px 12px;' +
'border:1px solid var(--border);border-radius:10px;cursor:pointer;' +
'transition:border-color .15s,background .15s;';
const inp = document.createElement('input');
inp.type = isMulti ? 'checkbox' : 'radio';
inp.name = nameAttr;
inp.value = id;
inp.style.flexShrink = '0';
// Restore prior selection
if (isMulti && Array.isArray(prior)) {
inp.checked = prior.includes(id);
} else if (!isMulti) {
inp.checked = prior === id;
}
inp.addEventListener('change', () => {
if (!isMulti) {
qArea.querySelectorAll('label').forEach(l => highlightRow(l, false));
}
if (isMulti) {
highlightRow(row, inp.checked);
} else {
highlightRow(row, true);
}
});
const textEl = document.createElement('span');
textEl.textContent = optLabel(opt);
row.appendChild(inp);
row.appendChild(textEl);
// Apply initial highlight
if (inp.checked) highlightRow(row, true);
qArea.appendChild(row);
});
}
}
backBtn.addEventListener('click', () => {
if (current > 0) {
answers[questions[current].id] = getAnswer();
current--;
renderStep();
}
});
nextBtn.addEventListener('click', () => {
answers[questions[current].id] = getAnswer();
if (current < questions.length - 1) {
current++;
renderStep();
} else {
// Finish
const receipt = { component: 'question_flow', answers: Object.assign({}, answers) };
if (ctx && typeof ctx.sendReceipt === 'function') ctx.sendReceipt(receipt);
emit(root, receipt);
// Completion state
while (qArea.firstChild) qArea.removeChild(qArea.firstChild);
const done = document.createElement('p');
done.style.cssText = 'margin:0;color:var(--ok);font-weight:600;font-size:15px;';
done.textContent = '✓ Answers submitted';
qArea.appendChild(done);
nav.style.display = 'none';
progressBar.style.width = '100%';
stepLabel.textContent = `${questions.length} of ${questions.length}`;
}
});
renderStep();
return root;
},
example: {
questions: [
{
id: 'role',
prompt: 'What is your primary role?',
type: 'single',
options: [
{ id: 'dev', label: 'Developer' },
{ id: 'design', label: 'Designer' },
{ id: 'pm', label: 'Product manager' }
]
},
{
id: 'tools',
prompt: 'Which tools do you use regularly?',
type: 'multi',
options: [
{ id: 'vscode', label: 'VS Code' },
{ id: 'figma', label: 'Figma' },
{ id: 'linear', label: 'Linear' },
{ id: 'notion', label: 'Notion' }
]
},
{
id: 'goal',
prompt: 'What are you trying to build? (describe briefly)',
type: 'text'
}
]
}
};