@arcote.tech/arc-ds 0.5.2 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,40 @@
1
1
  import { Box } from "../box/box";
2
2
  import { Button } from "../button/button";
3
3
  import { TextareaField } from "../form";
4
- import { Send, Check } from "lucide-react";
5
- import { useState } from "react";
4
+ import { ArrowLeft, ArrowRight, Check, MessageCircle, Pencil, Send } from "lucide-react";
5
+ import { Fragment, useState } from "react";
6
6
  import type { Question, QuestionAnswers } from "./types";
7
+ import { useChatLabels } from "./chat-labels";
7
8
 
8
9
  interface QuestionTabsProps {
9
10
  questions: Question[];
10
- onSubmit: (answers: QuestionAnswers) => void;
11
- /** Label for submit button */
12
- submitLabel?: string;
13
- /** Label for completed count, e.g. "completed" */
14
- completedLabel?: string;
15
- /** Placeholder for custom answer input */
16
- customPlaceholder?: string;
11
+ /**
12
+ * Called when user finishes answering. `wantsToDiscuss` is true when the
13
+ * user clicked the secondary "Continue discussion" button — consumers
14
+ * should forward this to the LLM so it knows to switch from question
15
+ * mode to open-ended conversation.
16
+ */
17
+ onSubmit: (
18
+ answers: QuestionAnswers,
19
+ options: { wantsToDiscuss: boolean },
20
+ ) => void;
17
21
  }
18
22
 
