'use client' import { useId, useState } from 'react' import { cn } from '../../lib/cn' export interface LikertScaleStatement { id: string /** The statement being rated, such as “Onboarding was quick”. */ label: string /** Must be answered before the survey is complete. */ required?: boolean } export interface LikertScaleOption { value: string /** Heading of the column, and the radio’s name. */ label: string } export interface LikertScaleProps { /** Rows: one radio group each. */ statements: LikertScaleStatement[] /** Columns, from one end of the scale to the other. Defaults to five-point agreement. */ options?: LikertScaleOption[] /** Controlled answers, keyed by statement id. */ value?: Record /** Starting answers when uncontrolled. */ defaultValue?: Record /** Called with every answer after one changes. */ onValueChange?: (value: Record) => void /** Names the whole matrix, such as the survey question. */ label: string /** Show the answered count and bar. */ showProgress?: boolean /** Mark unanswered required statements as errors — set it after a submit attempt. */ showErrors?: boolean /** Form field name prefix; each row submits as `${name}[statementId]`. */ name?: string /** Blocks interaction and dims the matrix. */ disabled?: boolean /** Merged last, so it wins. */ className?: string } const AGREEMENT: LikertScaleOption[] = [ { value: '1', label: 'Strongly disagree' }, { value: '2', label: 'Disagree' }, { value: '3', label: 'Neutral' }, { value: '4', label: 'Agree' }, { value: '5', label: 'Strongly agree' }, ] /** * A survey matrix: statements down the side, one agreement scale across the top. * * Each row is a radiogroup of native radios, named by its statement, and every * radio carries its own option label rather than relying on a column header — * a screen reader moving through a row with arrows hears “Agree, 4 of 5”, not * “radio button, 4 of 5”. The header row is visual only. * * A grid of five columns is unreadable on a phone, so the layout follows the * width of its container, not the viewport: below about 36rem each statement * becomes its own card with the options stacked and labelled in place. It is * one set of elements in both layouts, so answers and focus survive a resize. */ export function LikertScale({ statements, options = AGREEMENT, value, defaultValue = {}, onValueChange, label, showProgress = true, showErrors = false, name, disabled = false, className, }: LikertScaleProps) { const uid = useId() const [uncontrolled, setUncontrolled] = useState(defaultValue) const answers = value ?? uncontrolled const answered = statements.filter((statement) => answers[statement.id]).length const columns = { gridTemplateColumns: `minmax(10rem, 1.6fr) repeat(${options.length}, minmax(3.5rem, 1fr))` } const choose = (statementId: string, option: string) => { const next = { ...answers, [statementId]: option } if (value === undefined) setUncontrolled(next) onValueChange?.(next) } return (
{label} {showProgress && ( )}
{statements.map((statement, row) => { const missing = showErrors && statement.required && !answers[statement.id] return (
{statement.label} {statement.required && ( )} {missing && ( Choose an answer )} {options.map((option) => { const checked = answers[statement.id] === option.value return ( ) })}
) })}
) }