AkurAI Build
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'
      }
    ]
  }
};