mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-20 05:21:44 +10:00
feat(agent): let the assistant ask clarifying questions
Adds the questionnaire and empty-state primitives and renders the ask_user_question tool call inline in the chat, so the agent can offer choices instead of guessing when a request is ambiguous.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AssistantMarkdown } from "./agent-chat";
|
||||
import type { UIMessage } from "ai";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { AskUserQuestion, AssistantMarkdown } from "./agent-chat";
|
||||
|
||||
describe("AssistantMarkdown", () => {
|
||||
it("renders GitHub-style pipe tables as tables", () => {
|
||||
@@ -23,3 +26,53 @@ describe("AssistantMarkdown", () => {
|
||||
expect(screen.getByText("After")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const questionPart = {
|
||||
type: "tool-ask_user_question",
|
||||
toolCallId: "call-1",
|
||||
state: "input-available",
|
||||
input: { question: "Which role are you targeting?", choices: ["Engineering manager", "Staff engineer"] },
|
||||
} as unknown as UIMessage["parts"][number];
|
||||
|
||||
const renderQuestion = (answer: string | null, onAnswer: (toolCallId: string, value: string) => void) =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AskUserQuestion part={questionPart} answer={answer} onAnswer={onAnswer} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("AskUserQuestion", () => {
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
it("submits the selected choice as the tool output", () => {
|
||||
const onAnswer = vi.fn();
|
||||
renderQuestion(null, onAnswer);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Staff engineer" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send answer" }));
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith("call-1", "Staff engineer");
|
||||
});
|
||||
|
||||
it("submits a freeform answer when no choice fits", () => {
|
||||
const onAnswer = vi.fn();
|
||||
renderQuestion(null, onAnswer);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Answer in your own words" }), {
|
||||
target: { value: "Product designer" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send answer" }));
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith("call-1", "Product designer");
|
||||
});
|
||||
|
||||
it("renders the recorded answer once the question is answered", () => {
|
||||
const onAnswer = vi.fn();
|
||||
renderQuestion("Staff engineer", onAnswer);
|
||||
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Staff engineer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ import { m } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Empty, EmptyContent, EmptyHeader, EmptyMedia, EmptyTitle } from "@reactive-resume/ui/components/empty";
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
|
||||
import { Message, MessageContent } from "@reactive-resume/ui/components/message";
|
||||
import {
|
||||
@@ -55,7 +55,19 @@ import {
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@reactive-resume/ui/components/message-scroller";
|
||||
import {
|
||||
Questionnaire,
|
||||
QuestionnaireActions,
|
||||
QuestionnaireChoice,
|
||||
QuestionnaireChoices,
|
||||
QuestionnaireError,
|
||||
QuestionnaireInput,
|
||||
QuestionnaireItem,
|
||||
QuestionnaireSubmit,
|
||||
QuestionnaireTitle,
|
||||
} from "@reactive-resume/ui/components/questionnaire";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
@@ -88,6 +100,12 @@ type FileAttachmentProps = {
|
||||
state?: "idle" | "uploading" | "processing" | "error" | "done";
|
||||
};
|
||||
|
||||
type AskUserQuestionProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
answer: string | null;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
};
|
||||
|
||||
type MessagePartProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
isUser: boolean;
|
||||
@@ -162,6 +180,8 @@ type AgentChatComposerProps = {
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
const ANSWER_FIELD = "answer";
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -433,6 +453,60 @@ function FileAttachment({ filename, mediaType, state = "done" }: FileAttachmentP
|
||||
);
|
||||
}
|
||||
|
||||
// The agent asks one question at a time, so this is a single-item Questionnaire: radio choices plus a
|
||||
// freeform answer, submitted as the tool output. `Questionnaire` owns the fieldset/legend semantics,
|
||||
// keyboard shortcuts, focus management, and required-answer validation.
|
||||
export function AskUserQuestion({ part, answer, onAnswer }: AskUserQuestionProps) {
|
||||
const input =
|
||||
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
|
||||
const choices = Array.isArray(input.choices)
|
||||
? input.choices.filter((choice): choice is string => typeof choice === "string")
|
||||
: [];
|
||||
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
|
||||
const toolCallId = "toolCallId" in part && typeof part.toolCallId === "string" ? part.toolCallId : null;
|
||||
|
||||
if (answer !== null || !toolCallId) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{question}</p>
|
||||
<p className="text-muted-foreground text-sm">{answer ?? <Trans>Waiting for the agent…</Trans>}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const value = String(new FormData(event.currentTarget).get(ANSWER_FIELD) ?? "").trim();
|
||||
if (value) onAnswer(toolCallId, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Questionnaire
|
||||
shortcuts="numbers"
|
||||
items={[{ name: ANSWER_FIELD, required: true, choices: choices.map((choice) => ({ value: choice })) }]}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<QuestionnaireItem required name={ANSWER_FIELD}>
|
||||
<QuestionnaireTitle>{question}</QuestionnaireTitle>
|
||||
<QuestionnaireChoices>
|
||||
{choices.map((choice) => (
|
||||
<QuestionnaireChoice key={choice} value={choice}>
|
||||
{choice}
|
||||
</QuestionnaireChoice>
|
||||
))}
|
||||
<QuestionnaireInput aria-label={t`Answer in your own words`} placeholder={t`Something else…`} />
|
||||
</QuestionnaireChoices>
|
||||
<QuestionnaireError />
|
||||
</QuestionnaireItem>
|
||||
<QuestionnaireActions>
|
||||
<QuestionnaireSubmit size="sm">
|
||||
<Trans>Send answer</Trans>
|
||||
</QuestionnaireSubmit>
|
||||
</QuestionnaireActions>
|
||||
</Questionnaire>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsById }: MessagePartProps) {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
@@ -464,26 +538,12 @@ function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsByI
|
||||
}
|
||||
|
||||
if (part.type === "tool-ask_user_question") {
|
||||
const input =
|
||||
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
|
||||
const choices = Array.isArray(input.choices)
|
||||
? input.choices.filter((choice): choice is string => typeof choice === "string")
|
||||
: [];
|
||||
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
|
||||
const answer = "output" in part && typeof part.output === "string" ? part.output : null;
|
||||
|
||||
return (
|
||||
<Bubble variant="outline" className="max-w-full">
|
||||
<BubbleContent className="w-full">
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">{question}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{choices.map((choice) => (
|
||||
<Button key={choice} size="sm" variant="outline" onClick={() => onAnswer(part.toolCallId, choice)}>
|
||||
{choice}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AskUserQuestion part={part} answer={answer} onAnswer={onAnswer} />
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
);
|
||||
@@ -602,11 +662,14 @@ export function AgentChat({
|
||||
{ id: threadId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Thread archived.`);
|
||||
toast.add({ type: "success", description: t`Thread archived.` });
|
||||
await refreshThread();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` }));
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -623,13 +686,16 @@ export function AgentChat({
|
||||
{ id: threadId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Thread deleted.`);
|
||||
toast.add({ type: "success", description: t`Thread deleted.` });
|
||||
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
|
||||
if (onClose) onClose();
|
||||
else void navigate({ to: "/agent" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` }));
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -730,9 +796,12 @@ export function AgentChat({
|
||||
);
|
||||
|
||||
setPendingAttachments((current) => [...current, ...attachments]);
|
||||
toast.success(t`Attachment uploaded.`);
|
||||
toast.add({ type: "success", description: t`Attachment uploaded.` });
|
||||
} catch (error) {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }));
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }),
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
@@ -763,12 +832,12 @@ export function AgentChat({
|
||||
2,
|
||||
),
|
||||
);
|
||||
toast.success(t`Conversation JSON copied.`);
|
||||
toast.add({ type: "success", description: t`Conversation JSON copied.` });
|
||||
};
|
||||
|
||||
const copyConversationText = () => {
|
||||
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
|
||||
toast.success(t`Conversation copied.`);
|
||||
toast.add({ type: "success", description: t`Conversation copied.` });
|
||||
};
|
||||
|
||||
const answerToolCall = (toolCallId: string, answer: string) => {
|
||||
@@ -786,13 +855,21 @@ export function AgentChat({
|
||||
{
|
||||
onSuccess: (action) => {
|
||||
if (action.status === "conflicted") {
|
||||
toast.error(action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`);
|
||||
toast.add({
|
||||
type: "error",
|
||||
description:
|
||||
action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`,
|
||||
});
|
||||
} else if (action.status === "rolled_back" || action.status === "reverted") {
|
||||
toast.success(t`Patch rolled back.`);
|
||||
toast.add({ type: "success", description: t`Patch rolled back.` });
|
||||
}
|
||||
void refreshThread();
|
||||
},
|
||||
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
|
||||
onError: (error) =>
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -880,13 +957,19 @@ function AgentChatMessages({
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="grid gap-6 py-12 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-2xl">
|
||||
<Trans>What do you want to do?</Trans>
|
||||
</h2>
|
||||
<StarterPromptMarquee onSelect={onStarterSelect} />
|
||||
</div>
|
||||
<Empty className="py-12">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparkleIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-2xl">
|
||||
<Trans>What do you want to do?</Trans>
|
||||
</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="max-w-full">
|
||||
<StarterPromptMarquee onSelect={onStarterSelect} />
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : null}
|
||||
|
||||
{messages.map((message) => (
|
||||
|
||||
@@ -5,10 +5,18 @@ import { GearSixIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@reactive-resume/ui/components/empty";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -24,23 +32,30 @@ type AiAssistantThreadProps = {
|
||||
};
|
||||
|
||||
function CenteredState({ children }: { children: ReactNode }) {
|
||||
return <div className="grid h-full place-items-center p-6 text-center text-muted-foreground text-sm">{children}</div>;
|
||||
return <Empty className="h-full text-muted-foreground text-sm">{children}</Empty>;
|
||||
}
|
||||
|
||||
function NoProviderHint() {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-6">
|
||||
<div className="max-w-xs space-y-4 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparkleIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
<Trans>No AI provider connected</Trans>
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
<Trans>Set up an AI provider to chat about this resume and apply edits automatically.</Trans>
|
||||
</p>
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button nativeButton={false} render={<Link to="/dashboard/settings/integrations" />}>
|
||||
<GearSixIcon />
|
||||
<Trans>Set up an AI provider</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +106,10 @@ function AiAssistantPanel({ resumeId, onClose }: BuilderAiAssistantProps & { onC
|
||||
onSuccess: (thread) => setThreadId(thread.id),
|
||||
onError: (mutationError) => {
|
||||
setSetupFailed(true);
|
||||
toast.error(getOrpcErrorMessage(mutationError, { fallback: t`Failed to start the AI assistant.` }));
|
||||
toast.add({
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(mutationError, { fallback: t`Failed to start the AI assistant.` }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 text-balance rounded-xl border-dashed p-6 text-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="empty-header" className={cn("flex max-w-sm flex-col items-center gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("cn-font-heading font-medium text-sm tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn("flex w-full min-w-0 max-w-sm flex-col items-center gap-2.5 text-balance text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
|
||||
@@ -0,0 +1,282 @@
|
||||
import type * as React from "react";
|
||||
import type { Button } from "./button";
|
||||
import { CheckIcon } from "@phosphor-icons/react";
|
||||
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function Questionnaire({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Root>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Root
|
||||
data-slot="questionnaire"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireProgress({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Progress
|
||||
data-slot="questionnaire-progress"
|
||||
className={cn("min-h-[1lh] w-fit min-w-[14ch] font-medium text-muted-foreground text-xs tabular-nums", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireItem({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Item
|
||||
data-slot="questionnaire-item"
|
||||
className={cn("flex min-w-0 flex-col gap-4 border-0 p-0 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireTitle({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Title
|
||||
data-slot="questionnaire-title"
|
||||
className={cn(
|
||||
"cn-font-heading text-pretty font-medium text-base leading-snug [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Description
|
||||
data-slot="questionnaire-description"
|
||||
className={cn("text-pretty text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireChoices({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Choices
|
||||
data-slot="questionnaire-choices"
|
||||
className={cn("group/questionnaire-choices grid min-w-0 gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireChoice({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Choice
|
||||
data-slot="questionnaire-choice"
|
||||
className={cn(
|
||||
"group/questionnaire-choice relative flex min-h-11 cursor-pointer select-none items-start gap-2.5 rounded-lg border border-input bg-transparent px-3 py-2.5 text-start text-sm outline-none transition-colors hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50 data-checked:border-primary/40 data-invalid:border-destructive data-checked:bg-muted dark:bg-input/20 dark:data-checked:bg-muted",
|
||||
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<QuestionnairePrimitive.ChoiceInput
|
||||
data-slot="questionnaire-choice-input"
|
||||
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="questionnaire-choice-indicator"
|
||||
className="pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border border-input group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary"
|
||||
>
|
||||
<span
|
||||
data-slot="questionnaire-choice-indicator-dot"
|
||||
className="hidden size-2 rounded-full bg-primary-foreground group-data-checked/questionnaire-choice:block group-data-[type=checkbox]/questionnaire-choice:hidden"
|
||||
/>
|
||||
<CheckIcon
|
||||
data-slot="questionnaire-choice-indicator-check"
|
||||
className="hidden size-3.5 group-data-checked/questionnaire-choice:block group-data-[type=radio]/questionnaire-choice:hidden"
|
||||
/>
|
||||
</span>
|
||||
<QuestionnairePrimitive.ChoiceLabel
|
||||
data-slot="questionnaire-choice-label"
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
|
||||
>
|
||||
{children}
|
||||
</QuestionnairePrimitive.ChoiceLabel>
|
||||
<QuestionnairePrimitive.ChoiceShortcut
|
||||
data-slot="questionnaire-choice-shortcut"
|
||||
className="pointer-events-none ms-auto hidden size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border border-input bg-background font-medium font-mono text-[0.625rem] text-muted-foreground leading-none group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[shortcut]/questionnaire-choice:inline-flex"
|
||||
/>
|
||||
</QuestionnairePrimitive.Choice>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireChoiceDescription({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span data-slot="questionnaire-choice-description" className={cn("text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireInput({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="questionnaire-input-wrapper" className="group/questionnaire-input relative w-full min-w-0">
|
||||
<QuestionnairePrimitive.Input
|
||||
data-slot="questionnaire-input"
|
||||
className={cn(
|
||||
"h-8 min-h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-[color,box-shadow,background-color] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 sm:min-h-0 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:disabled:bg-input/80",
|
||||
"selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireError({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Error
|
||||
data-slot="questionnaire-error"
|
||||
className={cn("mt-2 text-destructive text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="questionnaire-actions"
|
||||
className={cn(
|
||||
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 sm:min-h-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnairePrevious({
|
||||
children,
|
||||
className,
|
||||
size = "default",
|
||||
variant = "outline",
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> &
|
||||
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Previous
|
||||
data-slot="questionnaire-previous"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
buttonVariants({ size, variant }),
|
||||
"col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "Previous"}
|
||||
</QuestionnairePrimitive.Previous>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireSkip({
|
||||
children,
|
||||
className,
|
||||
size = "default",
|
||||
variant = "outline",
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> &
|
||||
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Skip
|
||||
data-slot="questionnaire-skip"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
buttonVariants({ size, variant }),
|
||||
"col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "Skip"}
|
||||
</QuestionnairePrimitive.Skip>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireNext({
|
||||
children,
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> &
|
||||
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Next
|
||||
data-slot="questionnaire-next"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
buttonVariants({ size, variant }),
|
||||
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "Next"}
|
||||
</QuestionnairePrimitive.Next>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireSubmit({
|
||||
children,
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> &
|
||||
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
|
||||
return (
|
||||
<QuestionnairePrimitive.Submit
|
||||
data-slot="questionnaire-submit"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
buttonVariants({ size, variant }),
|
||||
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "Submit"}
|
||||
</QuestionnairePrimitive.Submit>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Questionnaire,
|
||||
QuestionnaireActions,
|
||||
QuestionnaireChoice,
|
||||
QuestionnaireChoiceDescription,
|
||||
QuestionnaireChoices,
|
||||
QuestionnaireDescription,
|
||||
QuestionnaireError,
|
||||
QuestionnaireInput,
|
||||
QuestionnaireItem,
|
||||
QuestionnaireNext,
|
||||
QuestionnairePrevious,
|
||||
QuestionnaireProgress,
|
||||
QuestionnaireSkip,
|
||||
QuestionnaireSubmit,
|
||||
QuestionnaireTitle,
|
||||
};
|
||||
Reference in New Issue
Block a user