@arcote.tech/arc-ds 0.5.2 → 0.5.5
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 +2 -2
- package/src/ds/bento-card/bento-card.tsx +27 -35
- package/src/ds/bento-grid/bento-grid.tsx +23 -36
- 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 +2 -6
- 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/sidebar/sidebar.tsx +60 -0
- package/src/index.ts +9 -1
- package/src/layout/dynamic-slot.tsx +12 -2
- package/src/layout/layout.tsx +77 -8
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-ds",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.5",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
7
|
"description": "Design System for Arc framework — CVA-based components with display modes and variant overrides",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"tailwind-merge": "^3.5.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@arcote.tech/arc": "^0.5.
|
|
31
|
+
"@arcote.tech/arc": "^0.5.5",
|
|
32
32
|
"framer-motion": "^12.0.0",
|
|
33
33
|
"lucide-react": ">=0.400.0",
|
|
34
34
|
"radix-ui": "^1.0.0",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Box } from "../box/box";
|
|
2
|
-
import { useBentoSpan } from "../bento-grid/bento-grid";
|
|
3
2
|
import { motion } from "framer-motion";
|
|
4
3
|
import { Plus } from "lucide-react";
|
|
5
4
|
import type { ComponentType, ReactNode } from "react";
|
|
@@ -13,7 +12,6 @@ export interface BentoCardProps {
|
|
|
13
12
|
onEdit: () => void;
|
|
14
13
|
emptyLabel?: ReactNode;
|
|
15
14
|
className?: string;
|
|
16
|
-
span?: string;
|
|
17
15
|
children?: ReactNode;
|
|
18
16
|
}
|
|
19
17
|
|
|
@@ -26,45 +24,39 @@ export function BentoCard({
|
|
|
26
24
|
onEdit,
|
|
27
25
|
emptyLabel,
|
|
28
26
|
className,
|
|
29
|
-
span,
|
|
30
27
|
children,
|
|
31
28
|
}: BentoCardProps) {
|
|
32
|
-
const autoSpan = useBentoSpan();
|
|
33
|
-
const resolvedSpan = span ?? autoSpan;
|
|
34
|
-
|
|
35
29
|
return (
|
|
36
|
-
<div
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
<motion.div
|
|
31
|
+
layoutId={layoutId}
|
|
32
|
+
className={className}
|
|
33
|
+
style={{ opacity: isModalOpen ? 0 : 1 }}
|
|
34
|
+
>
|
|
35
|
+
<Box
|
|
36
|
+
layout={false}
|
|
37
|
+
className="group relative cursor-pointer p-5 hover:shadow-lg transition-all duration-300 hover:scale-[1.01]"
|
|
38
|
+
onClick={onEdit}
|
|
41
39
|
>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
onClick={onEdit}
|
|
46
|
-
>
|
|
47
|
-
<div className="flex items-center gap-2.5 mb-3">
|
|
48
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
|
49
|
-
<Icon className="h-4 w-4 text-primary" />
|
|
50
|
-
</div>
|
|
51
|
-
<span className="text-sm font-semibold">{title}</span>
|
|
40
|
+
<div className="flex items-center gap-2.5 mb-3">
|
|
41
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
|
42
|
+
<Icon className="h-4 w-4 text-primary" />
|
|
52
43
|
</div>
|
|
44
|
+
<span className="text-sm font-semibold">{title}</span>
|
|
45
|
+
</div>
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
</div>
|
|
59
|
-
{emptyLabel && (
|
|
60
|
-
<span className="text-xs text-muted-foreground">{emptyLabel}</span>
|
|
61
|
-
)}
|
|
47
|
+
{isEmpty ? (
|
|
48
|
+
<div className="flex flex-col items-center justify-center gap-2 py-4 border-2 border-dashed border-muted-foreground/15 rounded-xl transition-colors group-hover:border-primary/30">
|
|
49
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted transition-colors group-hover:bg-primary/10">
|
|
50
|
+
<Plus className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-primary" />
|
|
62
51
|
</div>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
{emptyLabel && (
|
|
53
|
+
<span className="text-xs text-muted-foreground">{emptyLabel}</span>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
) : (
|
|
57
|
+
<div className="flex flex-col gap-2">{children}</div>
|
|
58
|
+
)}
|
|
59
|
+
</Box>
|
|
60
|
+
</motion.div>
|
|
69
61
|
);
|
|
70
62
|
}
|
|
@@ -1,51 +1,38 @@
|
|
|
1
|
-
import { Children,
|
|
1
|
+
import { Children, useMemo, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
export interface BentoGridProps {
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
className?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
type BentoGridItemContext = {
|
|
9
|
-
index: number;
|
|
10
|
-
count: number;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const BentoItemContext = createContext<BentoGridItemContext | null>(null);
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Hook for children to read their grid span class.
|
|
17
|
-
* Returns the appropriate Tailwind class (e.g., "md:row-span-2", "md:col-span-3")
|
|
18
|
-
* or empty string for normal cells.
|
|
19
|
-
*/
|
|
20
|
-
export function useBentoSpan(): string {
|
|
21
|
-
const ctx = useContext(BentoItemContext);
|
|
22
|
-
if (!ctx) return "";
|
|
23
|
-
const { index, count } = ctx;
|
|
24
|
-
|
|
25
|
-
if (count < 5) return "";
|
|
26
|
-
if (index === 0) return "md:row-span-2";
|
|
27
|
-
if (index === count - 1 && (count === 6 || count === 8)) return "md:col-span-3";
|
|
28
|
-
return "";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
8
|
/**
|
|
32
|
-
* Responsive
|
|
9
|
+
* Responsive multi-column layout. Children are distributed round-robin into
|
|
10
|
+
* 3 columns; each column is a flex-col so items take their natural height.
|
|
11
|
+
* No row-spanning, no fixed row heights, no auto-layout magic — every item
|
|
12
|
+
* controls its own size and renders exactly as tall as its content.
|
|
33
13
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
14
|
+
* Breakpoints: 1 column (mobile), 2 (md), 3 (lg+). Children are always
|
|
15
|
+
* distributed across 3 buckets; on narrower viewports the buckets stack via
|
|
16
|
+
* the outer `grid-cols-*` utilities.
|
|
36
17
|
*/
|
|
37
18
|
export function BentoGrid({ children, className }: BentoGridProps) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
19
|
+
const columns = useMemo(() => {
|
|
20
|
+
const items = Children.toArray(children);
|
|
21
|
+
const cols: ReactNode[][] = [[], [], []];
|
|
22
|
+
items.forEach((item, i) => {
|
|
23
|
+
cols[i % 3].push(item);
|
|
24
|
+
});
|
|
25
|
+
return cols;
|
|
26
|
+
}, [children]);
|
|
42
27
|
|
|
43
28
|
return (
|
|
44
|
-
<div
|
|
45
|
-
{items
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
29
|
+
<div
|
|
30
|
+
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 items-start ${className ?? ""}`}
|
|
31
|
+
>
|
|
32
|
+
{columns.map((col, i) => (
|
|
33
|
+
<div key={i} className="flex flex-col gap-3">
|
|
34
|
+
{col}
|
|
35
|
+
</div>
|
|
49
36
|
))}
|
|
50
37
|
</div>
|
|
51
38
|
);
|
|
@@ -5,6 +5,7 @@ import { SearchSelect } from "../search-select/search-select";
|
|
|
5
5
|
import { Send, Globe } from "lucide-react";
|
|
6
6
|
import { useState, type ReactNode } from "react";
|
|
7
7
|
import type { ChatModel, SendMessageOptions } from "./types";
|
|
8
|
+
import { useChatLabels } from "./chat-labels";
|
|
8
9
|
|
|
9
10
|
interface ChatInputProps {
|
|
10
11
|
onSend: (message: string, options: SendMessageOptions) => void;
|
|
@@ -12,10 +13,18 @@ interface ChatInputProps {
|
|
|
12
13
|
defaultModel?: string;
|
|
13
14
|
/** Extra toolbar content (e.g., attach menu) rendered after web search toggle */
|
|
14
15
|
toolbar?: ReactNode;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
16
|
+
/** Show the model selector dropdown. Default true. */
|
|
17
|
+
showModelSelector?: boolean;
|
|
18
|
+
/** Show the web search toggle. Default true. */
|
|
19
|
+
showWebSearch?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Render slot for the send button. Receives `onClick` and `disabled` —
|
|
22
|
+
* consumer wires them to its own button. Defaults to a `<Button icon={Send}>`.
|
|
23
|
+
*/
|
|
24
|
+
renderSendButton?: (props: {
|
|
25
|
+
onClick: () => void;
|
|
26
|
+
disabled: boolean;
|
|
27
|
+
}) => ReactNode;
|
|
19
28
|
/** Disable input (e.g., during generation) */
|
|
20
29
|
disabled?: boolean;
|
|
21
30
|
}
|
|
@@ -25,10 +34,12 @@ export function ChatInput({
|
|
|
25
34
|
models,
|
|
26
35
|
defaultModel,
|
|
27
36
|
toolbar,
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
showModelSelector = true,
|
|
38
|
+
showWebSearch = true,
|
|
39
|
+
renderSendButton,
|
|
30
40
|
disabled = false,
|
|
31
41
|
}: ChatInputProps) {
|
|
42
|
+
const labels = useChatLabels();
|
|
32
43
|
const [message, setMessage] = useState("");
|
|
33
44
|
const [model, setModel] = useState(defaultModel ?? models[0]?.value ?? "");
|
|
34
45
|
const [webSearch, setWebSearch] = useState(false);
|
|
@@ -41,51 +52,65 @@ export function ChatInput({
|
|
|
41
52
|
setMessage("");
|
|
42
53
|
};
|
|
43
54
|
|
|
55
|
+
// Hide the toolbar row entirely if there's nothing in it
|
|
56
|
+
const hasToolbar = showModelSelector || showWebSearch || toolbar;
|
|
57
|
+
const sendDisabled = disabled || !message.trim();
|
|
58
|
+
|
|
44
59
|
return (
|
|
45
60
|
<Box className={`p-3 space-y-2 ${disabled ? "opacity-50 pointer-events-none" : ""}`}>
|
|
46
61
|
{/* Input + send */}
|
|
47
|
-
<div className="flex items-
|
|
62
|
+
<div className="flex items-center gap-2">
|
|
48
63
|
<div className="flex-1">
|
|
49
64
|
<TextareaField
|
|
50
65
|
value={message}
|
|
51
66
|
onChange={(val) => setMessage(val ?? "")}
|
|
52
|
-
placeholder={disabled ?
|
|
67
|
+
placeholder={disabled ? labels.placeholderGenerating : labels.placeholder}
|
|
53
68
|
rows={1}
|
|
54
69
|
/>
|
|
55
70
|
</div>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
{renderSendButton ? (
|
|
72
|
+
renderSendButton({ onClick: handleSend, disabled: sendDisabled })
|
|
73
|
+
) : (
|
|
74
|
+
<Button
|
|
75
|
+
size="sm"
|
|
76
|
+
icon={Send}
|
|
77
|
+
onClick={handleSend}
|
|
78
|
+
disabled={sendDisabled}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
62
81
|
</div>
|
|
63
82
|
|
|
64
83
|
{/* Toolbar */}
|
|
65
|
-
|
|
66
|
-
<div className="
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
{hasToolbar && (
|
|
85
|
+
<div className="flex items-center gap-1.5">
|
|
86
|
+
{showModelSelector && (
|
|
87
|
+
<div className="w-[130px]">
|
|
88
|
+
<SearchSelect
|
|
89
|
+
value={model}
|
|
90
|
+
onChange={setModel}
|
|
91
|
+
options={models}
|
|
92
|
+
placeholder={labels.modelPlaceholder}
|
|
93
|
+
position="absolute"
|
|
94
|
+
direction="up"
|
|
95
|
+
size="sm"
|
|
96
|
+
allowClear={false}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
{showWebSearch && (
|
|
102
|
+
<Button
|
|
103
|
+
variant={webSearch ? "default" : "ghost"}
|
|
104
|
+
size="xs"
|
|
105
|
+
icon={Globe}
|
|
106
|
+
label={labels.webSearchLabel}
|
|
107
|
+
onClick={() => setWebSearch((v) => !v)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
86
110
|
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
{toolbar}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
89
114
|
</Box>
|
|
90
115
|
);
|
|
91
116
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ChatLabels {
|
|
4
|
+
// ChatInput
|
|
5
|
+
placeholder: string;
|
|
6
|
+
placeholderGenerating: string;
|
|
7
|
+
modelPlaceholder: string;
|
|
8
|
+
webSearchLabel: string;
|
|
9
|
+
// QuestionTabs (wizard)
|
|
10
|
+
submitLabel: string;
|
|
11
|
+
customPlaceholder: string;
|
|
12
|
+
nextLabel: string;
|
|
13
|
+
backLabel: string;
|
|
14
|
+
summaryTitle: string;
|
|
15
|
+
editLabel: string;
|
|
16
|
+
noAnswerLabel: string;
|
|
17
|
+
questionOfLabel: (current: number, total: number) => string;
|
|
18
|
+
/** Secondary button in QuestionTabs — "Continue discussion" instead of answering */
|
|
19
|
+
discussLabel: string;
|
|
20
|
+
// ChatMessage
|
|
21
|
+
questionsLabel: string;
|
|
22
|
+
answerLabel: string;
|
|
23
|
+
// Tool execution log (rendered by chat-component)
|
|
24
|
+
toolCallingLabel: string;
|
|
25
|
+
toolDoneLabel: string;
|
|
26
|
+
errorLabel: string;
|
|
27
|
+
// askQuestions tool view
|
|
28
|
+
answerBelowLabel: string;
|
|
29
|
+
// completeStage tool view
|
|
30
|
+
stageCompleteLabel: string;
|
|
31
|
+
advanceStageLabel: string;
|
|
32
|
+
continueStageLabel: string;
|
|
33
|
+
stageAdvancedLabel: string;
|
|
34
|
+
stageContinuedLabel: string;
|
|
35
|
+
/** Label for the "Przejdź do następnego" button on the final stage. */
|
|
36
|
+
finishConsultationLabel: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const defaultChatLabels: ChatLabels = {
|
|
40
|
+
placeholder: "Type a message...",
|
|
41
|
+
placeholderGenerating: "Generating...",
|
|
42
|
+
modelPlaceholder: "Model...",
|
|
43
|
+
webSearchLabel: "Web",
|
|
44
|
+
submitLabel: "Submit",
|
|
45
|
+
customPlaceholder: "Custom answer...",
|
|
46
|
+
nextLabel: "Next",
|
|
47
|
+
backLabel: "Back",
|
|
48
|
+
summaryTitle: "Summary",
|
|
49
|
+
editLabel: "Edit",
|
|
50
|
+
noAnswerLabel: "No answer",
|
|
51
|
+
questionOfLabel: (current, total) => `Question ${current} of ${total}`,
|
|
52
|
+
discussLabel: "Continue discussion",
|
|
53
|
+
questionsLabel: "questions to answer",
|
|
54
|
+
answerLabel: "Answer",
|
|
55
|
+
toolCallingLabel: "Executing...",
|
|
56
|
+
toolDoneLabel: "Done",
|
|
57
|
+
errorLabel: "Error",
|
|
58
|
+
answerBelowLabel: "Answer the questions below",
|
|
59
|
+
stageCompleteLabel: "Stage complete",
|
|
60
|
+
advanceStageLabel: "Go to next stage",
|
|
61
|
+
continueStageLabel: "Keep talking",
|
|
62
|
+
stageAdvancedLabel: "Moved to next stage",
|
|
63
|
+
stageContinuedLabel: "Staying on this stage",
|
|
64
|
+
finishConsultationLabel: "Finish consultation",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const ChatLabelsContext = createContext<ChatLabels>(defaultChatLabels);
|
|
68
|
+
|
|
69
|
+
export function ChatLabelsProvider({
|
|
70
|
+
labels,
|
|
71
|
+
children,
|
|
72
|
+
}: {
|
|
73
|
+
labels?: Partial<ChatLabels>;
|
|
74
|
+
children: ReactNode;
|
|
75
|
+
}) {
|
|
76
|
+
const merged = useMemo<ChatLabels>(
|
|
77
|
+
() => ({ ...defaultChatLabels, ...labels }),
|
|
78
|
+
[labels],
|
|
79
|
+
);
|
|
80
|
+
return (
|
|
81
|
+
<ChatLabelsContext.Provider value={merged}>
|
|
82
|
+
{children}
|
|
83
|
+
</ChatLabelsContext.Provider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useChatLabels(): ChatLabels {
|
|
88
|
+
return useContext(ChatLabelsContext);
|
|
89
|
+
}
|
|
@@ -2,24 +2,20 @@ import { Button } from "../button/button";
|
|
|
2
2
|
import { Bot, User, MessageSquare } from "lucide-react";
|
|
3
3
|
import type { ChatMessageData } from "./types";
|
|
4
4
|
import { ToolUseBlock } from "./tool-use-block";
|
|
5
|
+
import { useChatLabels } from "./chat-labels";
|
|
5
6
|
|
|
6
7
|
interface ChatMessageProps {
|
|
7
8
|
message: ChatMessageData;
|
|
8
9
|
onAnswerQuestions?: () => void;
|
|
9
10
|
onToolUseClick?: (link: string) => void;
|
|
10
|
-
/** Label for questions count, e.g. "questions to answer" */
|
|
11
|
-
questionsLabel?: string;
|
|
12
|
-
/** Label for the answer button */
|
|
13
|
-
answerLabel?: string;
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
export function ChatMessage({
|
|
17
14
|
message,
|
|
18
15
|
onAnswerQuestions,
|
|
19
16
|
onToolUseClick,
|
|
20
|
-
questionsLabel = "questions to answer",
|
|
21
|
-
answerLabel = "Answer",
|
|
22
17
|
}: ChatMessageProps) {
|
|
18
|
+
const { questionsLabel, answerLabel } = useChatLabels();
|
|
23
19
|
const isUser = message.role === "user";
|
|
24
20
|
const hasUnansweredQuestions =
|
|
25
21
|
message.questions && message.questions.length > 0;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { MessageCircleQuestion,
|
|
2
|
+
import { MessageCircleQuestion, User } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
export interface ChatToolQuestionProps {
|
|
5
5
|
/** Whether the tool is waiting for user response */
|
|
@@ -12,23 +12,23 @@ export function ChatToolQuestion({
|
|
|
12
12
|
calling,
|
|
13
13
|
children,
|
|
14
14
|
}: ChatToolQuestionProps) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Calling = waiting for user → amber accent.
|
|
16
|
+
// Answered → primary (purple) so it visually matches user message bubbles.
|
|
17
|
+
const containerColor = calling
|
|
18
|
+
? "border border-amber-500/20 bg-amber-500/5"
|
|
19
|
+
: "border border-primary/20 bg-primary/10";
|
|
18
20
|
|
|
19
|
-
const iconColor = calling
|
|
20
|
-
? "text-amber-500"
|
|
21
|
-
: "text-green-500";
|
|
21
|
+
const iconColor = calling ? "text-amber-500" : "text-primary";
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<div
|
|
25
|
-
className={`flex items-start gap-3 w-full rounded-xl
|
|
25
|
+
className={`flex items-start gap-3 w-full rounded-xl p-3 transition-colors ${containerColor}`}
|
|
26
26
|
>
|
|
27
27
|
<div className={`mt-0.5 shrink-0 ${iconColor}`}>
|
|
28
28
|
{calling ? (
|
|
29
29
|
<MessageCircleQuestion className="h-4 w-4" />
|
|
30
30
|
) : (
|
|
31
|
-
<
|
|
31
|
+
<User className="h-4 w-4" />
|
|
32
32
|
)}
|
|
33
33
|
</div>
|
|
34
34
|
<div className="flex-1 min-w-0 space-y-2 text-sm">{children}</div>
|
package/src/ds/chat/chat.tsx
CHANGED
|
@@ -32,20 +32,22 @@ export interface ChatProps {
|
|
|
32
32
|
sidebar?: ReactNode;
|
|
33
33
|
/** Extra toolbar content for ChatInput (attach menu, etc.) */
|
|
34
34
|
toolbar?: ReactNode;
|
|
35
|
+
/** Show the model selector dropdown in ChatInput. Default true. */
|
|
36
|
+
showModelSelector?: boolean;
|
|
37
|
+
/** Show the web search toggle in ChatInput. Default true. */
|
|
38
|
+
showWebSearch?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Render slot for ChatInput's send button. Receives `onClick` and
|
|
41
|
+
* `disabled` — caller renders its own button (e.g. branded with a logo).
|
|
42
|
+
*/
|
|
43
|
+
renderSendButton?: (props: {
|
|
44
|
+
onClick: () => void;
|
|
45
|
+
disabled: boolean;
|
|
46
|
+
}) => ReactNode;
|
|
35
47
|
/** Max width class for the message area */
|
|
36
48
|
maxWidth?: string;
|
|
37
49
|
/** Disable input (during generation) */
|
|
38
50
|
disabled?: boolean;
|
|
39
|
-
/** Labels for i18n */
|
|
40
|
-
labels?: {
|
|
41
|
-
questionsLabel?: string;
|
|
42
|
-
answerLabel?: string;
|
|
43
|
-
webSearchLabel?: string;
|
|
44
|
-
placeholder?: string;
|
|
45
|
-
submitLabel?: string;
|
|
46
|
-
completedLabel?: string;
|
|
47
|
-
customPlaceholder?: string;
|
|
48
|
-
};
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export function Chat({
|
|
@@ -58,9 +60,11 @@ export function Chat({
|
|
|
58
60
|
header,
|
|
59
61
|
sidebar,
|
|
60
62
|
toolbar,
|
|
63
|
+
showModelSelector = true,
|
|
64
|
+
showWebSearch = true,
|
|
65
|
+
renderSendButton,
|
|
61
66
|
maxWidth = "max-w-3xl",
|
|
62
67
|
disabled = false,
|
|
63
|
-
labels,
|
|
64
68
|
}: ChatProps) {
|
|
65
69
|
const { inputOverride } = useChatInput();
|
|
66
70
|
const [answeringMessageId, setAnsweringMessageId] = useState<string | null>(
|
|
@@ -103,8 +107,6 @@ export function Chat({
|
|
|
103
107
|
: undefined
|
|
104
108
|
}
|
|
105
109
|
onToolUseClick={onToolUseClick}
|
|
106
|
-
questionsLabel={labels?.questionsLabel}
|
|
107
|
-
answerLabel={labels?.answerLabel}
|
|
108
110
|
/>
|
|
109
111
|
))}
|
|
110
112
|
<div ref={messagesEndRef} />
|
|
@@ -121,9 +123,6 @@ export function Chat({
|
|
|
121
123
|
onAnswerQuestions?.(answeringMessageId!, answers);
|
|
122
124
|
setAnsweringMessageId(null);
|
|
123
125
|
}}
|
|
124
|
-
submitLabel={labels?.submitLabel}
|
|
125
|
-
completedLabel={labels?.completedLabel}
|
|
126
|
-
customPlaceholder={labels?.customPlaceholder}
|
|
127
126
|
/>
|
|
128
127
|
) : (
|
|
129
128
|
<ChatInput
|
|
@@ -131,8 +130,9 @@ export function Chat({
|
|
|
131
130
|
models={models}
|
|
132
131
|
defaultModel={defaultModel}
|
|
133
132
|
toolbar={toolbar}
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
showModelSelector={showModelSelector}
|
|
134
|
+
showWebSearch={showWebSearch}
|
|
135
|
+
renderSendButton={renderSendButton}
|
|
136
136
|
disabled={disabled}
|
|
137
137
|
/>
|
|
138
138
|
)}
|
|
@@ -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,60 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { Box } from "../box/box";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sidebar — vertical container for navigation/auxiliary content beside main
|
|
8
|
+
* page content. Typically registered into a layout slot like `sidebar-left`.
|
|
9
|
+
*
|
|
10
|
+
* The `sticky` variant pins the sidebar next to the toolbar and stretches it
|
|
11
|
+
* to fill the visible viewport height. It relies on the CSS variable
|
|
12
|
+
* `--arc-toolbar-height` set by the desktop `Layout` (measured from the
|
|
13
|
+
* actual rendered toolbar via ResizeObserver). Falls back to `5rem` if the
|
|
14
|
+
* variable isn't set.
|
|
15
|
+
*/
|
|
16
|
+
export const sidebarVariants = cva("flex flex-col gap-3 p-4", {
|
|
17
|
+
variants: {
|
|
18
|
+
variant: {
|
|
19
|
+
default: "",
|
|
20
|
+
sticky:
|
|
21
|
+
"sticky top-[var(--arc-toolbar-height,5rem)] " +
|
|
22
|
+
"max-h-[calc(100vh-var(--arc-toolbar-height,5rem)-1rem)] " +
|
|
23
|
+
"overflow-y-auto",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
variant: "default",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export interface SidebarProps extends VariantProps<typeof sidebarVariants> {
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Title rendered at the top with uppercase muted styling. */
|
|
35
|
+
title?: ReactNode;
|
|
36
|
+
/** Footer rendered at the bottom with a top border. */
|
|
37
|
+
footer?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function Sidebar({
|
|
41
|
+
variant,
|
|
42
|
+
children,
|
|
43
|
+
className,
|
|
44
|
+
title,
|
|
45
|
+
footer,
|
|
46
|
+
}: SidebarProps) {
|
|
47
|
+
return (
|
|
48
|
+
<Box className={cn(sidebarVariants({ variant }), className)}>
|
|
49
|
+
{title && (
|
|
50
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
51
|
+
{title}
|
|
52
|
+
</h3>
|
|
53
|
+
)}
|
|
54
|
+
<div className="flex-1 min-h-0">{children}</div>
|
|
55
|
+
{footer && (
|
|
56
|
+
<div className="pt-2 border-t border-border">{footer}</div>
|
|
57
|
+
)}
|
|
58
|
+
</Box>
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -54,7 +54,7 @@ export {
|
|
|
54
54
|
} from "./ds/avatar/avatar";
|
|
55
55
|
export { BentoCard } from "./ds/bento-card/bento-card";
|
|
56
56
|
export type { BentoCardProps } from "./ds/bento-card/bento-card";
|
|
57
|
-
export { BentoGrid
|
|
57
|
+
export { BentoGrid } from "./ds/bento-grid/bento-grid";
|
|
58
58
|
export type { BentoGridProps } from "./ds/bento-grid/bento-grid";
|
|
59
59
|
export { CardModal, CardFormModal, ModalActions } from "./ds/card-modal/card-modal";
|
|
60
60
|
export type { CardModalProps, CardFormModalProps, ModalActionsProps } from "./ds/card-modal/card-modal";
|
|
@@ -67,6 +67,8 @@ export type { SearchSelectProps, SearchSelectOption } from "./ds/search-select/s
|
|
|
67
67
|
export { Badge, badgeVariants } from "./ds/badge/badge";
|
|
68
68
|
export { Box, boxVariants } from "./ds/box/box";
|
|
69
69
|
export type { BoxProps } from "./ds/box/box";
|
|
70
|
+
export { Sidebar, sidebarVariants } from "./ds/sidebar/sidebar";
|
|
71
|
+
export type { SidebarProps } from "./ds/sidebar/sidebar";
|
|
70
72
|
export {
|
|
71
73
|
Button,
|
|
72
74
|
buttonIconVariants,
|
|
@@ -111,6 +113,12 @@ export type { ChatToolLogProps } from "./ds/chat/chat-tool-log";
|
|
|
111
113
|
export { ChatToolQuestion } from "./ds/chat/chat-tool-question";
|
|
112
114
|
export type { ChatToolQuestionProps } from "./ds/chat/chat-tool-question";
|
|
113
115
|
export { ChatInputProvider, useChatInput } from "./ds/chat/chat-input-provider";
|
|
116
|
+
export {
|
|
117
|
+
ChatLabelsProvider,
|
|
118
|
+
useChatLabels,
|
|
119
|
+
defaultChatLabels,
|
|
120
|
+
} from "./ds/chat/chat-labels";
|
|
121
|
+
export type { ChatLabels } from "./ds/chat/chat-labels";
|
|
114
122
|
export type {
|
|
115
123
|
ChatMessageData,
|
|
116
124
|
ChatModel,
|
|
@@ -68,6 +68,12 @@ export function DynamicSlotProvider({ children }: { children: ReactNode }) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Register dynamic content into a named slot.
|
|
70
70
|
* Content appears while the component is mounted, removed on unmount.
|
|
71
|
+
*
|
|
72
|
+
* Always registers — even with `null` content — so the slot preserves a
|
|
73
|
+
* stable insertion order across re-renders. Readers filter out null entries
|
|
74
|
+
* via `useDynamicSlotContent` below, so conditional injection
|
|
75
|
+
* (`useSlotContent(slot, id, cond ? <x /> : null)`) works without shifting
|
|
76
|
+
* the position of sibling slot entries.
|
|
71
77
|
*/
|
|
72
78
|
export function useSlotContent(slotId: string, id: string, content: ReactNode) {
|
|
73
79
|
const ctx = useContext(DynamicSlotContext);
|
|
@@ -85,11 +91,15 @@ export function useSlotContent(slotId: string, id: string, content: ReactNode) {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/**
|
|
88
|
-
* Read dynamic slot content for a given slot ID.
|
|
94
|
+
* Read dynamic slot content for a given slot ID. Filters out null/undefined
|
|
95
|
+
* entries so consumers get only the renderable pieces.
|
|
89
96
|
*/
|
|
90
97
|
export function useDynamicSlotContent(slotId: string): ReactNode[] {
|
|
91
98
|
const ctx = useContext(DynamicSlotContext);
|
|
92
99
|
if (!ctx) return [];
|
|
93
100
|
const entries = ctx.entries.get(slotId);
|
|
94
|
-
|
|
101
|
+
if (!entries) return [];
|
|
102
|
+
return entries
|
|
103
|
+
.map((e) => e.content)
|
|
104
|
+
.filter((c) => c != null && c !== false);
|
|
95
105
|
}
|
package/src/layout/layout.tsx
CHANGED
|
@@ -47,8 +47,11 @@ export function Layout({ children }: { children?: ReactNode }) {
|
|
|
47
47
|
function SubNavSlot() {
|
|
48
48
|
const content = useDynamicSlotContent("sub-nav");
|
|
49
49
|
if (content.length === 0) return null;
|
|
50
|
+
// `relative z-30` creates an explicit stacking context at Z.BASE so any
|
|
51
|
+
// popovers inside the sub-nav (SearchSelect absolute dropdown, etc.) paint
|
|
52
|
+
// above the main content area that follows it in document flow.
|
|
50
53
|
return (
|
|
51
|
-
<div className="flex justify-center py-2">
|
|
54
|
+
<div className="relative z-30 flex justify-center py-2">
|
|
52
55
|
<Box layoutId="sub-nav" className="flex items-center gap-1 px-3 py-2">
|
|
53
56
|
{content}
|
|
54
57
|
</Box>
|
|
@@ -56,21 +59,84 @@ function SubNavSlot() {
|
|
|
56
59
|
);
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Tracks the desktop toolbar's actual rendered height and exposes it as a
|
|
64
|
+
* CSS variable on `<html>`. The toolbar is `position: fixed` so the layout
|
|
65
|
+
* itself can't measure it via flow — we query the data-attributed boxes
|
|
66
|
+
* (set by `DesktopToolbar`) and observe size changes.
|
|
67
|
+
*
|
|
68
|
+
* Consumers (sidebar, content spacer) read `var(--arc-toolbar-height)` to
|
|
69
|
+
* align with the toolbar without hard-coding pixel values.
|
|
70
|
+
*/
|
|
71
|
+
function useDesktopToolbarHeight() {
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const root = document.documentElement;
|
|
74
|
+
const set = (px: number) => {
|
|
75
|
+
root.style.setProperty("--arc-toolbar-height", `${px}px`);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let ro: ResizeObserver | null = null;
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
|
|
81
|
+
const start = () => {
|
|
82
|
+
if (cancelled) return;
|
|
83
|
+
const boxes = Array.from(
|
|
84
|
+
document.querySelectorAll<HTMLElement>("[data-arc-toolbar]"),
|
|
85
|
+
);
|
|
86
|
+
if (boxes.length === 0) {
|
|
87
|
+
// Toolbar not mounted yet — try again next frame
|
|
88
|
+
requestAnimationFrame(start);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const measure = () => {
|
|
92
|
+
const tallest = boxes.reduce(
|
|
93
|
+
(max, el) => Math.max(max, el.offsetHeight),
|
|
94
|
+
0,
|
|
95
|
+
);
|
|
96
|
+
// 16px top margin (top-4) + box height + 16px breathing room
|
|
97
|
+
set(tallest + 16 + 16);
|
|
98
|
+
};
|
|
99
|
+
measure();
|
|
100
|
+
ro = new ResizeObserver(measure);
|
|
101
|
+
for (const el of boxes) ro.observe(el);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
start();
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
ro?.disconnect();
|
|
109
|
+
root.style.removeProperty("--arc-toolbar-height");
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
function DesktopLayout({ children }: { children?: ReactNode }) {
|
|
60
115
|
const renderSlot = useRenderSlot();
|
|
116
|
+
useDesktopToolbarHeight();
|
|
61
117
|
|
|
62
118
|
return (
|
|
63
119
|
<>
|
|
64
120
|
<DesktopToolbar />
|
|
65
|
-
|
|
121
|
+
{/* Spacer pushing main content below the fixed toolbar.
|
|
122
|
+
Height matches `--arc-toolbar-height` set by useDesktopToolbarHeight. */}
|
|
123
|
+
<div style={{ height: "var(--arc-toolbar-height, 5rem)" }} />
|
|
66
124
|
<SubNavSlot />
|
|
67
|
-
<div className="
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
125
|
+
<div className="flex w-full flex-1">
|
|
126
|
+
<div className="flex min-w-0 flex-1 justify-center">
|
|
127
|
+
<div className="flex w-full max-w-5xl gap-4 p-4">
|
|
128
|
+
{renderSlot("sidebar-left", { className: "flex w-64 shrink-0 flex-col gap-4" })}
|
|
129
|
+
<div className="min-w-0 flex-1">
|
|
130
|
+
{children}
|
|
131
|
+
{renderSlot("main-content", { className: "flex flex-col gap-4" })}
|
|
132
|
+
</div>
|
|
133
|
+
{renderSlot("sidebar-right", { className: "flex w-64 shrink-0 flex-col gap-4" })}
|
|
134
|
+
</div>
|
|
72
135
|
</div>
|
|
73
|
-
{renderSlot("
|
|
136
|
+
{renderSlot("preview-pane", {
|
|
137
|
+
className:
|
|
138
|
+
"flex w-[480px] shrink-0 flex-col gap-4 overflow-y-auto border-l p-4",
|
|
139
|
+
})}
|
|
74
140
|
</div>
|
|
75
141
|
</>
|
|
76
142
|
);
|
|
@@ -98,6 +164,7 @@ function DesktopToolbar() {
|
|
|
98
164
|
return (
|
|
99
165
|
<>
|
|
100
166
|
<div
|
|
167
|
+
data-arc-toolbar="left"
|
|
101
168
|
className="fixed left-4 top-4"
|
|
102
169
|
style={{ zIndex: zIndex("workspace") }}
|
|
103
170
|
>
|
|
@@ -108,6 +175,7 @@ function DesktopToolbar() {
|
|
|
108
175
|
</div>
|
|
109
176
|
|
|
110
177
|
<div
|
|
178
|
+
data-arc-toolbar="center"
|
|
111
179
|
className="fixed left-1/2 top-4 max-w-[calc(100vw-26rem)] -translate-x-1/2"
|
|
112
180
|
style={{ zIndex: zIndex("center") }}
|
|
113
181
|
>
|
|
@@ -117,6 +185,7 @@ function DesktopToolbar() {
|
|
|
117
185
|
</div>
|
|
118
186
|
|
|
119
187
|
<div
|
|
188
|
+
data-arc-toolbar="right"
|
|
120
189
|
className="fixed right-4 top-4"
|
|
121
190
|
style={{ zIndex: zIndex("settings") }}
|
|
122
191
|
>
|