canopy-ui 0.2.0 → 0.4.0
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 +20 -1
- package/src/chat/ChatPanel.tsx +140 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +95 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.tsx +144 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +66 -0
- package/src/chat/drafts.ts +19 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +208 -0
- package/src/chat/pairToolMessages.ts +162 -0
- package/src/chat/protocol.ts +99 -0
- package/src/chat/sessionReducer.test.ts +284 -0
- package/src/chat/sessionReducer.ts +245 -0
- package/src/chat/useSessionSocket.ts +269 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/ui/AutoResizeTextarea.tsx +61 -0
- package/src/ui/dialog.tsx +131 -0
- package/src/ui/dropdown-menu.tsx +226 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/sonner.tsx +23 -0
- package/src/ui/tooltip.tsx +55 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
|
+
import { prependHistory } from "./history";
|
|
5
|
+
import { sessionReducer } from "./sessionReducer";
|
|
6
|
+
|
|
7
|
+
const HEARTBEAT_INTERVAL_MS = 20_000;
|
|
8
|
+
const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
|
|
9
|
+
const DRAFT_UPDATE_DEBOUNCE_MS = 150;
|
|
10
|
+
|
|
11
|
+
const INITIAL_STATE: SessionState = {
|
|
12
|
+
messages: [],
|
|
13
|
+
active_draft: null,
|
|
14
|
+
participants: [],
|
|
15
|
+
presence_user_ids: [],
|
|
16
|
+
current_user_id: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface UseSessionSocketOptions {
|
|
20
|
+
/** The chat session id (UUID string). */
|
|
21
|
+
sessionId: string;
|
|
22
|
+
/**
|
|
23
|
+
* App-injected WebSocket URL builder. The kit never imports app routing/base
|
|
24
|
+
* helpers; the container passes one (e.g. canopy's `wsUrl`). Called with the
|
|
25
|
+
* relative path `ws/canopy-sessions/${sessionId}/`.
|
|
26
|
+
*/
|
|
27
|
+
wsUrl: (path: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Optional side-effect callback fired when the server broadcasts a
|
|
30
|
+
* `session.title_updated` (replaces ace's `notifySessionsUpdated`). The kit
|
|
31
|
+
* has no opinion on what to do with it.
|
|
32
|
+
*/
|
|
33
|
+
onTitleUpdated?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseSessionSocketResult {
|
|
37
|
+
state: SessionState;
|
|
38
|
+
connected: boolean;
|
|
39
|
+
sendChat: () => void;
|
|
40
|
+
stopChat: (messageId: string) => void;
|
|
41
|
+
updateDraft: (body: string) => void;
|
|
42
|
+
takeOverDraft: () => void;
|
|
43
|
+
discardDraft: () => void;
|
|
44
|
+
prependMessages: (older: Message[]) => void;
|
|
45
|
+
lastError: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useSessionSocket({
|
|
49
|
+
sessionId,
|
|
50
|
+
wsUrl,
|
|
51
|
+
onTitleUpdated,
|
|
52
|
+
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
53
|
+
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
54
|
+
const [connected, setConnected] = useState(false);
|
|
55
|
+
const [lastError, setLastError] = useState<string | null>(null);
|
|
56
|
+
|
|
57
|
+
const socketRef = useRef<WebSocket | null>(null);
|
|
58
|
+
const stateRef = useRef<SessionState>(INITIAL_STATE);
|
|
59
|
+
const reconnectAttemptRef = useRef(0);
|
|
60
|
+
const heartbeatTimerRef = useRef<number | null>(null);
|
|
61
|
+
const draftDebounceRef = useRef<number | null>(null);
|
|
62
|
+
const pendingDraftBodyRef = useRef<string | null>(null);
|
|
63
|
+
const closedByUserRef = useRef(false);
|
|
64
|
+
const onTitleUpdatedRef = useRef(onTitleUpdated);
|
|
65
|
+
// Control frames that must not be lost across a reconnect (currently
|
|
66
|
+
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
67
|
+
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
stateRef.current = state;
|
|
71
|
+
}, [state]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
onTitleUpdatedRef.current = onTitleUpdated;
|
|
75
|
+
}, [onTitleUpdated]);
|
|
76
|
+
|
|
77
|
+
const send = useCallback((frame: { action: string; data: unknown }) => {
|
|
78
|
+
const ws = socketRef.current;
|
|
79
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
80
|
+
ws.send(JSON.stringify(frame));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Queue chat.stop so a stop clicked while the socket is reconnecting
|
|
84
|
+
// is delivered on next OPEN instead of silently dropped. Draft updates
|
|
85
|
+
// are intentionally NOT queued — they have a version guard and the
|
|
86
|
+
// user's next keystroke will refresh the body anyway.
|
|
87
|
+
if (frame.action === "chat.stop") {
|
|
88
|
+
pendingFramesRef.current.push(frame);
|
|
89
|
+
}
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const applyEvent = useCallback((frame: WsEvent) => {
|
|
93
|
+
// Side-effect events: handle BEFORE setState so React strict-mode's
|
|
94
|
+
// double-invocation of the updater doesn't double-fire the effect.
|
|
95
|
+
if (frame.event === "session.title_updated") {
|
|
96
|
+
onTitleUpdatedRef.current?.();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (frame.event === "session.error") {
|
|
100
|
+
setLastError(frame.data.message);
|
|
101
|
+
if (
|
|
102
|
+
frame.data.code === "draft_version_mismatch" &&
|
|
103
|
+
frame.data.detail &&
|
|
104
|
+
typeof frame.data.detail === "object"
|
|
105
|
+
) {
|
|
106
|
+
// Clear any pending optimistic body so the user's stale local
|
|
107
|
+
// text doesn't auto-re-send with the new version.
|
|
108
|
+
pendingDraftBodyRef.current = null;
|
|
109
|
+
if (draftDebounceRef.current != null) {
|
|
110
|
+
window.clearTimeout(draftDebounceRef.current);
|
|
111
|
+
draftDebounceRef.current = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
setState((prev) => sessionReducer(prev, frame));
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const connect = useCallback(() => {
|
|
119
|
+
if (closedByUserRef.current) return;
|
|
120
|
+
const ws = new WebSocket(wsUrl(`ws/canopy-sessions/${sessionId}/`));
|
|
121
|
+
socketRef.current = ws;
|
|
122
|
+
|
|
123
|
+
ws.onopen = () => {
|
|
124
|
+
setConnected(true);
|
|
125
|
+
reconnectAttemptRef.current = 0;
|
|
126
|
+
// Flush any control frames that were queued while the socket was
|
|
127
|
+
// closed. See `send` above.
|
|
128
|
+
const queued = pendingFramesRef.current;
|
|
129
|
+
pendingFramesRef.current = [];
|
|
130
|
+
for (const frame of queued) {
|
|
131
|
+
ws.send(JSON.stringify(frame));
|
|
132
|
+
}
|
|
133
|
+
if (heartbeatTimerRef.current != null) {
|
|
134
|
+
window.clearInterval(heartbeatTimerRef.current);
|
|
135
|
+
}
|
|
136
|
+
heartbeatTimerRef.current = window.setInterval(() => {
|
|
137
|
+
send({ action: "presence.heartbeat", data: {} });
|
|
138
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
ws.onmessage = (e) => {
|
|
142
|
+
try {
|
|
143
|
+
const frame = JSON.parse(e.data) as WsEvent;
|
|
144
|
+
applyEvent(frame);
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore malformed frames
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
ws.onclose = () => {
|
|
151
|
+
setConnected(false);
|
|
152
|
+
if (heartbeatTimerRef.current != null) {
|
|
153
|
+
window.clearInterval(heartbeatTimerRef.current);
|
|
154
|
+
heartbeatTimerRef.current = null;
|
|
155
|
+
}
|
|
156
|
+
if (closedByUserRef.current) return;
|
|
157
|
+
const attempt = reconnectAttemptRef.current;
|
|
158
|
+
const delay =
|
|
159
|
+
RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
160
|
+
reconnectAttemptRef.current = attempt + 1;
|
|
161
|
+
window.setTimeout(connect, delay);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
ws.onerror = () => {
|
|
165
|
+
// onclose will fire next; nothing to do here.
|
|
166
|
+
};
|
|
167
|
+
}, [applyEvent, send, sessionId, wsUrl]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
closedByUserRef.current = false;
|
|
171
|
+
reconnectAttemptRef.current = 0;
|
|
172
|
+
connect();
|
|
173
|
+
return () => {
|
|
174
|
+
closedByUserRef.current = true;
|
|
175
|
+
if (heartbeatTimerRef.current != null) {
|
|
176
|
+
window.clearInterval(heartbeatTimerRef.current);
|
|
177
|
+
}
|
|
178
|
+
if (socketRef.current) {
|
|
179
|
+
socketRef.current.close();
|
|
180
|
+
socketRef.current = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}, [connect]);
|
|
184
|
+
|
|
185
|
+
const sendChat = useCallback(() => {
|
|
186
|
+
// Flush any pending debounced update first so the committed draft
|
|
187
|
+
// carries the latest local body.
|
|
188
|
+
if (draftDebounceRef.current != null) {
|
|
189
|
+
window.clearTimeout(draftDebounceRef.current);
|
|
190
|
+
draftDebounceRef.current = null;
|
|
191
|
+
if (pendingDraftBodyRef.current != null && stateRef.current.active_draft) {
|
|
192
|
+
send({
|
|
193
|
+
action: "draft.update",
|
|
194
|
+
data: {
|
|
195
|
+
version: stateRef.current.active_draft.version,
|
|
196
|
+
body: pendingDraftBodyRef.current,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
pendingDraftBodyRef.current = null;
|
|
202
|
+
send({ action: "chat.send", data: {} });
|
|
203
|
+
}, [send]);
|
|
204
|
+
|
|
205
|
+
const stopChat = useCallback(
|
|
206
|
+
(messageId: string) => {
|
|
207
|
+
send({ action: "chat.stop", data: { message_id: messageId } });
|
|
208
|
+
},
|
|
209
|
+
[send],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const updateDraft = useCallback(
|
|
213
|
+
(body: string) => {
|
|
214
|
+
// Optimistic local update so the textarea feels snappy.
|
|
215
|
+
setState((prev) =>
|
|
216
|
+
prev.active_draft
|
|
217
|
+
? { ...prev, active_draft: { ...prev.active_draft, body } }
|
|
218
|
+
: prev,
|
|
219
|
+
);
|
|
220
|
+
pendingDraftBodyRef.current = body;
|
|
221
|
+
if (draftDebounceRef.current != null) {
|
|
222
|
+
window.clearTimeout(draftDebounceRef.current);
|
|
223
|
+
}
|
|
224
|
+
draftDebounceRef.current = window.setTimeout(() => {
|
|
225
|
+
draftDebounceRef.current = null;
|
|
226
|
+
const current = stateRef.current.active_draft;
|
|
227
|
+
const pending = pendingDraftBodyRef.current;
|
|
228
|
+
pendingDraftBodyRef.current = null;
|
|
229
|
+
if (current != null && pending != null) {
|
|
230
|
+
send({
|
|
231
|
+
action: "draft.update",
|
|
232
|
+
data: { version: current.version, body: pending },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}, DRAFT_UPDATE_DEBOUNCE_MS);
|
|
236
|
+
},
|
|
237
|
+
[send],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const takeOverDraft = useCallback(() => {
|
|
241
|
+
send({ action: "draft.take_over", data: {} });
|
|
242
|
+
}, [send]);
|
|
243
|
+
|
|
244
|
+
const discardDraft = useCallback(() => {
|
|
245
|
+
send({ action: "draft.discard", data: {} });
|
|
246
|
+
}, [send]);
|
|
247
|
+
|
|
248
|
+
const prependMessages = useCallback((older: Message[]) => {
|
|
249
|
+
// Apply a REST "Load earlier" page into the live socket state. A later
|
|
250
|
+
// session.state snapshot (e.g. reconnect) resets to the tail — acceptable;
|
|
251
|
+
// the user re-loads earlier if needed.
|
|
252
|
+
setState((prev) => {
|
|
253
|
+
const merged = prependHistory(prev.messages, older);
|
|
254
|
+
return merged === prev.messages ? prev : { ...prev, messages: merged };
|
|
255
|
+
});
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
state,
|
|
260
|
+
connected,
|
|
261
|
+
sendChat,
|
|
262
|
+
stopChat,
|
|
263
|
+
updateDraft,
|
|
264
|
+
takeOverDraft,
|
|
265
|
+
discardDraft,
|
|
266
|
+
prependMessages,
|
|
267
|
+
lastError,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sticky-bottom auto-scroll for a streaming message list.
|
|
5
|
+
*
|
|
6
|
+
* Behaviour:
|
|
7
|
+
* - When the user is at (or within `thresholdPx` of) the bottom of the
|
|
8
|
+
* scroll container, growth of `dep` (e.g. messages array, streaming
|
|
9
|
+
* text length) snaps the view to the new bottom.
|
|
10
|
+
* - When the user has scrolled up to read history, growth does NOT
|
|
11
|
+
* yank them back. Auto-follow resumes once they scroll back near
|
|
12
|
+
* the bottom themselves.
|
|
13
|
+
*
|
|
14
|
+
* The "near bottom" predicate is updated in two places:
|
|
15
|
+
* - on the user's `scroll` event (so manual scroll-up disables follow)
|
|
16
|
+
* - immediately after an auto-scroll write (so we stay sticky even
|
|
17
|
+
* though the scroll event for our own write would temporarily make
|
|
18
|
+
* `scrollHeight - scrollTop - clientHeight` larger by a few pixels)
|
|
19
|
+
*
|
|
20
|
+
* Use "instant" scroll behavior for streaming chunks — smooth scroll
|
|
21
|
+
* cannot keep up with high-frequency updates and the view drifts.
|
|
22
|
+
*
|
|
23
|
+
* Returns:
|
|
24
|
+
* - `containerRef`: attach to the scrollable element.
|
|
25
|
+
* - `onScroll`: attach to `onScroll` on the same element.
|
|
26
|
+
* - `scrollToBottom`: force a snap (e.g. on send).
|
|
27
|
+
*/
|
|
28
|
+
export function useStickyBottom<T>(
|
|
29
|
+
dep: T,
|
|
30
|
+
options: { thresholdPx?: number; enabled?: boolean } = {},
|
|
31
|
+
): {
|
|
32
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
33
|
+
onScroll: () => void;
|
|
34
|
+
scrollToBottom: () => void;
|
|
35
|
+
} {
|
|
36
|
+
const { thresholdPx = 100, enabled = true } = options;
|
|
37
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
// Default true: when the container first mounts there's no history
|
|
39
|
+
// to read, so the user is "at the bottom" by definition.
|
|
40
|
+
const wasNearBottomRef = useRef(true);
|
|
41
|
+
|
|
42
|
+
const isNearBottom = useCallback(
|
|
43
|
+
(el: HTMLElement): boolean =>
|
|
44
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < thresholdPx,
|
|
45
|
+
[thresholdPx],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const onScroll = useCallback(() => {
|
|
49
|
+
const el = containerRef.current;
|
|
50
|
+
if (!el) return;
|
|
51
|
+
wasNearBottomRef.current = isNearBottom(el);
|
|
52
|
+
}, [isNearBottom]);
|
|
53
|
+
|
|
54
|
+
const scrollToBottom = useCallback(() => {
|
|
55
|
+
const el = containerRef.current;
|
|
56
|
+
if (!el) return;
|
|
57
|
+
el.scrollTop = el.scrollHeight;
|
|
58
|
+
wasNearBottomRef.current = true;
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!enabled) return;
|
|
63
|
+
const el = containerRef.current;
|
|
64
|
+
if (!el) return;
|
|
65
|
+
if (wasNearBottomRef.current) {
|
|
66
|
+
// "instant" by direct scrollTop write — smooth scroll falls
|
|
67
|
+
// behind during streaming and the view drifts.
|
|
68
|
+
el.scrollTop = el.scrollHeight;
|
|
69
|
+
}
|
|
70
|
+
// dep is intentionally the only signal that triggers a follow;
|
|
71
|
+
// it should change whenever the message list grows (length or
|
|
72
|
+
// the last message's content length).
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [dep, enabled]);
|
|
75
|
+
|
|
76
|
+
return { containerRef, onScroll, scrollToBottom };
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Textarea that auto-sizes to fit its content ONCE — when the content first
|
|
6
|
+
* loads — then behaves as a normal drag-resizable textarea.
|
|
7
|
+
*
|
|
8
|
+
* So the editor opens with every box sized to show all its text (no internal
|
|
9
|
+
* scrollbars), and after that the user owns each box's size by dragging the
|
|
10
|
+
* `resize-y` handle. Typing after the initial fit does NOT re-grow/shrink the
|
|
11
|
+
* box (it scrolls like a normal textarea) — per the desired behavior:
|
|
12
|
+
* auto-size on open, manual thereafter.
|
|
13
|
+
*
|
|
14
|
+
* The fit runs once, keyed on the arrival of content (the value is empty on the
|
|
15
|
+
* first paint while the template loads, then populated). It re-fits on remount,
|
|
16
|
+
* so keying the editor by template id sizes a freshly-opened template's boxes.
|
|
17
|
+
* Tailwind sets `box-sizing: border-box`, so `scrollHeight` includes padding.
|
|
18
|
+
*/
|
|
19
|
+
export const AutoResizeTextarea = React.forwardRef<
|
|
20
|
+
HTMLTextAreaElement,
|
|
21
|
+
React.ComponentPropsWithoutRef<"textarea">
|
|
22
|
+
>(({ className, rows = 2, ...props }, forwardedRef) => {
|
|
23
|
+
const innerRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
24
|
+
const fitted = React.useRef(false);
|
|
25
|
+
|
|
26
|
+
// Merge forwardedRef + innerRef so callers can still access the element.
|
|
27
|
+
const ref = React.useCallback(
|
|
28
|
+
(el: HTMLTextAreaElement | null) => {
|
|
29
|
+
innerRef.current = el;
|
|
30
|
+
if (typeof forwardedRef === "function") {
|
|
31
|
+
forwardedRef(el);
|
|
32
|
+
} else if (forwardedRef) {
|
|
33
|
+
(forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[forwardedRef],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Fit exactly once, when content first arrives. `height: auto` collapses the
|
|
40
|
+
// box so scrollHeight reflects content, then we pin that height. After this
|
|
41
|
+
// the box is purely manual (resize-y); we never auto-resize again.
|
|
42
|
+
React.useLayoutEffect(() => {
|
|
43
|
+
const el = innerRef.current;
|
|
44
|
+
if (!el || fitted.current) return;
|
|
45
|
+
if (!el.value) return; // wait until the loaded content populates the value
|
|
46
|
+
el.style.height = "auto";
|
|
47
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
48
|
+
fitted.current = true;
|
|
49
|
+
}, [props.value]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<textarea
|
|
53
|
+
ref={ref}
|
|
54
|
+
rows={rows}
|
|
55
|
+
className={cn("resize-y", className?.replace(/\bresize-none\b/g, ""))}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
AutoResizeTextarea.displayName = "AutoResizeTextarea";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
|
3
|
+
import { X } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
|
|
7
|
+
const Dialog = DialogPrimitive.Root
|
|
8
|
+
|
|
9
|
+
const DialogTrigger = DialogPrimitive.Trigger
|
|
10
|
+
|
|
11
|
+
const DialogClose = DialogPrimitive.Close
|
|
12
|
+
|
|
13
|
+
const DialogPortal = ({ children }: { children: React.ReactNode }) => (
|
|
14
|
+
<DialogPrimitive.Portal>{children}</DialogPrimitive.Portal>
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
function DialogOverlay({
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
|
|
21
|
+
return (
|
|
22
|
+
<DialogPrimitive.Backdrop
|
|
23
|
+
data-slot="dialog-overlay"
|
|
24
|
+
className={cn(
|
|
25
|
+
"fixed inset-0 z-50 bg-black/80 transition-all data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function DialogContent({
|
|
34
|
+
className,
|
|
35
|
+
children,
|
|
36
|
+
...props
|
|
37
|
+
}: React.ComponentProps<typeof DialogPrimitive.Popup>) {
|
|
38
|
+
return (
|
|
39
|
+
<DialogPrimitive.Portal>
|
|
40
|
+
<DialogOverlay />
|
|
41
|
+
<DialogPrimitive.Popup
|
|
42
|
+
data-slot="dialog-content"
|
|
43
|
+
className={cn(
|
|
44
|
+
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg transition-all data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
{...props}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
|
51
|
+
<X className="h-4 w-4" />
|
|
52
|
+
<span className="sr-only">Close</span>
|
|
53
|
+
</DialogPrimitive.Close>
|
|
54
|
+
</DialogPrimitive.Popup>
|
|
55
|
+
</DialogPrimitive.Portal>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function DialogHeader({
|
|
60
|
+
className,
|
|
61
|
+
...props
|
|
62
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
data-slot="dialog-header"
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex flex-col gap-1.5 text-center sm:text-left",
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function DialogFooter({
|
|
76
|
+
className,
|
|
77
|
+
...props
|
|
78
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
data-slot="dialog-footer"
|
|
82
|
+
className={cn(
|
|
83
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
|
|
84
|
+
className
|
|
85
|
+
)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function DialogTitle({
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
95
|
+
return (
|
|
96
|
+
<DialogPrimitive.Title
|
|
97
|
+
data-slot="dialog-title"
|
|
98
|
+
className={cn(
|
|
99
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
100
|
+
className
|
|
101
|
+
)}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function DialogDescription({
|
|
108
|
+
className,
|
|
109
|
+
...props
|
|
110
|
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
111
|
+
return (
|
|
112
|
+
<DialogPrimitive.Description
|
|
113
|
+
data-slot="dialog-description"
|
|
114
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
115
|
+
{...props}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export {
|
|
121
|
+
Dialog,
|
|
122
|
+
DialogPortal,
|
|
123
|
+
DialogOverlay,
|
|
124
|
+
DialogTrigger,
|
|
125
|
+
DialogClose,
|
|
126
|
+
DialogContent,
|
|
127
|
+
DialogHeader,
|
|
128
|
+
DialogFooter,
|
|
129
|
+
DialogTitle,
|
|
130
|
+
DialogDescription,
|
|
131
|
+
}
|