@arcote.tech/arc-ds 0.7.8 → 0.7.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ds",
3
3
  "type": "module",
4
- "version": "0.7.8",
4
+ "version": "0.7.9",
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",
@@ -30,7 +30,7 @@
30
30
  "tailwind-merge": "^3.5.0"
31
31
  },
32
32
  "peerDependencies": {
33
- "@arcote.tech/arc": "^0.7.8",
33
+ "@arcote.tech/arc": "^0.7.9",
34
34
  "framer-motion": "^12.0.0",
35
35
  "lucide-react": ">=0.400.0",
36
36
  "radix-ui": "^1.0.0",
@@ -7,6 +7,18 @@ import { useState, type ReactNode } from "react";
7
7
  import type { ChatModel, SendMessageOptions } from "./types";
8
8
  import { useChatLabels } from "./chat-labels";
9
9
 
10
+ /**
11
+ * Slot props dla custom textarea — używane przez `renderTextarea` żeby
12
+ * wymienić default TextareaField na np. VoiceTextarea z `@arcote.tech/arc-ai-voice`.
13
+ * Wszystkie pola są wymagane (provider musi je podpiąć), poza placeholder/rows.
14
+ */
15
+ export interface ChatInputTextareaSlotProps {
16
+ value: string;
17
+ onChange: (value: string) => void;
18
+ placeholder?: string;
19
+ rows?: number;
20
+ }
21
+
10
22
  interface ChatInputProps {
11
23
  onSend: (message: string, options: SendMessageOptions) => void;
12
24
  models: ChatModel[];
@@ -25,6 +37,12 @@ interface ChatInputProps {
25
37
  onClick: () => void;
26
38
  disabled: boolean;
27
39
  }) => ReactNode;
40
+ /**
41
+ * Slot na pole tekstowe — default to `<TextareaField>`. Konsumer może
42
+ * podpiąć `VoiceTextarea` z `@arcote.tech/arc-ai-voice` aby włączyć
43
+ * dyktowanie głosowe bez sprzęgania DS z fragmentem AI.
44
+ */
45
+ renderTextarea?: (props: ChatInputTextareaSlotProps) => ReactNode;
28
46
  /** Disable input (e.g., during generation) */
29
47
  disabled?: boolean;
30
48
  }