19
- export function QuestionTabs({
20
- questions,
21
- onSubmit,
22
- submitLabel = "Submit",
23
- completedLabel = "completed",
24
- customPlaceholder = "Custom answer...",
25
- }: QuestionTabsProps) {
26
- const [activeTab, setActiveTab] = useState(0);
23
+ type Step = { type: "question"; index: number } | { type: "summary" };
24
+
25
+ export function QuestionTabs({ questions, onSubmit }: QuestionTabsProps) {
26
+ const {
27
+ submitLabel,
28
+ nextLabel,
29
+ backLabel,
30
+ summaryTitle,
31
+ editLabel,
32
+ noAnswerLabel,
33
+ questionOfLabel,
34
+ customPlaceholder,
35
+ discussLabel,
36
+ } = useChatLabels();
37
+ const [step, setStep] = useState<Step>({ type: "question", index: 0 });
27
38
  const [answers, setAnswers] = useState<QuestionAnswers>(
28
39
  () =>
29
40
  Object.fromEntries(
@@ -31,69 +42,147 @@ export function QuestionTabs({
31
42
  ),
32
43
  );
33
44
 
34
- const current = questions[activeTab];
35
- const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
45
+ const isQuestionStep = step.type === "question";
46
+ const currentIndex = isQuestionStep ? step.index : -1;
47
+ const isLastQuestion = currentIndex === questions.length - 1;
36
48
 
37
- const toggleOption = (option: string) => {
38
- const selected = currentAnswer.selected.includes(option)
39
- ? currentAnswer.selected.filter((o) => o !== option)
40
- : [...currentAnswer.selected, option];
41
- setAnswers((prev) => ({
42
- ...prev,
43
- [current.id]: { ...prev[current.id], selected },
44
- }));
49
+ const goToQuestion = (index: number) =>
50
+ setStep({ type: "question", index });
51
+ const goToSummary = () => setStep({ type: "summary" });
52
+
53
+ const handleNext = () => {
54
+ if (!isQuestionStep) return;
55
+ if (isLastQuestion) {
56
+ goToSummary();
57
+ } else {
58
+ goToQuestion(currentIndex + 1);
59
+ }
45
60
  };
46
61
 
47
- const setText = (text: string) => {
62
+ const handleBackFromSummary = () => {
63
+ goToQuestion(questions.length - 1);
64
+ };
65
+
66
+ const updateAnswer = (
67
+ questionId: string,
68
+ updater: (a: { selected: string[]; text: string }) => {
69
+ selected: string[];
70
+ text: string;
71
+ },
72
+ ) => {
48
73
  setAnswers((prev) => ({
49
74
  ...prev,
50
- [current.id]: { ...prev[current.id], text },
75
+ [questionId]: updater(prev[questionId] ?? { selected: [], text: "" }),
51
76
  }));
52
77
  };
53
78
 
79
+ // ─── Summary step ──────────────────────────────────────────────
80
+ if (step.type === "summary") {
81
+ return (
82
+ <Box className="p-0 overflow-hidden">
83
+ <div className="border-b border-border px-4 py-3">
84
+ <p className="text-sm font-semibold">{summaryTitle}</p>
85
+ </div>
86
+
87
+ <ul className="divide-y divide-border">
88
+ {questions.map((q, i) => {
89
+ const answer = answers[q.id] ?? { selected: [], text: "" };
90
+ const parts = [
91
+ ...answer.selected,
92
+ ...(answer.text.trim() ? [answer.text.trim()] : []),
93
+ ];
94
+ const hasAnswer = parts.length > 0;
95
+ return (
96
+ <li
97
+ key={q.id}
98
+ className="group flex items-start gap-3 px-4 py-2.5"
99
+ >
100
+ <div className="flex-1 min-w-0">
101
+ <p className="text-xs font-medium text-muted-foreground">
102
+ {q.label}
103
+ </p>
104
+ <p
105
+ className={`text-sm mt-0.5 ${
106
+ hasAnswer ? "" : "italic text-muted-foreground/60"
107
+ }`}
108
+ >
109
+ {hasAnswer ? parts.join(", ") : noAnswerLabel}
110
+ </p>
111
+ </div>
112
+ <Button
113
+ variant="ghost"
114
+ size="xs"
115
+ icon={Pencil}
116
+ label={editLabel}
117
+ onClick={() => goToQuestion(i)}
118
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
119
+ />
120
+ </li>
121
+ );
122
+ })}
123
+ </ul>
124
+
125
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 gap-2">
126
+ <Button
127
+ variant="ghost"
128
+ size="sm"
129
+ icon={ArrowLeft}
130
+ label={backLabel}
131
+ onClick={handleBackFromSummary}
132
+ />
133
+ <div className="flex items-center gap-2">
134
+ <Button
135
+ variant="ghost"
136
+ size="sm"
137
+ icon={MessageCircle}
138
+ label={discussLabel}
139
+ onClick={() => onSubmit(answers, { wantsToDiscuss: true })}
140
+ />
141
+ <Button
142
+ size="sm"
143
+ icon={Send}
144
+ label={submitLabel}
145
+ onClick={() => onSubmit(answers, { wantsToDiscuss: false })}
146
+ />
147
+ </div>
148
+ </div>
149
+ </Box>
150
+ );
151
+ }
152
+
153
+ // ─── Question step ─────────────────────────────────────────────
154
+ const current = questions[currentIndex];
155
+ const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
54
156
  const hasCustomText = currentAnswer.text.trim().length > 0;
55
157
 
56
- const totalAnswered = questions.filter(
57
- (q) =>
58
- (answers[q.id]?.selected.length ?? 0) > 0 ||
59
- (answers[q.id]?.text ?? "").trim().length > 0,
60
- ).length;
158
+ const toggleOption = (option: string) => {
159
+ updateAnswer(current.id, (a) => ({
160
+ ...a,
161
+ selected: a.selected.includes(option)
162
+ ? a.selected.filter((o) => o !== option)
163
+ : [...a.selected, option],
164
+ }));
165
+ };
166
+
167
+ const setText = (text: string) => {
168
+ updateAnswer(current.id, (a) => ({ ...a, text }));
169
+ };
61
170
 
62
171
  return (
63
172
  <Box className="p-0 overflow-hidden">
64
- {/* Tabs */}
65
- <div className="flex border-b border-border overflow-x-auto">
66
- {questions.map((q, i) => {
67
- const hasAnswer =
68
- (answers[q.id]?.selected.length ?? 0) > 0 ||
69
- (answers[q.id]?.text ?? "").trim().length > 0;
70
- return (
71
- <button
72
- key={q.id}
73
- type="button"
74
- onClick={() => setActiveTab(i)}
75
- className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors border-b-2 -mb-px ${
76
- i === activeTab
77
- ? "border-primary text-primary"
78
- : "border-transparent text-muted-foreground hover:text-foreground"
79
- }`}
80
- >
81
- {hasAnswer && (
82
- <span className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
83
- )}
84
- {q.label}
85
- </button>
86
- );
87
- })}
173
+ {/* Header */}
174
+ <div className="border-b border-border px-4 py-3 space-y-1">
175
+ <p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
176
+ {questionOfLabel(currentIndex + 1, questions.length)}
177
+ </p>
178
+ <p className="text-sm font-semibold">{current.label}</p>
179
+ {current.description && (
180
+ <p className="text-xs text-muted-foreground">{current.description}</p>
181
+ )}
88
182
  </div>
89
183
 
90
- {/* Content */}
184
+ {/* Options */}
91
185
  <div className="p-3 space-y-1.5">
92
- <p className="text-xs text-muted-foreground mb-2">
93
- {current.description}
94
- </p>
95
-
96
- {/* Options */}
97
186
  {current.options.map((option) => {
98
187
  const isSelected = currentAnswer.selected.includes(option);
99
188
  return (
@@ -123,7 +212,7 @@ export function QuestionTabs({
123
212
  );
124
213
  })}
125
214
 
126
- {/* Custom option */}
215
+ {/* Custom answer */}
127
216
  <div
128
217
  className={`flex items-start gap-2.5 w-full rounded-lg px-3 py-2.5 transition-colors ${
129
218
  hasCustomText
@@ -153,17 +242,46 @@ export function QuestionTabs({
153
242
  </div>
154
243
  </div>
155
244
 
156
- {/* Footer */}
157
- <div className="flex items-center justify-between border-t border-border px-3 py-2">
158
- <span className="text-[10px] text-muted-foreground">
159
- {totalAnswered}/{questions.length} {completedLabel}
160
- </span>
161
- <Button
162
- size="sm"
163
- icon={Send}
164
- label={submitLabel}
165
- onClick={() => onSubmit(answers)}
166
- />
245
+ {/* Footer — progress dots + discuss/next buttons */}
246
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 gap-2">
247
+ <div className="flex items-center gap-1">
248
+ {questions.map((q, i) => {
249
+ const answered =
250
+ (answers[q.id]?.selected.length ?? 0) > 0 ||
251
+ (answers[q.id]?.text ?? "").trim().length > 0;
252
+ return (
253
+ <Fragment key={q.id}>
254
+ <button
255
+ type="button"
256
+ onClick={() => goToQuestion(i)}
257
+ className={`h-1.5 rounded-full transition-all ${
258
+ i === currentIndex
259
+ ? "w-4 bg-primary"
260
+ : answered
261
+ ? "w-1.5 bg-primary/60"
262
+ : "w-1.5 bg-muted-foreground/30"
263
+ }`}
264
+ aria-label={q.label}
265
+ />
266
+ </Fragment>
267
+ );
268
+ })}
269
+ </div>
270
+ <div className="flex items-center gap-2">
271
+ <Button
272
+ variant="ghost"
273
+ size="sm"
274
+ icon={MessageCircle}
275
+ label={discussLabel}
276
+ onClick={() => onSubmit(answers, { wantsToDiscuss: true })}
277
+ />
278
+ <Button
279
+ size="sm"
280
+ icon={ArrowRight}
281
+ label={nextLabel}
282
+ onClick={handleNext}
283
+ />
284
+ </div>
167
285
  </div>
168
286
  </Box>
169
287
  );
@@ -0,0 +1,107 @@
1
+ import { useRef, useEffect, useCallback } from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface EditableTextProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ placeholder?: string;
8
+ /** When true, Enter inserts a newline. When false (default), Enter saves and blurs. */
9
+ multiline?: boolean;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * A contenteditable div that looks like plain text — no border, no background.
15
+ * Emits `onChange` on blur or Enter (single-line mode).
16
+ *
17
+ * Placeholder is shown via CSS `:empty:before` using a data attribute.
18
+ */
19
+ export function EditableText({
20
+ value,
21
+ onChange,
22
+ placeholder,
23
+ multiline = false,
24
+ className,
25
+ }: EditableTextProps) {
26
+ const ref = useRef<HTMLDivElement>(null);
27
+ const isEditingRef = useRef(false);
28
+ const lastValueRef = useRef(value);
29
+
30
+ // Sync external value → DOM only when not actively editing
31
+ useEffect(() => {
32
+ if (isEditingRef.current) return;
33
+ if (!ref.current) return;
34
+ const currentText = ref.current.innerText;
35
+ if (currentText !== value) {
36
+ ref.current.innerText = value;
37
+ }
38
+ lastValueRef.current = value;
39
+ }, [value]);
40
+
41
+ const save = useCallback(() => {
42
+ if (!ref.current) return;
43
+ const text = ref.current.innerText.trim();
44
+ isEditingRef.current = false;
45
+ if (text !== lastValueRef.current) {
46
+ lastValueRef.current = text;
47
+ onChange(text);
48
+ }
49
+ }, [onChange]);
50
+
51
+ const handleBlur = useCallback(() => {
52
+ save();
53
+ }, [save]);
54
+
55
+ const handleFocus = useCallback(() => {
56
+ isEditingRef.current = true;
57
+ }, []);
58
+
59
+ const handleKeyDown = useCallback(
60
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
61
+ if (e.key === "Escape") {
62
+ e.preventDefault();
63
+ // Revert to last saved value
64
+ if (ref.current) {
65
+ ref.current.innerText = lastValueRef.current;
66
+ }
67
+ isEditingRef.current = false;
68
+ ref.current?.blur();
69
+ return;
70
+ }
71
+ if (e.key === "Enter" && !multiline) {
72
+ e.preventDefault();
73
+ save();
74
+ ref.current?.blur();
75
+ }
76
+ },
77
+ [multiline, save],
78
+ );
79
+
80
+ // Prevent pasting rich text — strip to plain text
81
+ const handlePaste = useCallback(
82
+ (e: React.ClipboardEvent<HTMLDivElement>) => {
83
+ e.preventDefault();
84
+ const text = e.clipboardData.getData("text/plain");
85
+ document.execCommand("insertText", false, text);
86
+ },
87
+ [],
88
+ );
89
+
90
+ return (
91
+ <div
92
+ ref={ref}
93
+ contentEditable
94
+ suppressContentEditableWarning
95
+ data-placeholder={placeholder}
96
+ onBlur={handleBlur}
97
+ onFocus={handleFocus}
98
+ onKeyDown={handleKeyDown}
99
+ onPaste={handlePaste}
100
+ className={cn(
101
+ "min-h-[1.5em] cursor-text outline-none",
102
+ "empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none",
103
+ className,
104
+ )}
105
+ />
106
+ );
107
+ }
@@ -1,15 +1,23 @@
1
1
  import { useContext } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { SearchSelect } from "../../search-select/search-select";
4
- import type { SearchSelectProps } from "../../search-select/search-select";
4
+ import type { SearchSelectOption } from "../../search-select/search-select";
5
5
  import { FormFieldContext } from "../field";
6
6
 
7
- export type SearchSelectFieldProps = Omit<SearchSelectProps, "value" | "onChange"> & {
7
+ export interface SearchSelectFieldProps {
8
+ options: SearchSelectOption[];
9
+ placeholder?: string;
10
+ searchPlaceholder?: string;
11
+ position?: "relative" | "absolute";
12
+ direction?: "down" | "up";
13
+ size?: "default" | "sm";
14
+ renderOption?: (option: SearchSelectOption, isActive: boolean, isSelected: boolean) => ReactNode;
15
+ allowClear?: boolean;
8
16
  label?: ReactNode;
9
17
  value?: string;
10
18
  onChange?: (value: string) => void;
11
19
  className?: string;
12
- };
20
+ }
13
21
 
14
22
  export function SearchSelectField({
15
23
  label,
@@ -29,9 +37,9 @@ export function SearchSelectField({
29
37
  </label>
30
38
  )}
31
39
  <SearchSelect
32
- value={value}
33
- onChange={(val) => onChange?.(val)}
34
40
  {...rest}
41
+ value={value}
42
+ onChange={(val: string) => onChange?.(val)}
35
43
  />
36
44
  {hasError && (
37
45
  <p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>