@arcote.tech/arc-ds 0.5.5 → 0.5.7

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.5.5",
4
+ "version": "0.5.7",
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",
@@ -25,10 +25,12 @@
25
25
  "dependencies": {
26
26
  "class-variance-authority": "^0.7.1",
27
27
  "clsx": "^2.1.1",
28
+ "react-markdown": "^10.1.0",
29
+ "remark-gfm": "^4.0.1",
28
30
  "tailwind-merge": "^3.5.0"
29
31
  },
30
32
  "peerDependencies": {
31
- "@arcote.tech/arc": "^0.5.5",
33
+ "@arcote.tech/arc": "^0.5.7",
32
34
  "framer-motion": "^12.0.0",
33
35
  "lucide-react": ">=0.400.0",
34
36
  "radix-ui": "^1.0.0",
@@ -21,6 +21,8 @@ export const buttonVariants = cva(
21
21
  "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
22
  outline:
23
23
  "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
24
+ "outline-dashed":
25
+ "border border-dashed border-border/60 bg-transparent text-muted-foreground hover:border-border hover:text-foreground",
24
26
  destructive: "bg-destructive text-white hover:bg-destructive/90",
25
27
  link: "text-primary underline-offset-4 hover:underline",
26
28
  },
@@ -1,3 +1,5 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
1
3
  import { Button } from "../button/button";
2
4
  import { Bot, User, MessageSquare } from "lucide-react";
3
5
  import type { ChatMessageData } from "./types";
@@ -46,18 +48,11 @@ export function ChatMessage({
46
48
  : "bg-card border border-border rounded-tl-sm"
47
49
  }`}
48
50
  >
49
- {message.content.split("\n").map((line, i) => (
50
- <p key={i} className={i > 0 ? "mt-1.5" : ""}>
51
- {line.startsWith("• ") ? (
52
- <span className="flex items-start gap-1.5 text-left">
53
- <span className="text-primary mt-px">•</span>
54
- <span>{line.slice(2)}</span>
55
- </span>
56
- ) : (
57
- line
58
- )}
59
- </p>
60
- ))}
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">
52
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
53
+ {message.content}
54
+ </ReactMarkdown>
55
+ </div>
61
56
  {message.isStreaming && (
62
57
  <span className="inline-block w-1.5 h-4 bg-foreground/60 animate-pulse ml-0.5 -mb-0.5" />
63
58
  )}
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { CheckCircle2, Loader2, AlertCircle } from "lucide-react";
2
+ import { CheckCircle2, Loader2, AlertCircle, ArrowUpRight } from "lucide-react";
3
3
 
4
4
  export interface ChatToolLogProps {
5
5
  /** Tool label displayed in the header */
@@ -12,6 +12,13 @@ export interface ChatToolLogProps {
12
12
  icon?: string;
13
13
  /** Additional details rendered below the label */
14
14
  children?: ReactNode;
15
+ /**
16
+ * Optional click handler. When provided, the log becomes interactive
17
+ * (button) and shows a navigation indicator. Useful for jumping to
18
+ * the underlying domain object (e.g. the strategy page that the tool
19
+ * just mutated).
20
+ */
21
+ onClick?: () => void;
15
22
  }
16
23
 
17
24
  export function ChatToolLog({
@@ -19,6 +26,7 @@ export function ChatToolLog({
19
26
  calling,
20
27
  error,
21
28
  children,
29
+ onClick,
22
30
  }: ChatToolLogProps) {
23
31
  const hasError = !!error;
24
32
 
@@ -40,9 +48,17 @@ export function ChatToolLog({
40
48
  ? "text-blue-700 dark:text-blue-400"
41
49
  : "text-green-700 dark:text-green-400";
42
50
 
51
+ const isClickable = !!onClick && !calling && !hasError;
52
+ const Component = isClickable ? "button" : "div";
53
+ const interactiveCls = isClickable
54
+ ? "cursor-pointer hover:bg-green-500/10 text-left"
55
+ : "";
56
+
43
57
  return (
44
- <div
45
- className={`flex items-start gap-3 w-full rounded-xl border p-3 transition-colors ${borderColor}`}
58
+ <Component
59
+ type={isClickable ? "button" : undefined}
60
+ onClick={isClickable ? onClick : undefined}
61
+ className={`flex items-start gap-3 w-full rounded-xl border p-3 transition-colors ${borderColor} ${interactiveCls}`}
46
62
  >
47
63
  <div className={`mt-0.5 shrink-0 ${iconColor}`}>
48
64
  {hasError ? (
@@ -66,6 +82,9 @@ export function ChatToolLog({
66
82
  </div>
67
83
  )}
68
84
  </div>
69
- </div>
85
+ {isClickable && (
86
+ <ArrowUpRight className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
87
+ )}
88
+ </Component>
70
89
  );
71
90
  }
@@ -0,0 +1,33 @@
1
+ import { cva } from "class-variance-authority";
2
+ import { forwardRef, type HTMLAttributes } from "react";
3
+ import { cn } from "../../lib/utils";
4
+ import { useDsVariantOverrides } from "../ds-provider";
5
+
6
+ export const containerVariants = cva("mx-auto w-full max-w-screen-2xl");
7
+
8
+ export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {}
9
+
10
+ /**
11
+ * Container — szerokościowy wrapper dla głównego contentu Layoutu.
12
+ *
13
+ * Domyślnie `max-w-screen-2xl` (1536px). Aplikacje, które chcą węziej,
14
+ * nadpisują przez `arc.variants({ Container: { className: { default: "max-w-5xl" } } })`.
15
+ */
16
+ export const Container = forwardRef<HTMLDivElement, ContainerProps>(
17
+ ({ className, children, ...rest }, ref) => {
18
+ const overrides = useDsVariantOverrides("Container");
19
+ const extraClass = overrides?.className?.default;
20
+
21
+ return (
22
+ <div
23
+ ref={ref}
24
+ className={cn(containerVariants(), extraClass, className)}
25
+ {...rest}
26
+ >
27
+ {children}
28
+ </div>
29
+ );
30
+ },
31
+ );
32
+
33
+ Container.displayName = "Container";
@@ -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>
@@ -10,33 +10,67 @@ export interface SearchSelectOption {
10
10
  icon?: ReactNode;
11
11
  }
12
12
 
13
- export interface SearchSelectProps {
14
- value?: string;
15
- onChange?: (value: string) => void;
13
+ export interface SearchSelectTriggerProps {
14
+ selectedOption: SearchSelectOption | undefined;
15
+ placeholder: string;
16
+ open: () => void;
17
+ }
18
+
19
+ export interface SearchSelectMultiTriggerProps {
20
+ selectedOptions: SearchSelectOption[];
21
+ placeholder: string;
22
+ open: () => void;
23
+ }
24
+
25
+ // ─── Props union ───────────────────────────────────────────────
26
+
27
+ interface SearchSelectBaseProps {
16
28
  options: SearchSelectOption[];
17
29
  placeholder?: string;
18
30
  searchPlaceholder?: string;
19
31
  position?: "relative" | "absolute";
20
- /** Direction the dropdown opens. Default: "down". */
21
32
  direction?: "down" | "up";
22
- /** Trigger button size. Default: "default". */
23
33
  size?: "default" | "sm";
24
- renderOption?: (option: SearchSelectOption, isActive: boolean, isSelected: boolean) => ReactNode;
34
+ renderOption?: (
35
+ option: SearchSelectOption,
36
+ isActive: boolean,
37
+ isSelected: boolean,
38
+ ) => ReactNode;
25
39
  allowClear?: boolean;
26
40
  }
27
41
 
28
- export function SearchSelect({
29
- value,
30
- onChange,
31
- options,
32
- placeholder = "Wybierz...",
33
- searchPlaceholder = "Szukaj...",
34
- position = "relative",
35
- direction = "down",
36
- size = "default",
37
- renderOption,
38
- allowClear = true,
39
- }: SearchSelectProps) {
42
+ interface SearchSelectSingleProps extends SearchSelectBaseProps {
43
+ multiple?: false;
44
+ value?: string;
45
+ onChange?: (value: string) => void;
46
+ renderTrigger?: (props: SearchSelectTriggerProps) => ReactNode;
47
+ }
48
+
49
+ interface SearchSelectMultiProps extends SearchSelectBaseProps {
50
+ multiple: true;
51
+ value?: string[];
52
+ onChange?: (value: string[]) => void;
53
+ renderTrigger?: (props: SearchSelectMultiTriggerProps) => ReactNode;
54
+ }
55
+
56
+ export type SearchSelectProps = SearchSelectSingleProps | SearchSelectMultiProps;
57
+
58
+ // ─── Component ─────────────────────────────────────────────────
59
+
60
+ export function SearchSelect(props: SearchSelectProps) {
61
+ const {
62
+ options,
63
+ placeholder = "Wybierz...",
64
+ searchPlaceholder = "Szukaj...",
65
+ position = "relative",
66
+ direction = "down",
67
+ size = "default",
68
+ renderOption,
69
+ allowClear = true,
70
+ } = props;
71
+
72
+ const isMulti = props.multiple === true;
73
+
40
74
  const [isOpen, setIsOpen] = useState(false);
41
75
  const [query, setQuery] = useState("");
42
76
  const [activeIndex, setActiveIndex] = useState(0);
@@ -44,11 +78,29 @@ export function SearchSelect({
44
78
  const containerRef = useRef<HTMLDivElement>(null);
45
79
  const inputRef = useRef<HTMLInputElement>(null);
46
80
 
81
+ // ─── Value helpers ──────────────────────────────────────────
82
+
83
+ const singleValue = !isMulti ? (props.value ?? "") : "";
84
+ const multiValue = isMulti ? (props.value ?? []) : [];
85
+
47
86
  const selectedOption = useMemo(
48
- () => options.find((o) => o.value === value),
49
- [options, value],
87
+ () => (!isMulti ? options.find((o) => o.value === singleValue) : undefined),
88
+ [options, singleValue, isMulti],
89
+ );
90
+
91
+ const selectedOptions = useMemo(
92
+ () => (isMulti ? options.filter((o) => multiValue.includes(o.value)) : []),
93
+ [options, multiValue, isMulti],
94
+ );
95
+
96
+ const isSelected = useCallback(
97
+ (val: string) =>
98
+ isMulti ? multiValue.includes(val) : singleValue === val,
99
+ [isMulti, singleValue, multiValue],
50
100
  );
51
101
 
102
+ // ─── Filtering ──────────────────────────────────────────────
103
+
52
104
  const filtered = useMemo(() => {
53
105
  if (!query.trim()) return options;
54
106
  const q = query.toLowerCase().trim();
@@ -56,14 +108,19 @@ export function SearchSelect({
56
108
  }, [options, query]);
57
109
 
58
110
  useEffect(() => {
59
- setActiveIndex((prev) => (filtered.length === 0 ? 0 : Math.min(prev, filtered.length - 1)));
111
+ setActiveIndex((prev) =>
112
+ filtered.length === 0 ? 0 : Math.min(prev, filtered.length - 1),
113
+ );
60
114
  }, [filtered.length]);
61
115
 
62
116
  // Click outside
63
117
  useEffect(() => {
64
118
  if (!isOpen) return;
65
119
  const handler = (e: MouseEvent) => {
66
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
120
+ if (
121
+ containerRef.current &&
122
+ !containerRef.current.contains(e.target as Node)
123
+ ) {
67
124
  setIsOpen(false);
68
125
  setQuery("");
69
126
  }
@@ -79,42 +136,81 @@ export function SearchSelect({
79
136
  if (el) el.scrollIntoView({ block: "nearest" });
80
137
  }, [activeIndex, isOpen]);
81
138
 
82
- const select = useCallback(
139
+ // ─── Actions ────────────────────────────────────────────────
140
+
141
+ const selectSingle = useCallback(
142
+ (val: string) => {
143
+ if (!isMulti) {
144
+ (props as SearchSelectSingleProps).onChange?.(val);
145
+ setIsOpen(false);
146
+ setQuery("");
147
+ }
148
+ },
149
+ [isMulti, props],
150
+ );
151
+
152
+ const toggleMulti = useCallback(
153
+ (val: string) => {
154
+ if (isMulti) {
155
+ const current = (props as SearchSelectMultiProps).value ?? [];
156
+ const next = current.includes(val)
157
+ ? current.filter((v) => v !== val)
158
+ : [...current, val];
159
+ (props as SearchSelectMultiProps).onChange?.(next);
160
+ // Keep dropdown open in multi mode
161
+ }
162
+ },
163
+ [isMulti, props],
164
+ );
165
+
166
+ const handleOptionClick = useCallback(
83
167
  (val: string) => {
84
- onChange?.(val);
85
- setIsOpen(false);
86
- setQuery("");
168
+ if (isMulti) {
169
+ toggleMulti(val);
170
+ } else {
171
+ selectSingle(val);
172
+ }
87
173
  },
88
- [onChange],
174
+ [isMulti, selectSingle, toggleMulti],
89
175
  );
90
176
 
91
177
  const clear = useCallback(() => {
92
- onChange?.("");
178
+ if (isMulti) {
179
+ (props as SearchSelectMultiProps).onChange?.([]);
180
+ } else {
181
+ (props as SearchSelectSingleProps).onChange?.("");
182
+ }
93
183
  setIsOpen(false);
94
184
  setQuery("");
95
- }, [onChange]);
185
+ }, [isMulti, props]);
96
186
 
97
- const open = () => {
187
+ const open = useCallback(() => {
98
188
  setIsOpen(true);
99
189
  setActiveIndex(0);
100
190
  setQuery("");
101
191
  requestAnimationFrame(() => inputRef.current?.focus());
102
- };
192
+ }, []);
103
193
 
104
194
  const handleKeyDown = (e: React.KeyboardEvent) => {
105
195
  switch (e.key) {
106
196
  case "ArrowDown":
107
197
  e.preventDefault();
108
- setActiveIndex((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length));
198
+ setActiveIndex((prev) =>
199
+ filtered.length === 0 ? 0 : (prev + 1) % filtered.length,
200
+ );
109
201
  break;
110
202
  case "ArrowUp":
111
203
  e.preventDefault();
112
- setActiveIndex((prev) => (filtered.length === 0 ? 0 : (prev - 1 + filtered.length) % filtered.length));
204
+ setActiveIndex((prev) =>
205
+ filtered.length === 0
206
+ ? 0
207
+ : (prev - 1 + filtered.length) % filtered.length,
208
+ );
113
209
  break;
114
210
  case "Enter":
115
211
  e.preventDefault();
116
212
  if (filtered.length > 0 && activeIndex < filtered.length) {
117
- select(filtered[activeIndex].value);
213
+ handleOptionClick(filtered[activeIndex].value);
118
214
  }
119
215
  break;
120
216
  case "Escape":
@@ -125,13 +221,23 @@ export function SearchSelect({
125
221
  }
126
222
  };
127
223
 
128
- const defaultRenderOption = (opt: SearchSelectOption, isActive: boolean, isSelected: boolean) => (
224
+ // ─── Render helpers ─────────────────────────────────────────
225
+
226
+ const defaultRenderOption = (
227
+ opt: SearchSelectOption,
228
+ isActive: boolean,
229
+ isSel: boolean,
230
+ ) => (
129
231
  <div className="flex items-center gap-2.5">
130
- {opt.icon && <span className="shrink-0 text-muted-foreground">{opt.icon}</span>}
131
- <span className={`flex-1 truncate text-sm ${isActive ? "text-primary font-medium" : ""}`}>
232
+ {opt.icon && (
233
+ <span className="shrink-0 text-muted-foreground">{opt.icon}</span>
234
+ )}
235
+ <span
236
+ className={`flex-1 truncate text-sm ${isActive ? "font-medium text-primary" : ""}`}
237
+ >
132
238
  {opt.label}
133
239
  </span>
134
- {isSelected && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
240
+ {isSel && <Check className="h-3.5 w-3.5 shrink-0 text-primary" />}
135
241
  </div>
136
242
  );
137
243
 
@@ -139,100 +245,174 @@ export function SearchSelect({
139
245
 
140
246
  const isAbsolute = position === "absolute";
141
247
  const isUp = direction === "up";
142
- const triggerHeight = size === "sm" ? "h-8 text-xs px-2.5" : "h-10 md:h-9 text-base md:text-sm px-3";
248
+ const triggerHeight =
249
+ size === "sm" ? "h-8 text-xs px-2.5" : "h-10 md:h-9 text-base md:text-sm px-3";
250
+
251
+ const hasValue = isMulti ? multiValue.length > 0 : !!singleValue;
252
+
253
+ // ─── Trigger ────────────────────────────────────────────────
254
+
255
+ const renderDefaultTrigger = () => (
256
+ <button
257
+ type="button"
258
+ onClick={open}
259
+ className={`flex w-full items-center justify-between rounded-md border border-input bg-transparent ${triggerHeight} shadow-xs transition-colors hover:bg-muted/50`}
260
+ >
261
+ {isMulti ? (
262
+ selectedOptions.length > 0 ? (
263
+ <span className="truncate">
264
+ {selectedOptions.map((o) => o.label).join(", ")}
265
+ </span>
266
+ ) : (
267
+ <span className="text-muted-foreground">{placeholder}</span>
268
+ )
269
+ ) : selectedOption ? (
270
+ <div className="flex min-w-0 items-center gap-2">
271
+ {selectedOption.icon && (
272
+ <span className="shrink-0">{selectedOption.icon}</span>
273
+ )}
274
+ <span className="truncate">{selectedOption.label}</span>
275
+ </div>
276
+ ) : (
277
+ <span className="text-muted-foreground">{placeholder}</span>
278
+ )}
279
+ <div className="flex shrink-0 items-center gap-1">
280
+ {allowClear && hasValue && (
281
+ <span
282
+ role="button"
283
+ onClick={(e) => {
284
+ e.stopPropagation();
285
+ clear();
286
+ }}
287
+ className="rounded-full p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
288
+ >
289
+ <X className="h-3 w-3" />
290
+ </span>
291
+ )}
292
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
293
+ </div>
294
+ </button>
295
+ );
296
+
297
+ const renderCustomTrigger = () => {
298
+ if (isMulti && (props as SearchSelectMultiProps).renderTrigger) {
299
+ return (props as SearchSelectMultiProps).renderTrigger!({
300
+ selectedOptions,
301
+ placeholder,
302
+ open,
303
+ });
304
+ }
305
+ if (!isMulti && (props as SearchSelectSingleProps).renderTrigger) {
306
+ return (props as SearchSelectSingleProps).renderTrigger!({
307
+ selectedOption,
308
+ placeholder,
309
+ open,
310
+ });
311
+ }
312
+ return null;
313
+ };
314
+
315
+ const hasCustomTrigger = isMulti
316
+ ? !!(props as SearchSelectMultiProps).renderTrigger
317
+ : !!(props as SearchSelectSingleProps).renderTrigger;
318
+
319
+ // ─── Option list (shared between relative and absolute) ─────
320
+
321
+ const renderOptionList = () => (
322
+ <>
323
+ {filtered.length > 0 && (
324
+ <div className="max-h-[192px] overflow-y-auto border-t border-border">
325
+ {filtered.map((opt, i) => (
326
+ <button
327
+ key={opt.value}
328
+ type="button"
329
+ data-active={i === activeIndex}
330
+ onClick={() => handleOptionClick(opt.value)}
331
+ onMouseEnter={() => setActiveIndex(i)}
332
+ className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
333
+ i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
334
+ }`}
335
+ >
336
+ {optionRenderer(opt, i === activeIndex, isSelected(opt.value))}
337
+ </button>
338
+ ))}
339
+ </div>
340
+ )}
341
+ {filtered.length === 0 && query && (
342
+ <div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
343
+ Brak wyników
344
+ </div>
345
+ )}
346
+ </>
347
+ );
348
+
349
+ const renderSearchInput = () => (
350
+ <div className="p-1.5">
351
+ <Input
352
+ ref={inputRef}
353
+ icon={Search}
354
+ size="sm"
355
+ value={query}
356
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
357
+ setQuery(e.target.value);
358
+ setActiveIndex(0);
359
+ }}
360
+ onKeyDown={handleKeyDown}
361
+ placeholder={searchPlaceholder}
362
+ className="border-0 shadow-none focus-visible:border-transparent focus-visible:ring-0"
363
+ />
364
+ </div>
365
+ );
143
366
 
