feat: add keyboard usability to end user survey experience

feat: add keyboard usability to end user survey experience
This commit is contained in:
Johannes
2023-10-08 13:37:02 +05:30
committed by GitHub
11 changed files with 175 additions and 34 deletions

View File

@@ -28,9 +28,9 @@ export const RatingResponse: React.FC<RatingResponseProps> = ({ scale, range, an
const stars: any = [];
for (let i = 0; i < range; i++) {
if (i < parseInt(answer)) {
stars.push(<StarIcon className="h-7 text-yellow-400" />);
stars.push(<StarIcon key={i} className="h-7 text-yellow-400" />);
} else {
stars.push(<StarIcon className="h-7 text-gray-300" />);
stars.push(<StarIcon key={i} className="h-7 text-gray-300" />);
}
}
return <div className="flex">{stars}</div>;

View File

@@ -3,11 +3,13 @@ import { cn } from "../../../lib/cn";
interface BackButtonProps {
onClick: () => void;
backButtonLabel?: string;
tabIndex?: number;
}
export function BackButton({ onClick, backButtonLabel }: BackButtonProps) {
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
return (
<button
tabIndex={tabIndex}
type={"button"}
className={cn(
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"

View File

@@ -33,9 +33,10 @@ export default function CTAQuestion({
{!isFirstQuestion && (
<BackButton backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
)}
<div className="flex justify-end">
<div className="flex w-full justify-end">
{!question.required && (
<button
tabIndex={0}
type="button"
onClick={() => {
onSubmit({ [question.id]: "dismissed" });
@@ -48,6 +49,7 @@ export default function CTAQuestion({
question={question}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
focus={true}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();

View File

@@ -36,7 +36,14 @@ export default function ConsentQuestion({
e.preventDefault();
onSubmit({ [question.id]: value });
}}>
<label className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 bg-slate-50 p-4 text-sm text-slate-800 focus:outline-none">
<label
tabIndex={1}
onKeyDown={(e) => {
if (e.key == "Enter") {
onChange({ [question.id]: "accepted" });
}
}}
className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 p-4 text-sm text-slate-800 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2">
<input
type="checkbox"
id={question.id}
@@ -62,10 +69,11 @@ export default function ConsentQuestion({
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
<BackButton tabIndex={3} backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
)}
<div />
<SubmitButton
tabIndex={2}
brandColor={brandColor}
question={question}
isLastQuestion={isLastQuestion}

View File

@@ -3,6 +3,7 @@ export default function FormbricksSignature() {
<a
href="https://formbricks.com?utm_source=survey_branding"
target="_blank"
tabIndex={-1}
className="mb-5 mt-2 flex justify-center">
<p className="text-xs text-slate-400">
Powered by{" "}

View File

@@ -86,18 +86,29 @@ export default function MultipleChoiceSingleQuestion({
<fieldset>
<legend className="sr-only">Options</legend>
<div className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2">
{questionChoices.map((choice) => (
{questionChoices.map((choice, idx) => (
<label
key={choice.id}
tabIndex={idx + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
if (Array.isArray(value) && value.includes(choice.label)) {
removeItem(choice.label);
} else {
addItem(choice.label);
}
}
}}
className={cn(
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none "
)}>
<span className="flex items-center text-sm">
<input
type="checkbox"
id={choice.id}
name={question.id}
tabIndex={-1}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
@@ -110,9 +121,7 @@ export default function MultipleChoiceSingleQuestion({
}}
checked={Array.isArray(value) && value.includes(choice.label)}
style={{ borderColor: brandColor, color: brandColor }}
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
required={question.required && idx === 0}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
@@ -122,13 +131,20 @@ export default function MultipleChoiceSingleQuestion({
))}
{otherOption && (
<label
tabIndex={questionChoices.length + 1}
className={cn(
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
)}>
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
setOtherSelected(!otherSelected);
}
}}>
<span className="flex items-center text-sm">
<input
type="checkbox"
tabIndex={-1}
id={otherOption.id}
name={question.id}
value={otherOption.label}
@@ -155,12 +171,20 @@ export default function MultipleChoiceSingleQuestion({
ref={otherSpecify}
id={`${otherOption.id}-label`}
name={question.id}
tabIndex={questionChoices.length + 1}
value={otherValue}
onChange={(e) => {
setOtherValue(e.currentTarget.value);
removeItem(otherValue);
addItem(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter") {
setTimeout(() => {
onSubmit({ [question.id]: value });
}, 100);
}
}}
placeholder="Please specify"
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
required={question.required}
@@ -173,9 +197,16 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && <BackButton backButtonLabel={question.backButtonLabel} onClick={onBack} />}
{!isFirstQuestion && (
<BackButton
tabIndex={questionChoices.length + 3}
backButtonLabel={question.backButtonLabel}
onClick={onBack}
/>
)}
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
question={question}
isLastQuestion={isLastQuestion}
brandColor={brandColor}

View File

@@ -55,7 +55,6 @@ export default function MultipleChoiceSingleQuestion({
otherSpecify.current?.focus();
}
}, [otherSelected]);
return (
<form
onSubmit={(e) => {
@@ -68,16 +67,28 @@ export default function MultipleChoiceSingleQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2">
<div
className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2"
role="radiogroup">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}
tabIndex={idx + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
onChange({ [question.id]: choice.label });
setTimeout(() => {
onSubmit({ [question.id]: choice.label });
}, 350);
}
}}
className={cn(
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none "
)}>
<span className="flex items-center text-sm">
<input
tabIndex={-1}
type="radio"
id={choice.id}
name={question.id}
@@ -100,14 +111,22 @@ export default function MultipleChoiceSingleQuestion({
))}
{otherOption && (
<label
tabIndex={questionChoices.length + 1}
className={cn(
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
)}>
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
setOtherSelected(!otherSelected);
if (!otherSelected) onChange({ [question.id]: "" });
}
}}>
<span className="flex items-center text-sm">
<input
type="radio"
id={otherOption.id}
tabIndex={-1}
name={question.id}
value={otherOption.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
@@ -126,12 +145,20 @@ export default function MultipleChoiceSingleQuestion({
{otherSelected && (
<input
ref={otherSpecify}
tabIndex={questionChoices.length + 1}
id={`${otherOption.id}-label`}
name={question.id}
value={value}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}
onKeyDown={(e) => {
if (e.key == "Enter") {
setTimeout(() => {
onSubmit({ [question.id]: value });
}, 100);
}
}}
placeholder="Please specify"
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
required={question.required}
@@ -144,9 +171,16 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && <BackButton backButtonLabel={question.backButtonLabel} onClick={onBack} />}
{!isFirstQuestion && (
<BackButton
backButtonLabel={question.backButtonLabel}
tabIndex={questionChoices.length + 3}
onClick={onBack}
/>
)}
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
question={question}
isLastQuestion={isLastQuestion}
brandColor={brandColor}

View File

@@ -39,12 +39,18 @@ export default function NPSQuestion({
<fieldset>
<legend className="sr-only">Options</legend>
<div className="flex">
{Array.from({ length: 11 }, (_, i) => i).map((number) => (
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => (
<label
key={number}
tabIndex={idx + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
onSubmit({ [question.id]: number });
}
}}
className={cn(
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
"relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 text-slate-800 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:outline-none"
"relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 text-slate-800 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
)}>
<input
type="radio"
@@ -76,6 +82,7 @@ export default function NPSQuestion({
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton
tabIndex={isLastQuestion ? 12 : 13}
backButtonLabel={question.backButtonLabel}
onClick={() => {
onBack();
@@ -85,6 +92,7 @@ export default function NPSQuestion({
<div></div>
{!question.required && (
<SubmitButton
tabIndex={12}
question={question}
isLastQuestion={isLastQuestion}
brandColor={brandColor}

View File

@@ -4,6 +4,7 @@ import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useCallback } from "react";
interface OpenTextQuestionProps {
question: TSurveyOpenTextQuestion;
@@ -28,6 +29,12 @@ export default function OpenTextQuestion({
brandColor,
autoFocus = true,
}: OpenTextQuestionProps) {
const openTextRef = useCallback((currentElement: HTMLInputElement | HTMLTextAreaElement | null) => {
if (currentElement && autoFocus) {
currentElement.focus();
}
}, []);
return (
<form
onSubmit={(e) => {
@@ -40,6 +47,8 @@ export default function OpenTextQuestion({
<div className="mt-4">
{question.longAnswer === false ? (
<input
ref={openTextRef}
tabIndex={1}
name={question.id}
id={question.id}
placeholder={question.placeholder}
@@ -48,13 +57,17 @@ export default function OpenTextQuestion({
onInput={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}
autoFocus={autoFocus}
onKeyDown={(e) => {
if (e.key == "Enter") onSubmit({ [question.id]: value });
}}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm"
/>
) : (
<textarea
ref={openTextRef}
rows={3}
name={question.id}
tabIndex={1}
id={question.id}
placeholder={question.placeholder}
required={question.required}
@@ -62,8 +75,7 @@ export default function OpenTextQuestion({
onInput={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}
autoFocus={autoFocus}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"></textarea>
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm"></textarea>
)}
</div>
<div className="mt-4 flex w-full justify-between">

View File

@@ -81,24 +81,38 @@ export default function RatingQuestion({
key={number}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(0)}
className="max-w-10 relative flex max-h-10 flex-1 cursor-pointer justify-center bg-white text-center text-sm leading-10">
className="max-w-10 relative max-h-10 flex-1 cursor-pointer bg-white text-center text-sm leading-10">
{question.scale === "number" ? (
<label
tabIndex={i + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
handleSelect(number);
}
}}
className={cn(
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
a.length === number ? "rounded-r-md" : "",
number === 1 ? "rounded-l-md" : "",
"block h-full w-full border text-slate-800 hover:bg-gray-100 focus:outline-none"
"block h-full w-full border text-slate-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
)}>
<HiddenRadioInput number={number} />
{number}
</label>
) : question.scale === "star" ? (
<label
tabIndex={i + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
handleSelect(number);
}
}}
className={cn(
number <= hoveredNumber ? "text-yellow-500" : "",
"flex h-full w-full justify-center"
)}>
"flex h-full w-full justify-center focus:text-yellow-500 focus:outline-none"
)}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
<HiddenRadioInput number={number} />
{typeof value === "number" && value >= number ? (
<span className="text-yellow-300">
@@ -132,10 +146,15 @@ export default function RatingQuestion({
</label>
) : (
<label
className={cn(
"flex items-center justify-center text-slate-800",
question.range === 10 ? "h-6 w-6" : "h-full w-full"
)}>
tabIndex={i + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
handleSelect(number);
}
}}
className="flex h-full w-full justify-center text-slate-800 focus:outline-none"
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
<HiddenRadioInput number={number} />
<RatingSmiley
active={value === number || hoveredNumber === number}
@@ -157,6 +176,7 @@ export default function RatingQuestion({
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton
tabIndex={!question.required || value ? question.range + 2 : question.range + 1}
backButtonLabel={question.backButtonLabel}
onClick={() => {
onBack();
@@ -166,6 +186,7 @@ export default function RatingQuestion({
<div></div>
{(!question.required || value) && (
<SubmitButton
tabIndex={question.range + 1}
question={question}
isLastQuestion={isLastQuestion}
brandColor={brandColor}

View File

@@ -1,3 +1,4 @@
import { useCallback } from "preact/hooks";
import { cn } from "../../../lib/cn";
import { isLight } from "../lib/utils";
import { TSurveyQuestion } from "../../../types/v1/surveys";
@@ -7,13 +8,34 @@ interface SubmitButtonProps {
isLastQuestion: boolean;
brandColor: string;
onClick: () => void;
focus?: boolean;
tabIndex?: number;
type?: "submit" | "button";
}
function SubmitButton({ question, isLastQuestion, brandColor, onClick, type = "submit" }: SubmitButtonProps) {
function SubmitButton({
question,
isLastQuestion,
brandColor,
onClick,
tabIndex = 1,
focus = false,
type = "submit",
}: SubmitButtonProps) {
const buttonRef = useCallback((currentButton: HTMLButtonElement | null) => {
if (currentButton && focus) {
setTimeout(() => {
currentButton.focus();
}, 200);
}
}, []);
return (
<button
ref={buttonRef}
type={type}
tabIndex={tabIndex}
autoFocus={focus}
className={cn(
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2",
isLight(brandColor) ? "text-black" : "text-white"