@lasercat/qna
v1.0.25
Published
A TypeScript-native library template with bun
Readme
QNA - Question and Answer Framework
A flexible, type-safe React-based framework for building dynamic questionnaires and forms with advanced features like conditional logic, validation, and priority management.
Features
- Multiple Question Types: Support for various question types including short-answer, long-form, multiple-choice, true/false, slider, stack-ranking, and numeric inputs
- Conditional Logic: Show/hide questions based on answers to other questions
- Validation: Built-in validation with support for custom validators
- Priority System: Assign priority levels to questions with customizable display styles
- Veto System: Allow users to flag problematic questions
- TypeScript Support: Fully typed with comprehensive TypeScript definitions
- Responsive Design: Built with Tailwind CSS for responsive, accessible UIs
Installation
npm install @lasercat/qna
# or
yarn add @lasercat/qna
# or
bun add @lasercat/qnaStyling Requirements
This library uses Tailwind CSS for styling. You must have Tailwind CSS v4 installed in your project:
npm install -D tailwindcss@4Then configure Tailwind to process the library's components:
// tailwind.config.js
export default {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./node_modules/@lasercat/qna/**/*.{js,jsx}', // Add this line
],
};Usage
Basic Example
import { QuestionRenderer } from '@lasercat/qna';
import type { MultipleChoiceQuestion, QuestionResponse } from '@lasercat/qna';
const question: MultipleChoiceQuestion = {
id: 'skills',
type: 'multiple-choice',
text: 'What are your programming skills?',
required: true,
priority: 'high',
tags: ['technical'],
options: [
{ id: 'js', label: 'JavaScript' },
{ id: 'ts', label: 'TypeScript' },
{ id: 'py', label: 'Python' },
],
multiple: true,
allowAdditionalText: false,
};
function MyForm() {
const [responses, setResponses] = useState<Record<string, QuestionResponse>>({});
const handleChange = (response: QuestionResponse) => {
setResponses((prev) => ({
...prev,
[response.questionId]: response,
}));
};
return (
<QuestionRenderer
question={question}
response={responses[question.id]}
onChange={handleChange}
/>
);
}Response Format
All question changes emit a complete QuestionResponse object:
interface QuestionResponse<T = unknown> {
questionId: string;
value: T; // The answer value
timestamp: Date;
valid: boolean; // Whether it passes validation
errors?: string[]; // Validation error messages
vetoed?: boolean; // If user vetoed the question (see Veto System)
vetoReason?: string; // Optional reason for veto
}Question Types
Multiple Choice Questions
Multiple choice questions support both single and multi-select modes, with an optional always-visible text input for additional information.
Basic Multiple Choice
const question: MultipleChoiceQuestion = {
id: 'q1',
type: 'multiple-choice',
text: 'Select your favorite color',
required: true,
priority: 'medium',
tags: [],
options: [
{ id: 'red', label: 'Red' },
{ id: 'blue', label: 'Blue' },
{ id: 'green', label: 'Green' },
],
multiple: false,
allowAdditionalText: false,
};Multiple Choice with Additional Text Input
The additional text feature allows users to provide free-form text alongside their selections. The text input is always visible at the bottom of the question when enabled. You can configure whether the text input and option selections work together or are mutually exclusive.
Additional Mode (Default)
When additionalTextMode is set to 'additional' (or omitted, as it's the default), users can select options AND provide additional text - both work together.
const question: MultipleChoiceQuestion = {
id: 'q2',
type: 'multiple-choice',
text: 'What programming languages do you know?',
required: true,
priority: 'high',
tags: [],
options: [
{ id: 'js', label: 'JavaScript' },
{ id: 'py', label: 'Python' },
{ id: 'java', label: 'Java' },
],
multiple: true,
allowAdditionalText: true,
additionalTextMode: 'additional', // Users can select options AND type text
additionalTextLabel: 'Any other languages?',
additionalTextPlaceholder: 'e.g., Rust, Go, C++...',
};Exclusive Mode
When additionalTextMode is set to 'exclusive', the text input and option selections are mutually exclusive:
- Typing text in the input will clear all selected options and disable them
- Selecting an option will clear the text input and disable it
const question: MultipleChoiceQuestion = {
id: 'q3',
type: 'multiple-choice',
text: 'What is your PRIMARY development tool?',
required: true,
priority: 'high',
tags: [],
options: [
{ id: 'vscode', label: 'VS Code' },
{ id: 'intellij', label: 'IntelliJ IDEA' },
{ id: 'vim', label: 'Vim/Neovim' },
],
multiple: false,
allowAdditionalText: true,
additionalTextMode: 'exclusive', // Text and selections are mutually exclusive
additionalTextLabel: 'Or enter a different tool',
additionalTextPlaceholder: 'e.g., Sublime Text, Emacs...',
};Answer Format
When using the additional text feature, answers are returned in a structured format:
type MultipleChoiceAnswer = {
selectedChoices: string[]; // Array of selected option IDs
additionalText?: string; // The additional text provided
};
// Example answer with both selections and text (additional mode):
const answer: MultipleChoiceAnswer = {
selectedChoices: ['js', 'py'],
additionalText: 'Also learning Rust',
};
// Example answer with only text (exclusive mode):
const answer: MultipleChoiceAnswer = {
selectedChoices: [],
additionalText: 'Sublime Text',
};Note: All multiple choice questions now use the MultipleChoiceAnswer format, regardless of whether allowAdditionalText is enabled. When allowAdditionalText is false, the additionalText field will be an empty string.
Other Question Types
- Short Answer: Single-line text input with optional max length and pattern validation
- Long Form: Multi-line text input with markdown/rich text support
- True/False: Boolean questions with customizable labels and display styles
- Slider: Numeric range selection with optional dual handles
- Stack Ranking: Drag-and-drop ranking with optional tie support
- Numeric: Number input with min/max constraints and unit display
Validation
All question types support built-in validation rules:
const question: ShortAnswerQuestion = {
id: 'email',
type: 'short-answer',
text: 'Email address',
required: true,
priority: 'high',
tags: [],
validation: [
{
type: 'required',
message: 'Email is required',
},
{
type: 'pattern',
value: '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$',
message: 'Invalid email format',
},
],
};Supported validation types:
required: Field must have a valuemin: Minimum value (number) or length (string/array)max: Maximum value (number) or length (string/array)pattern: Regex pattern matchingcustom: Custom validator function
Conditional Logic
Show or hide questions based on answers to other questions:
const questions = [
{
id: 'employed',
type: 'true-false',
text: 'Are you employed?',
required: true,
priority: 'high',
tags: [],
},
{
id: 'company',
type: 'short-answer',
text: 'Company name',
required: false,
priority: 'medium',
tags: [],
conditions: [
{
questionId: 'employed',
operator: 'equals',
value: true,
action: 'show',
},
],
},
];Supported operators:
equals,not-equalscontains,not-containsgreater-than,less-than,greater-than-or-equal,less-than-or-equalin,not-inis-empty,is-not-empty
Supported actions:
show,hiderequire,disable,enable
Priority System
Questions can be assigned priority levels (low, medium, high, critical) with different display styles:
const question: Question = {
// ... other properties
priority: 'high',
priorityDisplayStyle: 'chip', // 'border-left' | 'border-all' | 'background' | 'chip' | 'dot' | 'none'
};Veto System
The veto system allows users to mark questions as problematic or not applicable. Vetoed questions:
- Display with reduced opacity and are disabled
- Show a message indicating they're vetoed
- Are still included in form submission with
vetoed: trueflag - Count as "complete" for progress tracking
- Preserve their answer value (can be un-vetoed without data loss)
Enabling Veto
Enable veto on individual questions:
const question: ShortAnswerQuestion = {
id: 'income',
type: 'short-answer',
text: 'What is your annual income?',
required: true,
priority: 'medium',
tags: [],
allowVeto: true, // Enable veto checkbox
vetoLabel: 'I prefer not to answer this', // Optional custom label
};Handling Vetoed Responses
Vetoed questions are included in responses with a flag:
// Filter out vetoed questions before submission (if desired)
const finalResponses = Object.entries(responses)
.filter(([_, response]) => !response.vetoed)
.reduce((acc, [id, response]) => ({ ...acc, [id]: response }), {});
// Or keep them to track which questions were problematic
const allResponses = responses; // Includes vetoed with { vetoed: true, vetoReason: "..." }Customizing Veto Button
You can completely customize the veto button by providing a renderVetoButton function that renders your own button:
import { QuestionRenderer } from '@lasercat/qna';
function MyForm() {
return (
<QuestionRenderer
question={question}
response={responses[question.id]}
onChange={handleChange}
renderVetoButton={(isVetoed, handleToggle) => (
<button
onClick={handleToggle}
className={
isVetoed
? 'px-3 py-1 text-sm bg-red-500 text-white rounded-md hover:bg-red-600'
: 'px-3 py-1 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600'
}
>
{isVetoed ? '✓ Vetoed' : 'Flag Issue'}
</button>
)}
/>
);
}This also works with QuestionGroup:
import { QuestionGroup } from '@lasercat/qna';
function MyForm() {
return (
<QuestionGroup
group={group}
responses={responses}
onChange={handleChange}
renderVetoButton={(isVetoed, handleToggle) => (
<span
onClick={handleToggle}
className="cursor-pointer text-xs underline"
>
{isVetoed ? 'Remove Flag' : 'Flag'}
</span>
)}
/>
);
}The function receives two parameters:
isVetoed(boolean):truewhen the question is vetoed,falseotherwisehandleToggle(function): Call this to toggle the veto state
Note: You have full control over the rendering - style it however you want, use any element (button, span, div, icon, etc.). If renderVetoButton is not provided, the default veto button is shown.
Question Groups
Organize related questions into groups with progress tracking:
import { QuestionGroup } from '@lasercat/qna';
import type { QuestionGroupType, QuestionResponse } from '@lasercat/qna';
const group: QuestionGroupType = {
id: 'personal-info',
name: 'Personal Information',
description: 'Tell us about yourself',
priority: 'high',
tags: ['onboarding'],
collapsible: true,
defaultExpanded: true,
questions: [
// ... your questions
],
};
function MyForm() {
const [responses, setResponses] = useState<Record<string, QuestionResponse>>({});
const handleChange = (response: QuestionResponse) => {
setResponses((prev) => ({
...prev,
[response.questionId]: response,
}));
};
return (
<QuestionGroup
group={group}
responses={responses}
onChange={handleChange}
onGroupComplete={(groupId) => console.log(`${groupId} completed!`)}
/>
);
}Custom Question Text Rendering
The renderQuestionText prop allows you to customize how question text is displayed. This is useful for adding inline badges, icons, or other custom content within the question text.
Basic Example
import { QuestionRenderer } from '@lasercat/qna';
import type { AnyQuestion, QuestionResponse } from '@lasercat/qna';
function MyForm() {
const [responses, setResponses] = useState<Record<string, QuestionResponse>>({});
const handleChange = (response: QuestionResponse) => {
setResponses((prev) => ({
...prev,
[response.questionId]: response,
}));
};
return (
<QuestionRenderer
question={question}
response={responses[question.id]}
onChange={handleChange}
renderQuestionText={(q) => (
<span>
{q.text}{' '}
<span className="ml-2 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700">NEW</span>
</span>
)}
/>
);
}Advanced Example with Conditional Badges
You can use the question object to conditionally render different content:
function MyForm() {
const renderQuestionText = (question: AnyQuestion) => {
return (
<span className="flex items-center gap-2">
<span>{question.text}</span>
{question.priority === 'critical' && (
<span className="px-2 py-0.5 text-xs rounded bg-red-100 text-red-700 font-semibold">
URGENT
</span>
)}
{question.tags.includes('beta') && (
<span className="px-2 py-0.5 text-xs rounded bg-purple-100 text-purple-700">BETA</span>
)}
</span>
);
};
return (
<QuestionRenderer
question={question}
response={responses[question.id]}
onChange={handleChange}
renderQuestionText={renderQuestionText}
/>
);
}With Question Groups
The renderQuestionText prop also works with QuestionGroup:
import { QuestionGroup } from '@lasercat/qna';
function MyForm() {
return (
<QuestionGroup
group={group}
responses={responses}
onChange={handleChange}
renderQuestionText={(q) => (
<span>
{q.text}
{q.required && <span className="ml-1 text-red-500">*</span>}
</span>
)}
/>
);
}Note: The renderQuestionText function receives the full question object, giving you access to all question properties including id, type, priority, tags, and type-specific properties.
Development
Running Tests
bun testType Checking
bun run typecheckLinting
bun run lintLicense
MIT
