Terminado sprint 2A
This commit is contained in:
26
components/progress-bar.tsx
Normal file
26
components/progress-bar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
interface ProgressBarProps {
|
||||
answered: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function ProgressBar({ answered, total }: ProgressBarProps) {
|
||||
const pct = total === 0 ? 0 : Math.round((answered / total) * 100)
|
||||
return (
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
role="progressbar"
|
||||
aria-label={`Progreso de la encuesta: ${pct}%`}
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<span className="progress-bar-label" aria-hidden="true">
|
||||
{answered}/{total} respondidas
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
components/question-renderer.tsx
Normal file
89
components/question-renderer.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
|
||||
import { RadioGroup } from './widgets/radio-group'
|
||||
import { CheckboxGroup } from './widgets/checkbox-group'
|
||||
import { RatingScale } from './widgets/rating-scale'
|
||||
import { TextAreaField } from './widgets/text-area-field'
|
||||
|
||||
interface QuestionRendererProps {
|
||||
question: Question
|
||||
answers: FormAnswers
|
||||
onChange: (code: string, value: FormAnswers[string]) => void
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
|
||||
return Array.isArray(opts)
|
||||
}
|
||||
|
||||
function isRatingOptions(opts: unknown): opts is RatingOptions {
|
||||
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
|
||||
}
|
||||
|
||||
export function QuestionRenderer({
|
||||
question,
|
||||
answers,
|
||||
onChange,
|
||||
errors,
|
||||
}: QuestionRendererProps) {
|
||||
const { code, type, prompt, required, max_select, options } = question
|
||||
const error = errors[code]
|
||||
|
||||
if (type === 'single_choice' && isArrayOptions(options)) {
|
||||
return (
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={prompt}
|
||||
options={options}
|
||||
value={answers[code] as string | undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'multiple_choice' && isArrayOptions(options)) {
|
||||
return (
|
||||
<CheckboxGroup
|
||||
name={code}
|
||||
legend={prompt}
|
||||
options={options}
|
||||
value={(answers[code] as string[]) ?? []}
|
||||
maxSelect={max_select}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'rating' && isRatingOptions(options)) {
|
||||
return (
|
||||
<RatingScale
|
||||
name={code}
|
||||
legend={prompt}
|
||||
ratingOptions={options}
|
||||
value={answers[code] as number | undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'text_long' || type === 'text_short') {
|
||||
return (
|
||||
<TextAreaField
|
||||
name={code}
|
||||
label={prompt}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
110
components/summary-screen.tsx
Normal file
110
components/summary-screen.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
|
||||
import { isQuestionVisible } from '@/lib/conditional'
|
||||
|
||||
interface SummaryScreenProps {
|
||||
surveyTitle: string
|
||||
questions: Question[]
|
||||
answers: FormAnswers
|
||||
onStartOver: () => void
|
||||
}
|
||||
|
||||
function formatAnswer(question: Question, raw: FormAnswers[string]): string {
|
||||
if (raw === null || raw === undefined || raw === '') return '—'
|
||||
|
||||
const { type, options } = question
|
||||
|
||||
if ((type === 'single_choice') && Array.isArray(options)) {
|
||||
const opt = (options as QuestionOption[]).find((o) => o.value === raw)
|
||||
return opt?.label ?? String(raw)
|
||||
}
|
||||
|
||||
if (type === 'multiple_choice' && Array.isArray(raw) && Array.isArray(options)) {
|
||||
const opts = options as QuestionOption[]
|
||||
const labels = (raw as string[]).map(
|
||||
(v) => opts.find((o) => o.value === v)?.label ?? v
|
||||
)
|
||||
return labels.length > 0 ? labels.join(', ') : '—'
|
||||
}
|
||||
|
||||
if (type === 'rating' && options && !Array.isArray(options)) {
|
||||
const ro = options as RatingOptions
|
||||
return `${raw} / ${ro.max} (${ro.min_label} → ${ro.max_label})`
|
||||
}
|
||||
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
// Section titles by first question code prefix
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
A: 'Datos Generales',
|
||||
B: '¿Vivís con discapacidad?',
|
||||
C: 'Familiares con Discapacidad',
|
||||
D: 'Adultos Mayores a Cargo',
|
||||
E: 'Tu Rol como Cuidador/a',
|
||||
F: 'Impacto Económico',
|
||||
G: 'Bienestar y Descanso',
|
||||
H: 'Necesidades',
|
||||
}
|
||||
|
||||
export function SummaryScreen({
|
||||
surveyTitle,
|
||||
questions,
|
||||
answers,
|
||||
onStartOver,
|
||||
}: SummaryScreenProps) {
|
||||
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
|
||||
|
||||
let lastSection = ''
|
||||
|
||||
return (
|
||||
<div className="summary-container" role="main">
|
||||
<div className="summary-header">
|
||||
<h1 className="summary-title">Resumen de tus respuestas</h1>
|
||||
<p className="summary-subtitle">
|
||||
{surveyTitle} — revisá lo que respondiste antes de continuar al envío.
|
||||
</p>
|
||||
<p className="summary-note" role="note">
|
||||
<strong>Nota:</strong> en esta etapa no se envía nada a la base de datos.
|
||||
El envío definitivo (con consentimiento) será en el paso siguiente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="summary-list">
|
||||
{visibleQuestions.map((q) => {
|
||||
const section = q.code.replace(/\d+$/, '')
|
||||
const showHeader = section !== lastSection
|
||||
lastSection = section
|
||||
|
||||
return (
|
||||
<div key={q.id}>
|
||||
{showHeader && (
|
||||
<h2 className="summary-section-heading">
|
||||
{SECTION_TITLES[section] ?? section}
|
||||
</h2>
|
||||
)}
|
||||
<div className="summary-item">
|
||||
<dt className="summary-question">
|
||||
<span className="summary-code">{q.code}</span> {q.prompt}
|
||||
</dt>
|
||||
<dd className="summary-answer">
|
||||
{formatAnswer(q, answers[q.code] ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
|
||||
<div className="summary-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartOver}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Volver a la encuesta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
components/survey-form.tsx
Normal file
249
components/survey-form.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FormAnswers, Question, Survey } from '@/lib/types'
|
||||
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
|
||||
import { getHiddenCodes, isQuestionVisible } from '@/lib/conditional'
|
||||
import { ProgressBar } from './progress-bar'
|
||||
import { QuestionRenderer } from './question-renderer'
|
||||
import { SummaryScreen } from './summary-screen'
|
||||
|
||||
// Section metadata (code prefix → display title)
|
||||
const SECTIONS: { prefix: string; title: string; sensitive?: boolean }[] = [
|
||||
{ prefix: 'A', title: 'Datos Generales' },
|
||||
{ prefix: 'B', title: '¿Vivís con discapacidad?', sensitive: true },
|
||||
{ prefix: 'C', title: 'Familiares con Discapacidad', sensitive: true },
|
||||
{ prefix: 'D', title: 'Adultos Mayores a Cargo', sensitive: true },
|
||||
{ prefix: 'E', title: 'Tu Rol como Cuidador/a' },
|
||||
{ prefix: 'F', title: 'Impacto Económico' },
|
||||
{ prefix: 'G', title: 'Bienestar y Descanso' },
|
||||
{ prefix: 'H', title: 'Necesidades' },
|
||||
]
|
||||
|
||||
function getSectionPrefix(code: string) {
|
||||
return code.replace(/\d+$/, '')
|
||||
}
|
||||
|
||||
interface SurveyFormProps {
|
||||
survey: Survey
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export function SurveyForm({ survey, questions }: SurveyFormProps) {
|
||||
const [answers, setAnswers] = useState<FormAnswers>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
// Announce when conditional blocks appear/disappear
|
||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||
|
||||
// Track which trigger codes had "Sí" on prev render to detect toggle
|
||||
const prevVisibility = useRef<Record<string, boolean>>({})
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const draft = loadDraft(survey.id)
|
||||
if (Object.keys(draft).length > 0) setAnswers(draft)
|
||||
setHydrated(true)
|
||||
}, [survey.id])
|
||||
|
||||
// Persist to localStorage on every change (after hydration)
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
saveDraft(survey.id, answers)
|
||||
}, [answers, hydrated, survey.id])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(code: string, value: FormAnswers[string]) => {
|
||||
setAnswers((prev) => {
|
||||
const updated = { ...prev, [code]: value }
|
||||
|
||||
// Clear any dependent questions that become hidden
|
||||
const toHide = getHiddenCodes(code, value, questions, prev)
|
||||
for (const hiddenCode of toHide) {
|
||||
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
|
||||
}
|
||||
|
||||
// Announce block visibility change to screen readers
|
||||
if (toHide.length > 0) {
|
||||
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
|
||||
} else {
|
||||
// Check if a conditional block just became visible
|
||||
const appeared = questions.some(
|
||||
(q) =>
|
||||
q.conditional_rules?.some((r) => r.if_question === code) &&
|
||||
isQuestionVisible(q, updated) &&
|
||||
!isQuestionVisible(q, prev)
|
||||
)
|
||||
if (appeared) {
|
||||
setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
// Clear validation error for this field
|
||||
setErrors((prev) => {
|
||||
if (!prev[code]) return prev
|
||||
const { [code]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
},
|
||||
[questions]
|
||||
)
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: Record<string, string> = {}
|
||||
for (const q of questions) {
|
||||
if (!q.required) continue
|
||||
if (!isQuestionVisible(q, answers)) continue
|
||||
const val = answers[q.code]
|
||||
if (
|
||||
val === null ||
|
||||
val === undefined ||
|
||||
val === '' ||
|
||||
(Array.isArray(val) && val.length === 0)
|
||||
) {
|
||||
newErrors[q.code] = 'Este campo es obligatorio.'
|
||||
}
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!validate()) {
|
||||
// Move focus to first error
|
||||
const firstErrorCode = Object.keys(errors)[0]
|
||||
if (firstErrorCode) {
|
||||
document.getElementById(firstErrorCode)?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 2A: no write to DB — just show summary
|
||||
clearDraft(survey.id)
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
function handleStartOver() {
|
||||
setAnswers({})
|
||||
setSubmitted(false)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
// Visible questions driven by conditional_rules
|
||||
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
|
||||
const answeredCount = visibleQuestions.filter((q) => {
|
||||
const v = answers[q.code]
|
||||
return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
|
||||
}).length
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<SummaryScreen
|
||||
surveyTitle={survey.title}
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onStartOver={handleStartOver}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Group visible questions by section prefix, in order
|
||||
type Section = { prefix: string; title: string; sensitive?: boolean; questions: Question[] }
|
||||
const sectionMap = new Map<string, Section>()
|
||||
for (const q of visibleQuestions) {
|
||||
const prefix = getSectionPrefix(q.code)
|
||||
if (!sectionMap.has(prefix)) {
|
||||
const meta = SECTIONS.find((s) => s.prefix === prefix)
|
||||
sectionMap.set(prefix, {
|
||||
prefix,
|
||||
title: meta?.title ?? prefix,
|
||||
sensitive: meta?.sensitive,
|
||||
questions: [],
|
||||
})
|
||||
}
|
||||
sectionMap.get(prefix)!.questions.push(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
{/* Skip link for keyboard users */}
|
||||
<a href="#survey-form" className="skip-link">
|
||||
Ir al formulario
|
||||
</a>
|
||||
|
||||
{/* Screen-reader live region for conditional announcements */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
role="status"
|
||||
>
|
||||
{a11yAnnounce}
|
||||
</div>
|
||||
|
||||
<header className="survey-header">
|
||||
<h1 className="survey-title">{survey.title}</h1>
|
||||
<p className="survey-anon-note">
|
||||
Esta encuesta es anónima. Tus respuestas no te identifican.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="survey-progress-sticky" aria-label="Barra de progreso">
|
||||
<ProgressBar answered={answeredCount} total={visibleQuestions.length} />
|
||||
</div>
|
||||
|
||||
<main id="survey-form">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
aria-label={`Encuesta: ${survey.title}`}
|
||||
>
|
||||
{Array.from(sectionMap.values()).map((section) => (
|
||||
<section
|
||||
key={section.prefix}
|
||||
className="survey-section"
|
||||
aria-labelledby={`section-${section.prefix}`}
|
||||
>
|
||||
<div className="section-header">
|
||||
<h2 id={`section-${section.prefix}`} className="section-title">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.sensitive && (
|
||||
<p className="section-sensitive-note" role="note">
|
||||
Esta sección contiene preguntas sensibles sobre salud o discapacidad.
|
||||
Responder es completamente voluntario.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{section.questions.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="question-wrapper"
|
||||
data-code={q.code}
|
||||
>
|
||||
<QuestionRenderer
|
||||
question={q}
|
||||
answers={answers}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Ver resumen de respuestas
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
components/widgets/checkbox-group.tsx
Normal file
85
components/widgets/checkbox-group.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
import type { QuestionOption } from '@/lib/types'
|
||||
|
||||
interface CheckboxGroupProps {
|
||||
name: string
|
||||
legend: string
|
||||
options: QuestionOption[]
|
||||
value: string[]
|
||||
maxSelect: number | null
|
||||
required: boolean
|
||||
onChange: (value: string[]) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function CheckboxGroup({
|
||||
name,
|
||||
legend,
|
||||
options,
|
||||
value,
|
||||
maxSelect,
|
||||
required,
|
||||
onChange,
|
||||
error,
|
||||
}: CheckboxGroupProps) {
|
||||
const atLimit = maxSelect !== null && value.length >= maxSelect
|
||||
|
||||
function toggle(optValue: string) {
|
||||
if (value.includes(optValue)) {
|
||||
onChange(value.filter((v) => v !== optValue))
|
||||
} else if (!atLimit) {
|
||||
onChange([...value, optValue])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="question-fieldset">
|
||||
<legend className="question-legend">
|
||||
{legend}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</legend>
|
||||
{maxSelect !== null && (
|
||||
<p className="max-select-hint" aria-live="polite">
|
||||
Elegí hasta {maxSelect} opciones
|
||||
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
|
||||
</p>
|
||||
)}
|
||||
<div className="options-list" role="list">
|
||||
{options.map((opt) => {
|
||||
const id = `${name}_${opt.value.replace(/\s+/g, '_')}`
|
||||
const checked = value.includes(opt.value)
|
||||
const disabled = atLimit && !checked
|
||||
|
||||
return (
|
||||
<div key={opt.value} className="option-item" role="listitem">
|
||||
<label
|
||||
className={`option-label${disabled ? ' option-disabled' : ''}`}
|
||||
htmlFor={id}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
name={name}
|
||||
value={opt.value}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggle(opt.value)}
|
||||
aria-required={required}
|
||||
aria-disabled={disabled}
|
||||
className="option-input"
|
||||
/>
|
||||
<span className="option-text">{opt.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
59
components/widgets/radio-group.tsx
Normal file
59
components/widgets/radio-group.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
import type { QuestionOption } from '@/lib/types'
|
||||
|
||||
interface RadioGroupProps {
|
||||
name: string
|
||||
legend: string
|
||||
options: QuestionOption[]
|
||||
value: string | undefined
|
||||
required: boolean
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function RadioGroup({
|
||||
name,
|
||||
legend,
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
onChange,
|
||||
error,
|
||||
}: RadioGroupProps) {
|
||||
return (
|
||||
<fieldset className="question-fieldset">
|
||||
<legend className="question-legend">
|
||||
{legend}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</legend>
|
||||
<div className="options-list" role="list">
|
||||
{options.map((opt) => {
|
||||
const id = `${name}_${opt.value.replace(/\s+/g, '_')}`
|
||||
return (
|
||||
<div key={opt.value} className="option-item" role="listitem">
|
||||
<label className="option-label" htmlFor={id}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
name={name}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
aria-required={required}
|
||||
className="option-input"
|
||||
/>
|
||||
<span className="option-text">{opt.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
71
components/widgets/rating-scale.tsx
Normal file
71
components/widgets/rating-scale.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import type { RatingOptions } from '@/lib/types'
|
||||
|
||||
interface RatingScaleProps {
|
||||
name: string
|
||||
legend: string
|
||||
ratingOptions: RatingOptions
|
||||
value: number | undefined
|
||||
required: boolean
|
||||
onChange: (value: number) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function RatingScale({
|
||||
name,
|
||||
legend,
|
||||
ratingOptions,
|
||||
value,
|
||||
required,
|
||||
onChange,
|
||||
error,
|
||||
}: RatingScaleProps) {
|
||||
const { min, max, min_label, max_label } = ratingOptions
|
||||
const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
||||
|
||||
return (
|
||||
<fieldset className="question-fieldset">
|
||||
<legend className="question-legend">
|
||||
{legend}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</legend>
|
||||
<div className="rating-wrapper" role="group" aria-label={`Escala ${min} a ${max}`}>
|
||||
<span className="rating-label rating-label-min" aria-hidden="true">
|
||||
{min_label}
|
||||
</span>
|
||||
<div className="rating-steps">
|
||||
{steps.map((step) => {
|
||||
const id = `${name}_${step}`
|
||||
return (
|
||||
<label key={step} className="rating-step" htmlFor={id}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
name={name}
|
||||
value={step}
|
||||
checked={value === step}
|
||||
onChange={() => onChange(step)}
|
||||
aria-required={required}
|
||||
aria-label={`${step} — ${step === min ? min_label : step === max ? max_label : ''}`}
|
||||
className="rating-input sr-only"
|
||||
/>
|
||||
<span className="rating-dot" aria-hidden="true">
|
||||
{step}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="rating-label rating-label-max" aria-hidden="true">
|
||||
{max_label}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
47
components/widgets/text-area-field.tsx
Normal file
47
components/widgets/text-area-field.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
interface TextAreaFieldProps {
|
||||
name: string
|
||||
label: string
|
||||
value: string
|
||||
required: boolean
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function TextAreaField({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
required,
|
||||
onChange,
|
||||
error,
|
||||
}: TextAreaFieldProps) {
|
||||
return (
|
||||
<div className="question-fieldset">
|
||||
<label className="question-legend" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
rows={4}
|
||||
required={required}
|
||||
aria-required={required}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
aria-invalid={!!error}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-area"
|
||||
placeholder="Escribí tu respuesta acá (opcional)…"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user