@@ -37,6 +55,7 @@ export function ChatInput({
37
55
  showModelSelector = true,
38
56
  showWebSearch = true,
39
57
  renderSendButton,
58
+ renderTextarea,
40
59
  disabled = false,
41
60
  }: ChatInputProps) {
42
61
  const labels = useChatLabels();
@@ -61,12 +80,23 @@ export function ChatInput({
61
80
  {/* Input + send */}
62
81
  <div className="flex items-center gap-2">
63
82
  <div className="flex-1">
64
- <TextareaField
65
- value={message}
66
- onChange={(val) => setMessage(val ?? "")}
67
- placeholder={disabled ? labels.placeholderGenerating : labels.placeholder}
68
- rows={1}
69
- />
83
+ {renderTextarea ? (
84
+ renderTextarea({
85
+ value: message,
86
+ onChange: setMessage,
87
+ placeholder: disabled
88
+ ? labels.placeholderGenerating
89
+ : labels.placeholder,
90
+ rows: 1,
91
+ })
92
+ ) : (
93
+ <TextareaField
94
+ value={message}
95
+ onChange={(val) => setMessage(val ?? "")}
96
+ placeholder={disabled ? labels.placeholderGenerating : labels.placeholder}
97
+ rows={1}
98
+ />
99
+ )}
70
100
  </div>
71
101
  {renderSendButton ? (
72
102
  renderSendButton({ onClick: handleSend, disabled: sendDisabled })
@@ -1,39 +1,45 @@
1
1
  import { createContext, useContext, useMemo, type ReactNode } from "react";
2
2
 
3
3
  export interface ChatLabels {
4
- // ChatInput
4
+ // HTML attribute strings (placeholder na <input>/<textarea>) muszą zostać
5
+ // `string` — DOM attribute API nie przyjmuje ReactNode.
5
6
  placeholder: string;
6
7
  placeholderGenerating: string;
7
8
  modelPlaceholder: string;
8
- webSearchLabel: string;
9
- // QuestionTabs (wizard)
10
- submitLabel: string;
11
9
  customPlaceholder: string;
12
- nextLabel: string;
13
- backLabel: string;
14
- summaryTitle: string;
15
- editLabel: string;
16
- noAnswerLabel: string;
17
- questionOfLabel: (current: number, total: number) => string;
10
+ // Wszystkie poniższe są renderowane w drzewie React (Button label, span,
11
+ // h2 itp.) — `ReactNode` pozwala używać `<Trans>...</Trans>` żeby labels
12
+ // reagowały na zmianę locale runtime'owo.
13
+ webSearchLabel: ReactNode;
14
+ // QuestionTabs (wizard)
15
+ submitLabel: ReactNode;
16
+ nextLabel: ReactNode;
17
+ backLabel: ReactNode;
18
+ summaryTitle: ReactNode;
19
+ editLabel: ReactNode;
20
+ noAnswerLabel: ReactNode;
21
+ questionOfLabel: (current: number, total: number) => ReactNode;
18
22
  /** Secondary button in QuestionTabs — "Continue discussion" instead of answering */
19
- discussLabel: string;
23
+ discussLabel: ReactNode;
20
24
  // ChatMessage
21
- questionsLabel: string;
22
- answerLabel: string;
23
- // Tool execution log (rendered by chat-component)
25
+ questionsLabel: ReactNode;
26
+ answerLabel: ReactNode;
27
+ // Tool execution log + error fallback — używane też w template literals
28
+ // (`${chatLabels.errorLabel}: ...`) i jako `content` ChatMessage. Stąd
29
+ // string, nie ReactNode.
24
30
  toolCallingLabel: string;
25
31
  toolDoneLabel: string;
26
32
  errorLabel: string;
27
33
  // askQuestions tool view
28
- answerBelowLabel: string;
34
+ answerBelowLabel: ReactNode;
29
35
  // completeStage tool view
30
- stageCompleteLabel: string;
31
- advanceStageLabel: string;
32
- continueStageLabel: string;
33
- stageAdvancedLabel: string;
34
- stageContinuedLabel: string;
36
+ stageCompleteLabel: ReactNode;
37
+ advanceStageLabel: ReactNode;
38
+ continueStageLabel: ReactNode;
39
+ stageAdvancedLabel: ReactNode;
40
+ stageContinuedLabel: ReactNode;
35
41
  /** Label for the "Przejdź do następnego" button on the final stage. */
36
- finishConsultationLabel: string;
42
+ finishConsultationLabel: ReactNode;
37
43
  }
38
44
 
39
45
  export const defaultChatLabels: ChatLabels = {
@@ -48,14 +48,13 @@ export function ChatMessage({
48
48
  : "bg-card border border-border rounded-tl-sm"
49
49
  }`}
50
50
  >
51
- <div className="chat-markdown space-y-2 text-left [&_p]:m-0 [&_ul]:my-1 [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-primary [&_a]:underline [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:text-xs [&_pre]:overflow-x-auto [&_strong]:font-semibold [&_em]:italic [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-medium [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:pl-3 [&_blockquote]:italic">
51
+ <div
52
+ data-streaming={message.isStreaming || undefined}
53
+ className="chat-markdown space-y-2 text-left [&_p]:m-0 [&_ul]:my-1 [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-primary [&_a]:underline [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:text-xs [&_pre]:overflow-x-auto [&_strong]:font-semibold [&_em]:italic [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-medium [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:pl-3 [&_blockquote]:italic data-[streaming]:[&_>*:last-child]:after:content-[''] data-[streaming]:[&_>*:last-child]:after:inline-block data-[streaming]:[&_>*:last-child]:after:w-[0.4em] data-[streaming]:[&_>*:last-child]:after:h-[1em] data-[streaming]:[&_>*:last-child]:after:bg-foreground/60 data-[streaming]:[&_>*:last-child]:after:animate-pulse data-[streaming]:[&_>*:last-child]:after:ml-0.5 data-[streaming]:[&_>*:last-child]:after:align-text-bottom">
52
54
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
53
55
  {message.content}
54
56
  </ReactMarkdown>
55
57
  </div>
56
- {message.isStreaming && (
57
- <span className="inline-block w-1.5 h-4 bg-foreground/60 animate-pulse ml-0.5 -mb-0.5" />
58
- )}
59
58
  </div>
60
59
 
61
60
  {/* Tool uses */}
@@ -6,7 +6,7 @@ import type {
6
6
  SendMessageOptions,
7
7
  } from "./types";
8
8
  import { ChatMessage } from "./chat-message";
9
- import { ChatInput } from "./chat-input";
9
+ import { ChatInput, type ChatInputTextareaSlotProps } from "./chat-input";
10
10
  import { useChatInput } from "./chat-input-provider";
11
11
  import { QuestionTabs } from "./question-tabs";
12
12
 
@@ -44,6 +44,11 @@ export interface ChatProps {
44
44
  onClick: () => void;
45
45
  disabled: boolean;
46
46
  }) => ReactNode;
47
+ /**
48
+ * Slot na pole tekstowe w ChatInput — przekazywane do `<ChatInput>`.
49
+ * Konsumer może podać `VoiceTextarea` z `@arcote.tech/arc-ai-voice`.
50
+ */
51
+ renderTextarea?: (props: ChatInputTextareaSlotProps) => ReactNode;
47
52
  /** Max width class for the message area */
48
53
  maxWidth?: string;
49
54
  /** Disable input (during generation) */
@@ -63,6 +68,7 @@ export function Chat({
63
68
  showModelSelector = true,
64
69
  showWebSearch = true,
65
70
  renderSendButton,
71
+ renderTextarea,
66
72
  maxWidth = "max-w-3xl",
67
73
  disabled = false,
68
74
  }: ChatProps) {
@@ -133,6 +139,7 @@ export function Chat({
133
139
  showModelSelector={showModelSelector}
134
140
  showWebSearch={showWebSearch}
135
141
  renderSendButton={renderSendButton}
142
+ renderTextarea={renderTextarea}
136
143
  disabled={disabled}
137
144
  />
138
145
  )}
@@ -10,10 +10,17 @@ export interface TextareaFieldProps {
10
10
  onChange?: (value: string) => void;
11
11
  rows?: number;
12
12
  maxHeight?: number;
13
+ /** Klasa na OUTER divie (label + pole). */
13
14
  className?: string;
15
+ /**
16
+ * Klasa dorzucona do INNER contentEditable. Używaj gdy chcesz nadpisać
17
+ * padding (np. zostawić miejsce na absolute-positioned ikonę po prawej)
18
+ * albo dodatkowe modyfikatory typografii.
19
+ */
20
+ inputClassName?: string;
14
21
  }
15
22
 
16
- export function TextareaField({ label, placeholder, value, onChange, rows = 1, maxHeight, className }: TextareaFieldProps) {
23
+ export function TextareaField({ label, placeholder, value, onChange, rows = 1, maxHeight, className, inputClassName }: TextareaFieldProps) {
17
24
  const fieldCtx = useContext(FormFieldContext);
18
25
  const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
19
26
  const ref = useRef<HTMLDivElement>(null);
@@ -66,6 +73,7 @@ export function TextareaField({ label, placeholder, value, onChange, rows = 1, m
66
73
  "whitespace-pre-wrap break-words",
67
74
  maxHeight && "overflow-y-auto",
68
75
  "empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none",
76
+ inputClassName,
69
77
  )}
70
78
  style={{
71
79
  minHeight,
@@ -28,6 +28,13 @@ export interface SuggestionListProps<T> {
28
28
  placeholder?: string;
29
29
  max?: number;
30
30
  initialCloud?: InitialCloudConfig;
31
+ /**
32
+ * Po dodaniu itemu (z sugestii, custom lub cloudu) ustaw focus na pierwszy
33
+ * fokusowalny input/textarea/contenteditable wewnątrz nowo wyrenderowanego
34
+ * elementu i zamknij dropdown wyszukiwania. Przydatne gdy `renderItem`
35
+ * zawiera pole tekstowe (np. URL kanału) i user ma od razu wpisywać dane.
36
+ */
37
+ focusItemOnAdd?: boolean;
31
38
  }
32
39
 
33
40
  const defaultGetKey = <T,>(item: T) => String(item);
@@ -49,12 +56,16 @@ export function SuggestionList<T>({
49
56
  placeholder = "Wyszukaj...",
50
57
  max,
51
58
  initialCloud,
59
+ focusItemOnAdd = false,
52
60
  }: SuggestionListProps<T>) {
53
61
  const [inputValue, setInputValue] = useState("");
54
62
  const [isEditing, setIsEditing] = useState(false);
55
63
  const [activeIndex, setActiveIndex] = useState(0);
56
64
  // Once user interacts (adds/removes or clicks "more"), we leave cloud mode permanently
57
65
  const [cloudDismissed, setCloudDismissed] = useState(false);
66
+ // Index świeżo dodanego itemu — wyzwala useEffect który ustawia focus na
67
+ // pierwszy input w jego wrapperze (tylko gdy focusItemOnAdd === true).
68
+ const [pendingFocusIndex, setPendingFocusIndex] = useState<number | null>(null);
58
69
 
59
70
  const containerRef = useRef<HTMLDivElement>(null);
60
71
  const inputRef = useRef<HTMLInputElement>(null);
@@ -112,16 +123,42 @@ export function SuggestionList<T>({
112
123
  }
113
124
  }, [activeIndex, isEditing]);
114
125
 
126
+ // Auto-focus pierwszego inputa w nowo dodanym itemie. Effect odpala się po
127
+ // commit (DOM gotowy) — szukamy wrappera po data-suggestion-item-index i
128
+ // pierwszego focusable child. requestAnimationFrame daje pewność że motion
129
+ // ukończył mount (item rozwija się z height 0).
130
+ useEffect(() => {
131
+ if (pendingFocusIndex === null) return;
132
+ if (!containerRef.current) return;
133
+ const target = pendingFocusIndex;
134
+ setPendingFocusIndex(null);
135
+ requestAnimationFrame(() => {
136
+ const wrapper = containerRef.current?.querySelector<HTMLElement>(
137
+ `[data-suggestion-item-index="${target}"]`,
138
+ );
139
+ const input = wrapper?.querySelector<HTMLElement>(
140
+ "input, textarea, [contenteditable='true']",
141
+ );
142
+ input?.focus();
143
+ });
144
+ }, [pendingFocusIndex, safeItems.length]);
145
+
115
146
  const addItem = useCallback(
116
147
  (item: T) => {
117
148
  if (max && safeItems.length >= max) return;
149
+ const newIndex = safeItems.length;
118
150
  onChange([...safeItems, item]);
119
151
  setInputValue("");
120
152
  setActiveIndex(0);
121
153
  setCloudDismissed(true);
122
- requestAnimationFrame(() => inputRef.current?.focus());
154
+ if (focusItemOnAdd) {
155
+ setIsEditing(false);
156
+ setPendingFocusIndex(newIndex);
157
+ } else {
158
+ requestAnimationFrame(() => inputRef.current?.focus());
159
+ }
123
160
  },
124
- [safeItems, onChange, max],
161
+ [safeItems, onChange, max, focusItemOnAdd],
125
162
  );
126
163
 
127
164
  const removeItemByIndex = useCallback(
@@ -213,14 +250,20 @@ export function SuggestionList<T>({
213
250
  const addFromCloud = useCallback(
214
251
  (item: T) => {
215
252
  if (max && safeItems.length >= max) return;
253
+ const newIndex = safeItems.length;
216
254
  onChange([...safeItems, item]);
217
255
  setInputValue("");
218
256
  setActiveIndex(0);
219
257
  setCloudDismissed(true);
220
- setIsEditing(true);
221
- requestAnimationFrame(() => inputRef.current?.focus());
258
+ if (focusItemOnAdd) {
259
+ setIsEditing(false);
260
+ setPendingFocusIndex(newIndex);
261
+ } else {
262
+ setIsEditing(true);
263
+ requestAnimationFrame(() => inputRef.current?.focus());
264
+ }
222
265
  },
223
- [safeItems, onChange, max],
266
+ [safeItems, onChange, max, focusItemOnAdd],
224
267
  );
225
268
 
226
269
  // ── Cloud view ──────────────────────────────────────────────
@@ -301,6 +344,7 @@ export function SuggestionList<T>({
301
344
  {safeItems.map((item, index) => (
302
345
  <motion.div
303
346
  key={`${getKey(item)}-${index}`}
347
+ data-suggestion-item-index={index}
304
348
  initial={{ height: 0, opacity: 0 }}
305
349
  animate={{ height: "auto", opacity: 1 }}
306
350
  exit={{ height: 0, opacity: 0 }}
package/src/index.ts CHANGED
@@ -110,6 +110,7 @@ export { Chat } from "./ds/chat/chat";
110
110
  export type { ChatProps } from "./ds/chat/chat";
111
111
  export { ChatMessage } from "./ds/chat/chat-message";
112
112
  export { ChatInput } from "./ds/chat/chat-input";
113
+ export type { ChatInputTextareaSlotProps } from "./ds/chat/chat-input";
113
114
  export { QuestionTabs } from "./ds/chat/question-tabs";
114
115
  export { ToolUseBlock } from "./ds/chat/tool-use-block";
115
116
  export { ChatToolLog } from "./ds/chat/chat-tool-log";