{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "questionnaire",
  "title": "Questionnaire",
  "type": "registry:ui",
  "description": "Single multi-step question component. Pass items, shortcuts, and showProgress — variants are props, not extra files.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/questionnaire/questionnaire.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { questionnaireChoiceVariants, questionnaireInputVariants } from './questionnaire.variants'\n\nexport type QuestionnaireValue = string | string[]\nexport type QuestionnaireAnswers = Record<string, QuestionnaireValue | undefined>\n\nexport interface QuestionnaireChoiceDef {\n  value: string\n  label: string\n  description?: string\n  disabled?: boolean\n}\n\nexport interface QuestionnaireItemDef {\n  name: string\n  prompt: string\n  description?: string\n  required?: boolean\n  multiple?: boolean\n  choices?: QuestionnaireChoiceDef[]\n  input?: { label?: string; placeholder?: string }\n}\n\nexport type QuestionnaireShortcuts = 'letters' | 'numbers' | false\n\nconst objectConverter = {\n  fromAttribute: (v: string | null) => {\n    if (!v) return undefined\n    try {\n      return JSON.parse(v)\n    } catch {\n      return undefined\n    }\n  },\n  toAttribute: (v: unknown) => (v ? JSON.stringify(v) : null),\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-questionnaire> — the registry Questionnaire as a web component.\n *\n * Form-associated: submits answered fields with its <form>.\n */\nexport class UipQuestionnaire extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    items: { converter: objectConverter },\n    shortcuts: {},\n    showProgress: { converter: trueUnlessFalse, attribute: 'show-progress' },\n    requiredMessage: { attribute: 'required-message' },\n    value: { reflect: true },\n    defaultValue: { attribute: 'default-value' },\n    answers: { converter: objectConverter },\n    defaultAnswers: { attribute: 'default-answers', converter: objectConverter },\n    showError: { state: true },\n  }\n\n  items: QuestionnaireItemDef[] = []\n  shortcuts: QuestionnaireShortcuts | string = false\n  showProgress = true\n  requiredMessage = 'Choose an answer to continue.'\n  value?: string\n  defaultValue?: string\n  answers?: QuestionnaireAnswers\n  defaultAnswers?: QuestionnaireAnswers\n\n  private showError = false\n  private internalValue?: string\n  private internalAnswers: QuestionnaireAnswers = {}\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'questionnaire')\n    if (this.defaultAnswers) {\n      this.internalAnswers = { ...this.defaultAnswers }\n    }\n    if (this.defaultValue) {\n      this.internalValue = this.defaultValue\n    }\n    this.syncFormValue()\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('answers') || changed.has('items')) {\n      this.syncFormValue()\n    }\n  }\n\n  private syncFormValue() {\n    const currentAnswers = this.currentAnswers\n    const fd = new FormData()\n    for (const [k, v] of Object.entries(currentAnswers)) {\n      if (Array.isArray(v)) {\n        v.forEach((val) => fd.append(`${k}[]`, val))\n      } else if (v !== undefined) {\n        fd.append(k, String(v))\n      }\n    }\n    this.internals.setFormValue(fd)\n  }\n\n  formResetCallback() {\n    this.internalAnswers = this.defaultAnswers ? { ...this.defaultAnswers } : {}\n    this.internalValue = this.defaultValue\n    this.showError = false\n    this.requestUpdate()\n    this.syncFormValue()\n  }\n\n  private get currentAnswers(): QuestionnaireAnswers {\n    return this.answers ?? this.internalAnswers\n  }\n\n  private get names(): string[] {\n    return this.items.map((i) => i.name)\n  }\n\n  private get currentName(): string | undefined {\n    return this.value ?? this.internalValue ?? this.names[0]\n  }\n\n  private get activeIndex(): number {\n    return Math.max(0, this.names.indexOf(this.currentName ?? ''))\n  }\n\n  private get current(): QuestionnaireItemDef | undefined {\n    return this.items[this.activeIndex]\n  }\n\n  private get isFirst(): boolean {\n    return this.activeIndex <= 0\n  }\n\n  private get isLast(): boolean {\n    return this.names.length === 0 || this.activeIndex >= this.names.length - 1\n  }\n\n  private setActive(name: string) {\n    this.value = name\n    this.internalValue = name\n    this.showError = false\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: name }, bubbles: true, composed: true }))\n  }\n\n  private writeAnswers(next: QuestionnaireAnswers) {\n    this.internalAnswers = next\n    this.syncFormValue()\n    this.dispatchEvent(new CustomEvent('answers-change', { detail: { answers: next }, bubbles: true, composed: true }))\n    this.requestUpdate()\n  }\n\n  private isAnswered(name: string): boolean {\n    const v = this.currentAnswers[name]\n    if (Array.isArray(v)) return v.length > 0\n    return typeof v === 'string' && v.trim().length > 0\n  }\n\n  private setAnswer(name: string, next: QuestionnaireValue | undefined) {\n    this.writeAnswers({ ...this.currentAnswers, [name]: next })\n    this.showError = false\n  }\n\n  private toggleChoice(name: string, choice: string, multiple?: boolean) {\n    if (multiple) {\n      const cur = Array.isArray(this.currentAnswers[name]) ? [...(this.currentAnswers[name] as string[])] : []\n      this.setAnswer(name, cur.includes(choice) ? cur.filter((v) => v !== choice) : [...cur, choice])\n      return\n    }\n    this.setAnswer(name, choice)\n  }\n\n  private shortcutFor(index: number): string | undefined {\n    const s = this.shortcuts\n    if (!s || s === 'false') return undefined\n    if (s === 'letters' && index < 26) return String.fromCharCode(65 + index)\n    if (s === 'numbers' && index < 9) return String(index + 1)\n    return undefined\n  }\n\n  private choiceChecked(name: string, choice: string): boolean {\n    const v = this.currentAnswers[name]\n    if (Array.isArray(v)) return v.includes(choice)\n    return v === choice\n  }\n\n  private goTo(index: number) {\n    const name = this.names[index]\n    if (name) this.setActive(name)\n  }\n\n  private goNext(): boolean {\n    if (!this.current) return false\n    if (this.current.required && !this.isAnswered(this.current.name)) {\n      this.showError = true\n      return false\n    }\n    this.showError = false\n    if (this.isLast) {\n      this.dispatchEvent(new CustomEvent('submit', { detail: { answers: this.currentAnswers }, bubbles: true, composed: true }))\n      return true\n    }\n    this.goTo(this.activeIndex + 1)\n    return true\n  }\n\n  private skip() {\n    if (!this.current || this.current.required) return\n    this.showError = false\n    if (this.isLast) {\n      this.dispatchEvent(new CustomEvent('submit', { detail: { answers: this.currentAnswers }, bubbles: true, composed: true }))\n    } else {\n      this.goTo(this.activeIndex + 1)\n    }\n  }\n\n  private handleKeyDown(e: KeyboardEvent) {\n    if (e.defaultPrevented) return\n    const target = e.target as HTMLElement\n    const inField = target.closest('input, textarea, select')\n    if (inField) {\n      if (e.key === 'Enter' && target.tagName === 'INPUT') {\n        e.preventDefault()\n        this.goNext()\n      }\n      return\n    }\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      this.goNext()\n      return\n    }\n    if (!this.shortcuts || this.shortcuts === 'false' || e.metaKey || e.ctrlKey || e.altKey || !this.current) return\n    const values = this.current.choices?.map((c) => c.value) ?? []\n    let idx = -1\n    if (this.shortcuts === 'letters') idx = e.key.toLowerCase().charCodeAt(0) - 97\n    if (this.shortcuts === 'numbers') idx = Number(e.key) - 1\n    if (idx < 0 || idx >= values.length) return\n    e.preventDefault()\n    this.toggleChoice(this.current.name, values[idx]!, this.current.multiple)\n  }\n\n  render() {\n    const current = this.current\n    const names = this.names\n    const answers = this.currentAnswers\n    const progress = { current: names.length ? this.activeIndex + 1 : 0, total: Math.max(names.length, 1) }\n\n    return html`\n      <form\n        part=\"base\"\n        data-uipkge=\"\"\n        data-slot=\"questionnaire\"\n        class=\"flex w-full max-w-lg flex-col gap-4\"\n        @submit=${(e: Event) => {\n          e.preventDefault()\n          this.goNext()\n        }}\n        @keydown=${this.handleKeyDown}\n      >\n        ${this.showProgress\n          ? html`\n              <div\n                data-slot=\"questionnaire-progress\"\n                role=\"progressbar\"\n                aria-valuemin=\"1\"\n                aria-valuenow=${progress.current}\n                aria-valuemax=${progress.total}\n                aria-label=${`Question ${progress.current} of ${progress.total}`}\n                class=\"flex flex-col gap-2\"\n              >\n                <div class=\"bg-muted h-1 w-full overflow-hidden rounded-full\">\n                  <div\n                    class=\"bg-primary h-full rounded-full transition-[width] duration-200\"\n                    style=${styleMap({ width: `${(progress.current / progress.total) * 100}%` })}\n                  ></div>\n                </div>\n                <p class=\"text-muted-foreground text-xs\">\n                  Question ${progress.current} of ${progress.total}\n                </p>\n              </div>\n            `\n          : nothing}\n\n        ${current\n          ? html`\n              <fieldset data-slot=\"questionnaire-item\" name=${current.name} class=\"flex min-w-0 flex-col gap-3\">\n                <legend data-slot=\"questionnaire-title\" class=\"text-foreground text-base font-medium\">\n                  ${current.prompt}\n                </legend>\n                ${current.description\n                  ? html`<p data-slot=\"questionnaire-description\" class=\"text-muted-foreground text-sm\">\n                      ${current.description}\n                    </p>`\n                  : nothing}\n                ${current.choices?.length\n                  ? html`\n                      <div data-slot=\"questionnaire-choices\" role=\"group\" class=\"flex flex-col gap-2\">\n                        ${current.choices.map((choice, index) => {\n                          const checked = this.choiceChecked(current.name, choice.value)\n                          return html`\n                            <label\n                              data-slot=\"questionnaire-choice\"\n                              ?data-checked=${checked}\n                              class=${cn(\n                                questionnaireChoiceVariants(),\n                                choice.disabled && 'pointer-events-none opacity-50',\n                              )}\n                            >\n                              <input\n                                class=\"sr-only\"\n                                type=${current.multiple ? 'checkbox' : 'radio'}\n                                name=${current.name}\n                                value=${choice.value}\n                                .checked=${checked}\n                                ?disabled=${choice.disabled}\n                                @change=${() => this.toggleChoice(current.name, choice.value, current.multiple)}\n                              />\n                              <span class=\"flex min-w-0 flex-1 flex-col\">\n                                <span class=\"font-medium\">${choice.label}</span>\n                                ${choice.description\n                                  ? html`<span class=\"text-muted-foreground text-xs\">${choice.description}</span>`\n                                  : nothing}\n                              </span>\n                              ${this.shortcutFor(index)\n                                ? html`\n                                    <kbd\n                                      data-slot=\"questionnaire-choice-shortcut\"\n                                      class=\"ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground\"\n                                    >\n                                      ${this.shortcutFor(index)}\n                                    </kbd>\n                                  `\n                                : nothing}\n                            </label>\n                          `\n                        })}\n                      </div>\n                    `\n                  : nothing}\n                ${current.input\n                  ? html`\n                      <input\n                        data-slot=\"questionnaire-input\"\n                        type=\"text\"\n                        name=${current.name}\n                        placeholder=${current.input.placeholder ?? nothing}\n                        .value=${typeof answers[current.name] === 'string' ? (answers[current.name] as string) : ''}\n                        class=${cn(questionnaireInputVariants())}\n                        @input=${(e: Event) => this.setAnswer(current.name, (e.target as HTMLInputElement).value)}\n                      />\n                    `\n                  : nothing}\n                ${this.showError && current.required && !this.isAnswered(current.name)\n                  ? html`\n                      <p data-slot=\"questionnaire-error\" role=\"alert\" class=\"text-destructive text-sm\">\n                        ${this.requiredMessage}\n                      </p>\n                    `\n                  : nothing}\n              </fieldset>\n            `\n          : nothing}\n\n        <div data-slot=\"questionnaire-actions\" class=\"flex flex-wrap items-center gap-2\">\n          <button\n            type=\"button\"\n            data-slot=\"questionnaire-previous\"\n            class=\"border-border bg-background hover:bg-accent inline-flex h-9 items-center rounded-md border px-4 text-sm font-medium disabled:opacity-50\"\n            ?disabled=${this.isFirst}\n            @click=${() => {\n              if (!this.isFirst) this.goTo(this.activeIndex - 1)\n            }}\n          >\n            Previous\n          </button>\n          ${current && !current.required\n            ? html`\n                <button\n                  type=\"button\"\n                  data-slot=\"questionnaire-skip\"\n                  class=\"hover:bg-accent inline-flex h-9 items-center rounded-md px-4 text-sm font-medium\"\n                  @click=${this.skip}\n                >\n                  Skip\n                </button>\n              `\n            : nothing}\n          ${!this.isLast\n            ? html`\n                <button\n                  type=\"button\"\n                  data-slot=\"questionnaire-next\"\n                  class=\"bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center rounded-md px-4 text-sm font-medium\"\n                  @click=${this.goNext}\n                >\n                  Next\n                </button>\n              `\n            : html`\n                <button\n                  type=\"submit\"\n                  data-slot=\"questionnaire-submit\"\n                  class=\"bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center rounded-md px-4 text-sm font-medium\"\n                >\n                  Submit\n                </button>\n              `}\n        </div>\n      </form>\n    `\n  }\n}\n\ncustomElements.get('uip-questionnaire') || customElements.define('uip-questionnaire', UipQuestionnaire)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-questionnaire': UipQuestionnaire\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/questionnaire/questionnaire.ts"
    },
    {
      "path": "packages/elements-lit/src/components/questionnaire/questionnaire.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport const questionnaireChoiceVariants = cva(\n  'border-border bg-card hover:bg-accent/50 has-[:focus-visible]:border-ring has-[:focus-visible]:ring-ring/50 has-[:checked]:border-primary has-[:checked]:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left text-sm transition-colors has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50 has-[:focus-visible]:ring-[3px]',\n)\n\nexport const questionnaireInputVariants = cva(\n  'border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]',\n)\n\nexport type QuestionnaireChoiceVariants = VariantProps<typeof questionnaireChoiceVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/questionnaire/questionnaire.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