144
367
  return (
145
368
  <div ref={containerRef} className={isAbsolute ? "relative" : ""}>
146
369
  {/* Trigger */}
147
370
  {!isOpen ? (
148
- <button
149
- type="button"
150
- onClick={open}
151
- className={`flex w-full items-center justify-between rounded-md border border-input bg-transparent ${triggerHeight} shadow-xs transition-colors hover:bg-muted/50`}
152
- >
153
- {selectedOption ? (
154
- <div className="flex items-center gap-2 min-w-0">
155
- {selectedOption.icon && <span className="shrink-0">{selectedOption.icon}</span>}
156
- <span className="truncate">{selectedOption.label}</span>
157
- </div>
158
- ) : (
159
- <span className="text-muted-foreground">{placeholder}</span>
160
- )}
161
- <div className="flex items-center gap-1 shrink-0">
162
- {allowClear && value && (
163
- <span
164
- role="button"
165
- onClick={(e) => {
166
- e.stopPropagation();
167
- clear();
168
- }}
169
- className="rounded-full p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors"
170
- >
171
- <X className="h-3 w-3" />
172
- </span>
173
- )}
174
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
175
- </div>
176
- </button>
371
+ hasCustomTrigger ? (
372
+ renderCustomTrigger()
373
+ ) : (
374
+ renderDefaultTrigger()
375
+ )
177
376
  ) : !isAbsolute ? (
178
377
  /* Inline search — relative mode */
179
- <div className="rounded-md border border-input overflow-hidden">
180
- <div className="p-1.5">
181
- <Input
182
- ref={inputRef}
183
- icon={Search}
184
- size="sm"
185
- value={query}
186
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
187
- setQuery(e.target.value);
188
- setActiveIndex(0);
189
- }}
190
- onKeyDown={handleKeyDown}
191
- placeholder={searchPlaceholder}
192
- className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
193
- />
194
- </div>
195
- {filtered.length > 0 && (
196
- <div className="border-t border-border max-h-[192px] overflow-y-auto">
197
- {filtered.map((opt, i) => (
198
- <button
199
- key={opt.value}
200
- type="button"
201
- data-active={i === activeIndex}
202
- onClick={() => select(opt.value)}
203
- onMouseEnter={() => setActiveIndex(i)}
204
- className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
205
- i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
206
- }`}
207
- >
208
- {optionRenderer(opt, i === activeIndex, opt.value === value)}
209
- </button>
210
- ))}
211
- </div>
212
- )}
213
- {filtered.length === 0 && query && (
214
- <div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
215
- Brak wyników
216
- </div>
217
- )}
378
+ <div className="overflow-hidden rounded-md border border-input">
379
+ {renderSearchInput()}
380
+ {renderOptionList()}
218
381
  </div>
219
382
  ) : (
220
383
  /* Trigger stays visible in absolute mode */
221
- <button
222
- type="button"
223
- onClick={() => { setIsOpen(false); setQuery(""); }}
224
- className={`flex w-full items-center justify-between rounded-md border border-ring bg-transparent ${triggerHeight} shadow-xs ring-[3px] ring-ring/50`}
225
- >
226
- {selectedOption ? (
227
- <div className="flex items-center gap-2 min-w-0">
228
- {selectedOption.icon && <span className="shrink-0">{selectedOption.icon}</span>}
229
- <span className="truncate">{selectedOption.label}</span>
230
- </div>
231
- ) : (
232
- <span className="text-muted-foreground">{placeholder}</span>
233
- )}
234
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground rotate-180 transition-transform" />
235
- </button>
384
+ hasCustomTrigger ? (
385
+ renderCustomTrigger()
386
+ ) : (
387
+ <button
388
+ type="button"
389
+ onClick={() => {
390
+ setIsOpen(false);
391
+ setQuery("");
392
+ }}
393
+ className={`flex w-full items-center justify-between rounded-md border border-ring bg-transparent ${triggerHeight} shadow-xs ring-[3px] ring-ring/50`}
394
+ >
395
+ {isMulti ? (
396
+ selectedOptions.length > 0 ? (
397
+ <span className="truncate">
398
+ {selectedOptions.map((o) => o.label).join(", ")}
399
+ </span>
400
+ ) : (
401
+ <span className="text-muted-foreground">{placeholder}</span>
402
+ )
403
+ ) : selectedOption ? (
404
+ <div className="flex min-w-0 items-center gap-2">
405
+ {selectedOption.icon && (
406
+ <span className="shrink-0">{selectedOption.icon}</span>
407
+ )}
408
+ <span className="truncate">{selectedOption.label}</span>
409
+ </div>
410
+ ) : (
411
+ <span className="text-muted-foreground">{placeholder}</span>
412
+ )}
413
+ <ChevronDown className="h-3.5 w-3.5 rotate-180 text-muted-foreground transition-transform" />
414
+ </button>
415
+ )
236
416
  )}
237
417
 
238
418
  {/* Absolute dropdown */}
@@ -243,46 +423,10 @@ export function SearchSelect({
243
423
  animate={{ opacity: 1, y: 0 }}
244
424
  exit={{ opacity: 0, y: isUp ? 4 : -4 }}
245
425
  transition={{ duration: 0.12 }}
246
- className={`absolute left-0 right-0 z-50 rounded-md border border-input bg-card shadow-lg overflow-hidden ${isUp ? "bottom-full mb-1" : "mt-1"}`}
426
+ className={`absolute left-0 right-0 z-50 overflow-hidden rounded-md border border-input bg-card shadow-lg ${isUp ? "bottom-full mb-1" : "mt-1"}`}
247
427
  >
248
- <div className="p-1.5">
249
- <Input
250
- ref={inputRef}
251
- icon={Search}
252
- size="sm"
253
- value={query}
254
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
255
- setQuery(e.target.value);
256
- setActiveIndex(0);
257
- }}
258
- onKeyDown={handleKeyDown}
259
- placeholder={searchPlaceholder}
260
- className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
261
- />
262
- </div>
263
- {filtered.length > 0 && (
264
- <div className="border-t border-border max-h-[192px] overflow-y-auto">
265
- {filtered.map((opt, i) => (
266
- <button
267
- key={opt.value}
268
- type="button"
269
- data-active={i === activeIndex}
270
- onClick={() => select(opt.value)}
271
- onMouseEnter={() => setActiveIndex(i)}
272
- className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
273
- i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
274
- }`}
275
- >
276
- {optionRenderer(opt, i === activeIndex, opt.value === value)}
277
- </button>
278
- ))}
279
- </div>
280
- )}
281
- {filtered.length === 0 && query && (
282
- <div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
283
- Brak wyników
284
- </div>
285
- )}
428
+ {renderSearchInput()}
429
+ {renderOptionList()}
286
430
  </motion.div>
