@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.
- package/package.json +4 -2
- package/src/ds/bento-card/bento-card.tsx +27 -35
- package/src/ds/bento-grid/bento-grid.tsx +23 -36
- package/src/ds/button/button.tsx +2 -0
- package/src/ds/chat/chat-input.tsx +61 -36
- package/src/ds/chat/chat-labels.tsx +89 -0
- package/src/ds/chat/chat-message.tsx +9 -18
- package/src/ds/chat/chat-tool-log.tsx +23 -4
- package/src/ds/chat/chat-tool-question.tsx +9 -9
- package/src/ds/chat/chat.tsx +18 -18
- package/src/ds/chat/question-tabs.tsx +194 -76
- package/src/ds/editable-text/editable-text.tsx +107 -0
- package/src/ds/form/fields/search-select-field.tsx +13 -5
- package/src/ds/search-select/search-select.tsx +305 -161
- package/src/ds/sidebar/sidebar.tsx +60 -0
- package/src/ds/types.ts +1 -0
- package/src/index.ts +12 -2
- package/src/layout/dynamic-slot.tsx +12 -2
- package/src/layout/layout.tsx +79 -8
|
@@ -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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
35
|
-
const
|
|
45
|
+
const isQuestionStep = step.type === "question";
|
|
46
|
+
const currentIndex = isQuestionStep ? step.index : -1;
|
|
47
|
+
const isLastQuestion = currentIndex === questions.length - 1;
|
|
36
48
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
57
|
-
(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
{/*
|
|
65
|
-
<div className="
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
{/*
|
|
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
|
|
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
|
-
<
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 {
|
|
4
|
+
import type { SearchSelectOption } from "../../search-select/search-select";
|
|
5
5
|
import { FormFieldContext } from "../field";
|
|
6
6
|
|
|
7
|
-
export
|
|
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>
|