@djangocfg/ui-tools 2.1.381 → 2.1.382
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/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Bot } from 'lucide-react';
|
|
4
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useIsPhone, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
export type ChatFABPosition =
|
|
10
|
+
| 'bottom-right'
|
|
11
|
+
| 'bottom-left'
|
|
12
|
+
| 'top-right'
|
|
13
|
+
| 'top-left';
|
|
14
|
+
|
|
15
|
+
export type ChatFABVariant = 'simple' | 'animated' | 'glass';
|
|
16
|
+
export type ChatFABSize = 'sm' | 'md' | 'lg' | 'responsive';
|
|
17
|
+
|
|
18
|
+
export interface ChatFABProps {
|
|
19
|
+
/** Click handler — typically toggles a `ChatDock`. */
|
|
20
|
+
onClick: () => void;
|
|
21
|
+
/** Accessible label. */
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
/** Icon inside the FAB. Defaults to a bot glyph. */
|
|
24
|
+
icon?: ReactNode;
|
|
25
|
+
/** Visual style. @default 'simple' */
|
|
26
|
+
variant?: ChatFABVariant;
|
|
27
|
+
/** Button size. @default 'md' */
|
|
28
|
+
size?: ChatFABSize;
|
|
29
|
+
/** Fixed-screen position. @default 'bottom-right' */
|
|
30
|
+
position?: ChatFABPosition;
|
|
31
|
+
/** Pixel offset from screen edges. @default 24 */
|
|
32
|
+
offset?: number;
|
|
33
|
+
/** z-index for the button. @default 9999 */
|
|
34
|
+
zIndex?: number;
|
|
35
|
+
/** Show a small attention dot (unread / new). */
|
|
36
|
+
pulse?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Numeric badge — unread count. Numbers > 9 render as "9+".
|
|
39
|
+
* Overrides `pulse` when both set.
|
|
40
|
+
*/
|
|
41
|
+
badge?: number;
|
|
42
|
+
/** Hover tooltip text. Shows next to the FAB on hover/focus. */
|
|
43
|
+
tooltip?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Render in-place (no fixed positioning) so the FAB sits inline in the
|
|
46
|
+
* normal document flow. Useful for stories, screenshots, and previews
|
|
47
|
+
* inside a contained playground panel. @default false
|
|
48
|
+
*/
|
|
49
|
+
inline?: boolean;
|
|
50
|
+
/** Override classes on the button itself. */
|
|
51
|
+
className?: string;
|
|
52
|
+
/** Extra style on the button (caller-controlled overrides). */
|
|
53
|
+
style?: CSSProperties;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ChatFABFixedSize = Exclude<ChatFABSize, 'responsive'>;
|
|
57
|
+
|
|
58
|
+
const SIZE_PX: Record<ChatFABFixedSize, number> = { sm: 44, md: 56, lg: 64 };
|
|
59
|
+
const ICON_PX: Record<ChatFABFixedSize, number> = { sm: 18, md: 22, lg: 26 };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve `size='responsive'` to a concrete fixed size based on the
|
|
63
|
+
* viewport. Phone → `sm`, tablet → `md`, desktop → `lg`. `inline`
|
|
64
|
+
* previews always collapse to `md` so stories stay stable.
|
|
65
|
+
*/
|
|
66
|
+
function useEffectiveFABSize(size: ChatFABSize, inline: boolean): ChatFABFixedSize {
|
|
67
|
+
const isPhone = useIsPhone();
|
|
68
|
+
const isBelowDesktop = useIsTabletOrBelow();
|
|
69
|
+
if (size !== 'responsive') return size;
|
|
70
|
+
if (inline) return 'md';
|
|
71
|
+
if (isPhone) return 'sm';
|
|
72
|
+
if (isBelowDesktop) return 'md';
|
|
73
|
+
return 'lg';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function positionStyle(position: ChatFABPosition, offset: number): CSSProperties {
|
|
77
|
+
const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
|
|
78
|
+
return { [vert]: offset, [horiz]: offset } as CSSProperties;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tooltipSideClasses(position: ChatFABPosition): string {
|
|
82
|
+
// Tooltip sits opposite-horizontal to the FAB so it doesn't run off-screen.
|
|
83
|
+
return position.endsWith('right')
|
|
84
|
+
? 'right-full mr-3 origin-right'
|
|
85
|
+
: 'left-full ml-3 origin-left';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Badge({ value }: { value: number }) {
|
|
89
|
+
const display = value > 9 ? '9+' : String(value);
|
|
90
|
+
return (
|
|
91
|
+
<span
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
className={cn(
|
|
94
|
+
'absolute -right-1 -top-1 inline-flex min-w-[18px] h-[18px] items-center justify-center',
|
|
95
|
+
'rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground',
|
|
96
|
+
'ring-2 ring-background',
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{display}
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function PulseDot() {
|
|
105
|
+
return (
|
|
106
|
+
<span aria-hidden="true" className="absolute right-1 top-1">
|
|
107
|
+
<span className="relative inline-flex h-2.5 w-2.5">
|
|
108
|
+
<span className="absolute inset-0 rounded-full bg-destructive opacity-75 animate-ping" />
|
|
109
|
+
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
|
|
110
|
+
</span>
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Tooltip({ text, side }: { text: string; side: string }) {
|
|
116
|
+
return (
|
|
117
|
+
<span
|
|
118
|
+
role="tooltip"
|
|
119
|
+
className={cn(
|
|
120
|
+
'pointer-events-none absolute top-1/2 -translate-y-1/2 whitespace-nowrap',
|
|
121
|
+
'rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-md',
|
|
122
|
+
'border border-border opacity-0 scale-95 transition-all duration-150',
|
|
123
|
+
'group-hover:opacity-100 group-hover:scale-100',
|
|
124
|
+
'group-focus-within:opacity-100 group-focus-within:scale-100',
|
|
125
|
+
side,
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{text}
|
|
129
|
+
</span>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Floating action button for opening a chat dock. Pure presentation — owns
|
|
135
|
+
* no state. Wire it to whatever toggles your dock open.
|
|
136
|
+
*
|
|
137
|
+
* For the common "FAB + dock + hotkey" composition, use `<ChatLauncher>` —
|
|
138
|
+
* this primitive is only useful when you need custom triggering.
|
|
139
|
+
*/
|
|
140
|
+
export function ChatFAB({
|
|
141
|
+
onClick,
|
|
142
|
+
ariaLabel = 'Open chat',
|
|
143
|
+
icon,
|
|
144
|
+
variant = 'simple',
|
|
145
|
+
size = 'responsive',
|
|
146
|
+
position = 'bottom-right',
|
|
147
|
+
offset = 24,
|
|
148
|
+
zIndex = 9999,
|
|
149
|
+
pulse = false,
|
|
150
|
+
badge,
|
|
151
|
+
tooltip,
|
|
152
|
+
inline = false,
|
|
153
|
+
className,
|
|
154
|
+
style,
|
|
155
|
+
}: ChatFABProps) {
|
|
156
|
+
const effectiveSize = useEffectiveFABSize(size, inline);
|
|
157
|
+
const px = SIZE_PX[effectiveSize];
|
|
158
|
+
const iconPx = ICON_PX[effectiveSize];
|
|
159
|
+
const renderedIcon = icon ?? <Bot size={iconPx} />;
|
|
160
|
+
|
|
161
|
+
const baseButton = cn(
|
|
162
|
+
'relative grid place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
163
|
+
'transition-transform hover:scale-105',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
className={cn('group', inline ? 'relative inline-flex' : 'fixed')}
|
|
169
|
+
style={
|
|
170
|
+
inline
|
|
171
|
+
? undefined
|
|
172
|
+
: { ...positionStyle(position, offset), zIndex }
|
|
173
|
+
}
|
|
174
|
+
>
|
|
175
|
+
{variant === 'animated' && (
|
|
176
|
+
<AnimatedFAB
|
|
177
|
+
ariaLabel={ariaLabel}
|
|
178
|
+
onClick={onClick}
|
|
179
|
+
size={px}
|
|
180
|
+
className={className}
|
|
181
|
+
style={style}
|
|
182
|
+
>
|
|
183
|
+
{renderedIcon}
|
|
184
|
+
</AnimatedFAB>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{variant === 'glass' && (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
aria-label={ariaLabel}
|
|
191
|
+
onClick={onClick}
|
|
192
|
+
className={cn(
|
|
193
|
+
baseButton,
|
|
194
|
+
'border border-border/40 bg-background/60 text-foreground shadow-lg backdrop-blur-xl',
|
|
195
|
+
'hover:bg-background/80',
|
|
196
|
+
className,
|
|
197
|
+
)}
|
|
198
|
+
style={{ width: px, height: px, ...style }}
|
|
199
|
+
>
|
|
200
|
+
{renderedIcon}
|
|
201
|
+
{badge !== undefined ? <Badge value={badge} /> : pulse ? <PulseDot /> : null}
|
|
202
|
+
</button>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{variant === 'simple' && (
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
aria-label={ariaLabel}
|
|
209
|
+
onClick={onClick}
|
|
210
|
+
className={cn(
|
|
211
|
+
baseButton,
|
|
212
|
+
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xl',
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
style={{ width: px, height: px, ...style }}
|
|
216
|
+
>
|
|
217
|
+
{renderedIcon}
|
|
218
|
+
{badge !== undefined ? <Badge value={badge} /> : pulse ? <PulseDot /> : null}
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{tooltip && <Tooltip text={tooltip} side={tooltipSideClasses(position)} />}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Animated variant ──────────────────────────────────────────────────────
|
|
228
|
+
// Orbital gradient ring + glow + entrance bounce. Self-contained CSS via
|
|
229
|
+
// inline <style>, scoped by a unique class name per instance to avoid
|
|
230
|
+
// collisions when multiple FABs mount.
|
|
231
|
+
|
|
232
|
+
interface AnimatedFABProps {
|
|
233
|
+
ariaLabel: string;
|
|
234
|
+
onClick: () => void;
|
|
235
|
+
size: number;
|
|
236
|
+
className?: string;
|
|
237
|
+
style?: CSSProperties;
|
|
238
|
+
children: ReactNode;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function AnimatedFAB({ ariaLabel, onClick, size, className, style, children }: AnimatedFABProps) {
|
|
242
|
+
return (
|
|
243
|
+
<>
|
|
244
|
+
<style>{ANIMATED_CSS}</style>
|
|
245
|
+
<div
|
|
246
|
+
className={cn('cmdop-fab-anim', className)}
|
|
247
|
+
style={{ width: size, height: size, ...style }}
|
|
248
|
+
>
|
|
249
|
+
<div className="cmdop-fab-anim-glow">
|
|
250
|
+
<div className="cmdop-fab-anim-wrap">
|
|
251
|
+
<div className="cmdop-fab-anim-grad cmdop-fab-anim-grad-1" />
|
|
252
|
+
<div className="cmdop-fab-anim-grad cmdop-fab-anim-grad-2" />
|
|
253
|
+
<div className="cmdop-fab-anim-inner" />
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
aria-label={ariaLabel}
|
|
257
|
+
onClick={onClick}
|
|
258
|
+
className="cmdop-fab-anim-btn"
|
|
259
|
+
>
|
|
260
|
+
<span className="cmdop-fab-anim-icon">{children}</span>
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const ANIMATED_CSS = `
|
|
270
|
+
.cmdop-fab-anim {
|
|
271
|
+
position: relative;
|
|
272
|
+
pointer-events: auto;
|
|
273
|
+
}
|
|
274
|
+
.cmdop-fab-anim-glow {
|
|
275
|
+
width: 100%; height: 100%;
|
|
276
|
+
border-radius: 50%;
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
animation:
|
|
279
|
+
cmdop-fab-entrance 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) forwards,
|
|
280
|
+
cmdop-fab-glow-shift 8s ease-in-out 0.6s infinite;
|
|
281
|
+
}
|
|
282
|
+
.cmdop-fab-anim-wrap {
|
|
283
|
+
position: relative; width: 100%; height: 100%;
|
|
284
|
+
border-radius: 50%; overflow: hidden;
|
|
285
|
+
}
|
|
286
|
+
.cmdop-fab-anim-grad { position: absolute; inset: 0; border-radius: 50%; }
|
|
287
|
+
.cmdop-fab-anim-grad-1 {
|
|
288
|
+
background: conic-gradient(
|
|
289
|
+
from 0deg,
|
|
290
|
+
#fbbf24 0%, rgba(251,191,36,0) 15%,
|
|
291
|
+
rgba(168,85,247,0) 20%, #a855f7 35%, rgba(168,85,247,0) 50%,
|
|
292
|
+
rgba(20,184,166,0) 55%, #14b8a6 70%, rgba(20,184,166,0) 85%,
|
|
293
|
+
rgba(236,72,153,0) 88%, #ec4899 97%, #fbbf24 100%
|
|
294
|
+
);
|
|
295
|
+
animation: cmdop-fab-rotate 7s linear infinite;
|
|
296
|
+
filter: blur(1px); opacity: 0.95;
|
|
297
|
+
}
|
|
298
|
+
.cmdop-fab-anim-grad-2 {
|
|
299
|
+
inset: 1px;
|
|
300
|
+
background: conic-gradient(
|
|
301
|
+
from 180deg,
|
|
302
|
+
#a855f7 0%, rgba(168,85,247,0) 20%,
|
|
303
|
+
rgba(20,184,166,0) 30%, #14b8a6 50%, rgba(20,184,166,0) 70%,
|
|
304
|
+
rgba(251,191,36,0) 75%, #fbbf24 95%, #a855f7 100%
|
|
305
|
+
);
|
|
306
|
+
animation: cmdop-fab-rotate-rev 9s linear infinite;
|
|
307
|
+
filter: blur(0.75px); opacity: 0.7;
|
|
308
|
+
}
|
|
309
|
+
.cmdop-fab-anim-inner {
|
|
310
|
+
position: absolute; inset: 3px; border-radius: 50%;
|
|
311
|
+
background: rgba(10, 10, 10, 0.65);
|
|
312
|
+
backdrop-filter: blur(12px) saturate(1.8);
|
|
313
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.8);
|
|
314
|
+
animation: cmdop-fab-inner-glow 5s ease-in-out infinite;
|
|
315
|
+
}
|
|
316
|
+
.cmdop-fab-anim-btn {
|
|
317
|
+
position: absolute; inset: 2px;
|
|
318
|
+
border-radius: 50%; border: none; background: transparent;
|
|
319
|
+
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
320
|
+
transition: transform 0.2s;
|
|
321
|
+
}
|
|
322
|
+
.cmdop-fab-anim-btn:hover { transform: scale(1.06); }
|
|
323
|
+
.cmdop-fab-anim-icon {
|
|
324
|
+
color: #fbbf24; display: flex;
|
|
325
|
+
filter: drop-shadow(0 0 6px rgba(251,191,36,0.8));
|
|
326
|
+
animation: cmdop-fab-icon-pulse 2.5s ease-in-out infinite;
|
|
327
|
+
}
|
|
328
|
+
@keyframes cmdop-fab-rotate { to { transform: rotate(360deg); } }
|
|
329
|
+
@keyframes cmdop-fab-rotate-rev { to { transform: rotate(-360deg); } }
|
|
330
|
+
@keyframes cmdop-fab-entrance {
|
|
331
|
+
0% { transform: scale(0); }
|
|
332
|
+
50% { transform: scale(1.08); }
|
|
333
|
+
70% { transform: scale(0.98); }
|
|
334
|
+
100% { transform: scale(1); }
|
|
335
|
+
}
|
|
336
|
+
@keyframes cmdop-fab-glow-shift {
|
|
337
|
+
0%, 100% { box-shadow: 0 0 20px rgba(251,191,36,0.5), 0 0 40px rgba(168,85,247,0.3); }
|
|
338
|
+
33% { box-shadow: 0 0 20px rgba(168,85,247,0.5), 0 0 40px rgba(20,184,166,0.3); }
|
|
339
|
+
66% { box-shadow: 0 0 20px rgba(20,184,166,0.5), 0 0 40px rgba(236,72,153,0.3); }
|
|
340
|
+
}
|
|
341
|
+
@keyframes cmdop-fab-icon-pulse {
|
|
342
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
343
|
+
50% { opacity: 0.85; transform: scale(1.15); }
|
|
344
|
+
}
|
|
345
|
+
@keyframes cmdop-fab-inner-glow {
|
|
346
|
+
0%, 100% { box-shadow: inset 0 0 20px rgba(251,191,36,0.25), inset 0 0 40px rgba(168,85,247,0.15); }
|
|
347
|
+
50% { box-shadow: inset 0 0 25px rgba(168,85,247,0.3), inset 0 0 45px rgba(20,184,166,0.2); }
|
|
348
|
+
}
|
|
349
|
+
`;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import { useChatPresence } from './useChatPresence';
|
|
10
|
+
import type { ChatFABPosition } from './ChatFAB';
|
|
11
|
+
|
|
12
|
+
export interface ChatGreetingProps {
|
|
13
|
+
/** Controlled visibility — usually `!chatOpen && !userDismissed`. */
|
|
14
|
+
open: boolean;
|
|
15
|
+
/** Greeting text. Pass a string for the default bubble, or any ReactNode. */
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
/** Click handler — typically opens the chat. Bubble is clickable when set. */
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
/** Close (×) button handler — typically marks the greeting as dismissed. */
|
|
20
|
+
onDismiss?: () => void;
|
|
21
|
+
/** Anchor relative to a FAB on the same side. @default 'bottom-right' */
|
|
22
|
+
position?: ChatFABPosition;
|
|
23
|
+
/**
|
|
24
|
+
* Horizontal pixel offset matching the FAB's `offset` prop, so the greeting
|
|
25
|
+
* lines up under the FAB. @default 24
|
|
26
|
+
*/
|
|
27
|
+
fabOffset?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Vertical pixel offset above/below the FAB centerline. @default 96
|
|
30
|
+
* (room for an `md` FAB plus a small gap).
|
|
31
|
+
*/
|
|
32
|
+
fabClearance?: number;
|
|
33
|
+
/** Delay before the greeting appears, in ms. @default 1500 */
|
|
34
|
+
delayMs?: number;
|
|
35
|
+
/** z-index. @default 9998 (just below the default FAB at 9999). */
|
|
36
|
+
zIndex?: number;
|
|
37
|
+
/** Override classes on the bubble. */
|
|
38
|
+
className?: string;
|
|
39
|
+
/** Override styles on the bubble. */
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
/** Optional sender avatar / icon shown on the left. */
|
|
42
|
+
avatar?: ReactNode;
|
|
43
|
+
/** Optional sender label rendered above the text. */
|
|
44
|
+
senderName?: string;
|
|
45
|
+
/** ARIA label for the dismiss button. @default 'Dismiss' */
|
|
46
|
+
dismissLabel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Render in-place (no fixed positioning). Useful for stories and inline previews.
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
inline?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function anchorStyle(
|
|
55
|
+
position: ChatFABPosition,
|
|
56
|
+
fabOffset: number,
|
|
57
|
+
fabClearance: number,
|
|
58
|
+
): CSSProperties {
|
|
59
|
+
const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
|
|
60
|
+
return { [vert]: fabClearance, [horiz]: fabOffset } as CSSProperties;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function originClass(position: ChatFABPosition): string {
|
|
64
|
+
// Scale-in origin matches the corner the bubble attaches to.
|
|
65
|
+
if (position === 'bottom-right') return 'origin-bottom-right';
|
|
66
|
+
if (position === 'bottom-left') return 'origin-bottom-left';
|
|
67
|
+
if (position === 'top-right') return 'origin-top-right';
|
|
68
|
+
return 'origin-top-left';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Greeting bubble shown next to a `ChatFAB` to invite the user to start a
|
|
73
|
+
* conversation (LiveChat / Intercom-style proactive prompt).
|
|
74
|
+
*
|
|
75
|
+
* Renders fixed-position, anchored to the same corner as the FAB. Owns its
|
|
76
|
+
* own delayed-mount + presence animation. Hide on chat open and/or after
|
|
77
|
+
* user dismissal.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```tsx
|
|
81
|
+
* const [open, setOpen] = useState(false);
|
|
82
|
+
* const [dismissed, setDismissed] = useState(false);
|
|
83
|
+
*
|
|
84
|
+
* <ChatLauncher
|
|
85
|
+
* open={open}
|
|
86
|
+
* onOpenChange={setOpen}
|
|
87
|
+
* fab={{ variant: 'animated' }}
|
|
88
|
+
* dock={{ title: 'Support' }}
|
|
89
|
+
* >
|
|
90
|
+
* <SupportChat />
|
|
91
|
+
* </ChatLauncher>
|
|
92
|
+
*
|
|
93
|
+
* <ChatGreeting
|
|
94
|
+
* open={!open && !dismissed}
|
|
95
|
+
* onClick={() => setOpen(true)}
|
|
96
|
+
* onDismiss={() => setDismissed(true)}
|
|
97
|
+
* senderName="Anna from Support"
|
|
98
|
+
* delayMs={2000}
|
|
99
|
+
* >
|
|
100
|
+
* Hi! 👋 Got a question? I'm here to help.
|
|
101
|
+
* </ChatGreeting>
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function ChatGreeting({
|
|
105
|
+
open,
|
|
106
|
+
children,
|
|
107
|
+
onClick,
|
|
108
|
+
onDismiss,
|
|
109
|
+
position = 'bottom-right',
|
|
110
|
+
fabOffset = 24,
|
|
111
|
+
fabClearance = 96,
|
|
112
|
+
delayMs = 1500,
|
|
113
|
+
zIndex = 9998,
|
|
114
|
+
className,
|
|
115
|
+
style,
|
|
116
|
+
avatar,
|
|
117
|
+
senderName,
|
|
118
|
+
dismissLabel = 'Dismiss',
|
|
119
|
+
inline = false,
|
|
120
|
+
}: ChatGreetingProps) {
|
|
121
|
+
const [delayed, setDelayed] = useState(delayMs <= 0);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!open || delayMs <= 0) return;
|
|
125
|
+
const t = setTimeout(() => setDelayed(true), delayMs);
|
|
126
|
+
return () => clearTimeout(t);
|
|
127
|
+
}, [open, delayMs]);
|
|
128
|
+
|
|
129
|
+
const shouldShow = open && delayed;
|
|
130
|
+
const phase = useChatPresence(shouldShow, 220);
|
|
131
|
+
|
|
132
|
+
if (phase === 'hidden') return null;
|
|
133
|
+
|
|
134
|
+
const animating = phase === 'entering' || phase === 'leaving';
|
|
135
|
+
const clickable = !!onClick;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
role={clickable ? 'button' : 'status'}
|
|
140
|
+
aria-live="polite"
|
|
141
|
+
tabIndex={clickable ? 0 : -1}
|
|
142
|
+
onClick={clickable ? onClick : undefined}
|
|
143
|
+
onKeyDown={
|
|
144
|
+
clickable
|
|
145
|
+
? (e) => {
|
|
146
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
onClick?.();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
: undefined
|
|
152
|
+
}
|
|
153
|
+
className={cn(
|
|
154
|
+
inline ? 'relative inline-flex' : 'fixed',
|
|
155
|
+
'flex items-start gap-2.5 max-w-[280px]',
|
|
156
|
+
'rounded-2xl border border-border bg-popover text-popover-foreground',
|
|
157
|
+
'px-3.5 py-2.5 shadow-2xl transition-all duration-200 ease-out',
|
|
158
|
+
clickable && 'cursor-pointer hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
159
|
+
originClass(position),
|
|
160
|
+
animating ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0',
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
style={{
|
|
164
|
+
...(inline ? {} : anchorStyle(position, fabOffset, fabClearance)),
|
|
165
|
+
...(inline ? {} : { zIndex }),
|
|
166
|
+
pointerEvents: phase === 'visible' ? 'auto' : 'none',
|
|
167
|
+
...style,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{avatar && <div className="mt-0.5 shrink-0">{avatar}</div>}
|
|
171
|
+
|
|
172
|
+
<div className="min-w-0 flex-1 text-sm leading-snug">
|
|
173
|
+
{senderName && (
|
|
174
|
+
<div className="mb-0.5 text-[11px] font-medium text-muted-foreground">
|
|
175
|
+
{senderName}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
<div className="text-foreground">{children}</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{onDismiss && (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
aria-label={dismissLabel}
|
|
185
|
+
onClick={(e) => {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
onDismiss();
|
|
188
|
+
}}
|
|
189
|
+
className={cn(
|
|
190
|
+
'-mr-1 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
|
|
191
|
+
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
|
192
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
<X className="h-3.5 w-3.5" />
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Bot, X } from 'lucide-react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
export interface ChatHeaderProps {
|
|
10
|
+
/** Window title text. */
|
|
11
|
+
title?: ReactNode;
|
|
12
|
+
/** Icon next to the title. Defaults to a bot glyph. */
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* Action slot — appears to the right of the title, before the close button.
|
|
16
|
+
* Use for reset / settings / minimize / etc. Compose with `ChatHeaderActionButton`.
|
|
17
|
+
*/
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
/** Show the close (×) button. @default true */
|
|
20
|
+
showClose?: boolean;
|
|
21
|
+
/** Close click handler. */
|
|
22
|
+
onClose?: () => void;
|
|
23
|
+
/** ARIA label for the close button. @default 'Close' */
|
|
24
|
+
closeLabel?: string;
|
|
25
|
+
/** Replace the close button entirely (rare — most hosts want the default). */
|
|
26
|
+
closeSlot?: ReactNode;
|
|
27
|
+
/** Extra classes on the `<header>` element. */
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Standalone chat header — title + icon + action slot + close button.
|
|
33
|
+
*
|
|
34
|
+
* Used by `<ChatDock>` automatically, but can be rendered standalone when
|
|
35
|
+
* the host owns the chat container (e.g. embedded inline in a page).
|
|
36
|
+
*/
|
|
37
|
+
export function ChatHeader({
|
|
38
|
+
title,
|
|
39
|
+
icon,
|
|
40
|
+
actions,
|
|
41
|
+
showClose = true,
|
|
42
|
+
onClose,
|
|
43
|
+
closeLabel = 'Close',
|
|
44
|
+
closeSlot,
|
|
45
|
+
className,
|
|
46
|
+
}: ChatHeaderProps) {
|
|
47
|
+
return (
|
|
48
|
+
<header
|
|
49
|
+
className={cn(
|
|
50
|
+
'border-border bg-muted/30 flex shrink-0 items-center justify-between border-b px-4 py-2.5',
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
|
55
|
+
{icon ?? <Bot className="text-primary h-4 w-4 shrink-0" />}
|
|
56
|
+
<span className="truncate">{title}</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="flex items-center gap-0.5">
|
|
60
|
+
{actions}
|
|
61
|
+
{closeSlot ??
|
|
62
|
+
(showClose && onClose && (
|
|
63
|
+
<Button
|
|
64
|
+
variant="ghost"
|
|
65
|
+
size="sm"
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
aria-label={closeLabel}
|
|
68
|
+
className="-mr-1 h-7 w-7 p-0"
|
|
69
|
+
>
|
|
70
|
+
<X className="h-4 w-4" />
|
|
71
|
+
</Button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</header>
|
|
75
|
+
);
|
|
76
|
+
}
|