287
431
  )}
288
432
  </AnimatePresence>
package/src/ds/types.ts CHANGED
@@ -19,6 +19,7 @@ export interface DSComponentMap {
19
19
  Separator: ComponentType<SeparatorProps>;
20
20
  Avatar: ComponentType<AvatarProps>;
21
21
  Input: ComponentType<InputProps>;
22
+ Container: ComponentType<ContainerProps>;
22
23
  }
23
24
 
24
25
  /** Partial override — moduł nadpisuje tylko wybrane komponenty. */
@@ -50,6 +51,7 @@ export interface ButtonProps {
50
51
  | "secondary"
51
52
  | "ghost"
52
53
  | "outline"
54
+ | "outline-dashed"
53
55
  | "destructive"
54
56
  | "link";
55
57
  size?: "default" | "sm" | "xs" | "lg" | "icon" | "icon-sm" | "icon-xs";
@@ -129,3 +131,12 @@ export interface BoxProps {
129
131
  className?: string;
130
132
  children?: ReactNode;
131
133
  }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Container
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export interface ContainerProps {
140
+ className?: string;
141
+ children?: ReactNode;
142
+ }
package/src/index.ts CHANGED
@@ -63,10 +63,14 @@ export type { TagListProps } from "./ds/tag-list/tag-list";
63
63
  export { SuggestionList } from "./ds/suggestion-list/suggestion-list";
64
64
  export type { SuggestionListProps, InitialCloudConfig } from "./ds/suggestion-list/suggestion-list";
65
65
  export { SearchSelect } from "./ds/search-select/search-select";
66
- export type { SearchSelectProps, SearchSelectOption } from "./ds/search-select/search-select";
66
+ export type { SearchSelectProps, SearchSelectOption, SearchSelectTriggerProps, SearchSelectMultiTriggerProps } from "./ds/search-select/search-select";
67
+ export { EditableText } from "./ds/editable-text/editable-text";
68
+ export type { EditableTextProps } from "./ds/editable-text/editable-text";
67
69
  export { Badge, badgeVariants } from "./ds/badge/badge";
68
70
  export { Box, boxVariants } from "./ds/box/box";
69
71
  export type { BoxProps } from "./ds/box/box";
72
+ export { Container, containerVariants } from "./ds/container/container";
73
+ export type { ContainerProps } from "./ds/container/container";
70
74
  export { Sidebar, sidebarVariants } from "./ds/sidebar/sidebar";
71
75
  export type { SidebarProps } from "./ds/sidebar/sidebar";
72
76
  export {
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Box } from "../ds/box/box";
4
4
  import { Button } from "../ds/button/button";
5
+ import { Container } from "../ds/container/container";
5
6
  import { DragHandle } from "./drag-handle";
6
7
  import { useDynamicSlotContent } from "./dynamic-slot";
7
8
  import { ExpandablePanel } from "./expandable-panel";
@@ -124,18 +125,20 @@ function DesktopLayout({ children }: { children?: ReactNode }) {
124
125
  <SubNavSlot />
125
126
  <div className="flex w-full flex-1">
126
127
  <div className="flex min-w-0 flex-1 justify-center">
127
- <div className="flex w-full max-w-5xl gap-4 p-4">
128
+ <Container className="flex gap-4 p-4">
128
129
  {renderSlot("sidebar-left", { className: "flex w-64 shrink-0 flex-col gap-4" })}
129
130
  <div className="min-w-0 flex-1">
130
131
  {children}
131
132
  {renderSlot("main-content", { className: "flex flex-col gap-4" })}
132
133
  </div>
133
134
  {renderSlot("sidebar-right", { className: "flex w-64 shrink-0 flex-col gap-4" })}
134
- </div>
135
+ </Container>
135
136
  </div>
136
137
  {renderSlot("preview-pane", {
137
138
  className:
138
- "flex w-[480px] shrink-0 flex-col gap-4 overflow-y-auto border-l p-4",
139
+ "flex w-[480px] shrink-0 flex-col gap-4 overflow-y-auto border-l p-4 " +
140
+ "sticky top-[var(--arc-toolbar-height,5rem)] " +
141
+ "max-h-[calc(100vh-var(--arc-toolbar-height,5rem))]",
139
142
  })}
140
143
  </div>
141
144
  </>