From cf07390809c0c73a81839253627576a4452c48dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:55:06 +0000 Subject: [PATCH 1/6] feat: add App zum Doc branding and chat components Add AppZumDocLogo and AppZumDocBadge branding components alongside a standalone chat widget set: ChatLayout, ChatThread, ChatConversationList, ChatConversationRow, ChatThreadHeader, ChatMessageList, ChatMessageBubble, ChatMessageCard, ChatAttachmentCard, ChatComposer, ChatQuickReplyChip, ChatSystemLine and ChatDateDivider, with stories for each. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- CHANGELOG.md | 21 ++ package.json | 2 +- src/components/branding/AppZumDocBadge.tsx | 32 ++ src/components/branding/AppZumDocLogo.tsx | 41 +++ src/components/chat/ChatAttachmentCard.tsx | 52 +++ src/components/chat/ChatComposer.tsx | 76 +++++ src/components/chat/ChatConversationList.tsx | 27 ++ src/components/chat/ChatConversationRow.tsx | 59 ++++ src/components/chat/ChatDateDivider.tsx | 14 + src/components/chat/ChatLayout.tsx | 49 +++ src/components/chat/ChatMessageBubble.tsx | 39 +++ src/components/chat/ChatMessageCard.tsx | 57 ++++ src/components/chat/ChatMessageList.tsx | 26 ++ src/components/chat/ChatQuickReplyChip.tsx | 23 ++ src/components/chat/ChatSystemLine.tsx | 28 ++ src/components/chat/ChatThreadHeader.tsx | 36 ++ src/style/theme/components/chat.css | 312 ++++++++++++++++++ src/style/theme/components/index.css | 1 + stories/Branding/AppZumDocBadge.stories.tsx | 17 + stories/Branding/AppZumDocLogo.stories.tsx | 18 + stories/Chat/ChatAttachmentCard.stories.tsx | 20 ++ stories/Chat/ChatComposer.stories.tsx | 38 +++ stories/Chat/ChatConversationList.stories.tsx | 62 ++++ stories/Chat/ChatConversationRow.stories.tsx | 52 +++ stories/Chat/ChatDateDivider.stories.tsx | 20 ++ stories/Chat/ChatLayout.stories.tsx | 282 ++++++++++++++++ stories/Chat/ChatMessageBubble.stories.tsx | 33 ++ stories/Chat/ChatMessageCard.stories.tsx | 81 +++++ stories/Chat/ChatMessageList.stories.tsx | 63 ++++ stories/Chat/ChatQuickReplyChip.stories.tsx | 25 ++ stories/Chat/ChatSystemLine.stories.tsx | 16 + stories/Chat/ChatThreadHeader.stories.tsx | 42 +++ 32 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 src/components/branding/AppZumDocBadge.tsx create mode 100644 src/components/branding/AppZumDocLogo.tsx create mode 100644 src/components/chat/ChatAttachmentCard.tsx create mode 100644 src/components/chat/ChatComposer.tsx create mode 100644 src/components/chat/ChatConversationList.tsx create mode 100644 src/components/chat/ChatConversationRow.tsx create mode 100644 src/components/chat/ChatDateDivider.tsx create mode 100644 src/components/chat/ChatLayout.tsx create mode 100644 src/components/chat/ChatMessageBubble.tsx create mode 100644 src/components/chat/ChatMessageCard.tsx create mode 100644 src/components/chat/ChatMessageList.tsx create mode 100644 src/components/chat/ChatQuickReplyChip.tsx create mode 100644 src/components/chat/ChatSystemLine.tsx create mode 100644 src/components/chat/ChatThreadHeader.tsx create mode 100644 src/style/theme/components/chat.css create mode 100644 stories/Branding/AppZumDocBadge.stories.tsx create mode 100644 stories/Branding/AppZumDocLogo.stories.tsx create mode 100644 stories/Chat/ChatAttachmentCard.stories.tsx create mode 100644 stories/Chat/ChatComposer.stories.tsx create mode 100644 stories/Chat/ChatConversationList.stories.tsx create mode 100644 stories/Chat/ChatConversationRow.stories.tsx create mode 100644 stories/Chat/ChatDateDivider.stories.tsx create mode 100644 stories/Chat/ChatLayout.stories.tsx create mode 100644 stories/Chat/ChatMessageBubble.stories.tsx create mode 100644 stories/Chat/ChatMessageCard.stories.tsx create mode 100644 stories/Chat/ChatMessageList.stories.tsx create mode 100644 stories/Chat/ChatQuickReplyChip.stories.tsx create mode 100644 stories/Chat/ChatSystemLine.stories.tsx create mode 100644 stories/Chat/ChatThreadHeader.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fba361f..57e401bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-07-06 + +### Added + +- `AppZumDocLogo` component with the official App zum Doc logo and configurable `frontColor`, `backColor`, and `size` +- `AppZumDocBadge` component with the App zum Doc logo and wordmark, mirroring `HelpwaveBadge` +- Chat component set for the App zum Doc messaging surfaces: + - `ChatLayout` two-pane layout (conversation list + open thread) with responsive single-pane behavior below the tablet breakpoint, nestable into `AppPage` + - `ChatThread` column layout with `header` and `footer` slots + - `ChatConversationList` with optional `header` and `footer` slots and a scrollable row area + - `ChatConversationRow` with avatar presence, timestamp, preview, sent indicator, unread count pill, and `selected`/`unread` states + - `ChatThreadHeader` with avatar presence, title, subtitle, and `leading`/`trailing` slots + - `ChatMessageList` scrollable message pane with optional `autoScroll` + - `ChatMessageBubble` incoming/outgoing bubble with asymmetric radii, inline timestamp, and optional read receipt + - `ChatMessageCard` structured message card (e.g. appointment proposal, prescription request, referral) with icon, title, subtitle, badge, body, and action footer + - `ChatAttachmentCard` with file icon, filename, metadata, and download action + - `ChatComposer` with controlled or uncontrolled value, `leading`/`trailing` slots, and enter-to-send + - `ChatQuickReplyChip` outlined pill button with active state + - `ChatSystemLine` centered status line with icon + - `ChatDateDivider` centered date pill + ## [0.14.1] - 2026-07-05 ### Changed diff --git a/package.json b/package.json index abcd7775..ca25cfdb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "access": "public" }, "license": "MPL-2.0", - "version": "0.14.1", + "version": "0.15.0", "browserslist": [ "> 0.2%", "last 3 versions", diff --git a/src/components/branding/AppZumDocBadge.tsx b/src/components/branding/AppZumDocBadge.tsx new file mode 100644 index 00000000..98438c35 --- /dev/null +++ b/src/components/branding/AppZumDocBadge.tsx @@ -0,0 +1,32 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' +import { AppZumDocLogo } from './AppZumDocLogo' + +type Size = 'sm' | 'md' | 'lg' + +export type AppZumDocBadgeProps = HTMLAttributes & { + size?: Size, +} + +export const AppZumDocBadge = ({ + size = 'sm', + ...props +}: AppZumDocBadgeProps) => { + return ( + + + App zum Doc + + ) +} diff --git a/src/components/branding/AppZumDocLogo.tsx b/src/components/branding/AppZumDocLogo.tsx new file mode 100644 index 00000000..909ab9cc --- /dev/null +++ b/src/components/branding/AppZumDocLogo.tsx @@ -0,0 +1,41 @@ +import type { SVGProps } from 'react' +import { clsx } from 'clsx' + +export type AppZumDocLogoProps = SVGProps & { + frontColor?: string, + backColor?: string, + size?: 'sm' | 'md' | 'lg', +} + +export const AppZumDocLogo = ({ + frontColor = '#095763', + backColor = '#4E97A2', + size, + ...props +}: AppZumDocLogoProps) => { + return ( + + + + + ) +} diff --git a/src/components/chat/ChatAttachmentCard.tsx b/src/components/chat/ChatAttachmentCard.tsx new file mode 100644 index 00000000..c04b59ae --- /dev/null +++ b/src/components/chat/ChatAttachmentCard.tsx @@ -0,0 +1,52 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { Download, FileText } from 'lucide-react' +import { IconButton } from '../user-interaction/IconButton' +import type { ChatMessageDirection } from './ChatMessageBubble' + +export type ChatAttachmentCardProps = HTMLAttributes & { + name: ReactNode, + metadata?: ReactNode, + icon?: ReactNode, + direction?: ChatMessageDirection, + downloadLabel?: string, + onDownload?: () => void, +} + +export const ChatAttachmentCard = ({ + name, + metadata, + icon, + direction = 'incoming', + downloadLabel = 'Download', + onDownload, + ...props +}: ChatAttachmentCardProps) => { + return ( +
+ + {icon ?? } + + + {name} + {metadata && ( + {metadata} + )} + + {onDownload && ( + + + + )} +
+ ) +} diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx new file mode 100644 index 00000000..85aede4f --- /dev/null +++ b/src/components/chat/ChatComposer.tsx @@ -0,0 +1,76 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { SendHorizontal } from 'lucide-react' +import { IconButton } from '../user-interaction/IconButton' +import { useControlledState } from '@/src/hooks/useControlledState' + +export type ChatComposerProps = Omit, 'onChange'> & { + value?: string, + initialValue?: string, + onValueChange?: (value: string) => void, + onSend: (value: string) => void, + placeholder?: string, + sendLabel?: string, + disabled?: boolean, + leading?: ReactNode, + trailing?: ReactNode, +} + +export const ChatComposer = ({ + value: controlledValue, + initialValue, + onValueChange, + onSend, + placeholder, + sendLabel = 'Send', + disabled = false, + leading, + trailing, + ...props +}: ChatComposerProps) => { + const [value, setValue] = useControlledState({ + value: controlledValue, + onValueChange, + defaultValue: initialValue ?? '', + }) + + const send = () => { + const trimmed = (value ?? '').trim() + if (!trimmed) { + return + } + onSend(trimmed) + setValue('') + } + + return ( +
+ {leading} +
+ setValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + send() + } + }} + /> + {trailing} +
+ + + +
+ ) +} diff --git a/src/components/chat/ChatConversationList.tsx b/src/components/chat/ChatConversationList.tsx new file mode 100644 index 00000000..a139ab90 --- /dev/null +++ b/src/components/chat/ChatConversationList.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes, ReactNode } from 'react' + +export type ChatConversationListProps = HTMLAttributes & { + header?: ReactNode, + footer?: ReactNode, +} + +export const ChatConversationList = ({ + header, + footer, + children, + ...props +}: ChatConversationListProps) => { + return ( +
+ {header && ( +
{header}
+ )} +
+ {children} +
+ {footer && ( +
{footer}
+ )} +
+ ) +} diff --git a/src/components/chat/ChatConversationRow.tsx b/src/components/chat/ChatConversationRow.tsx new file mode 100644 index 00000000..e5ecaa9d --- /dev/null +++ b/src/components/chat/ChatConversationRow.tsx @@ -0,0 +1,59 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' +import { Check } from 'lucide-react' +import type { AvatarWithStatusProps } from '../display-and-visualization/Avatar' +import { AvatarWithStatus } from '../display-and-visualization/Avatar' +import { PropsUtil } from '@/src/utils/propsUtil' + +export type ChatConversationRowProps = ButtonHTMLAttributes & { + avatar: AvatarWithStatusProps, + title: ReactNode, + timestamp?: ReactNode, + preview?: ReactNode, + unreadCount?: number, + isSelected?: boolean, + isUnread?: boolean, + hasSentIndicator?: boolean, +} + +export const ChatConversationRow = ({ + avatar, + title, + timestamp, + preview, + unreadCount, + isSelected = false, + isUnread = false, + hasSentIndicator = false, + ...props +}: ChatConversationRowProps) => { + return ( + + ) +} diff --git a/src/components/chat/ChatDateDivider.tsx b/src/components/chat/ChatDateDivider.tsx new file mode 100644 index 00000000..833b5435 --- /dev/null +++ b/src/components/chat/ChatDateDivider.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes } from 'react' + +export type ChatDateDividerProps = HTMLAttributes + +export const ChatDateDivider = ({ + children, + ...props +}: ChatDateDividerProps) => { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/chat/ChatLayout.tsx b/src/components/chat/ChatLayout.tsx new file mode 100644 index 00000000..79f8b406 --- /dev/null +++ b/src/components/chat/ChatLayout.tsx @@ -0,0 +1,49 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { PropsUtil } from '@/src/utils/propsUtil' + +export type ChatLayoutProps = HTMLAttributes & { + conversationList: ReactNode, + isConversationOpen?: boolean, +} + +export const ChatLayout = ({ + conversationList, + isConversationOpen = false, + children, + ...props +}: ChatLayoutProps) => { + return ( +
+
+ {conversationList} +
+
+ {children} +
+
+ ) +} + +export type ChatThreadProps = HTMLAttributes & { + header?: ReactNode, + footer?: ReactNode, +} + +export const ChatThread = ({ + header, + footer, + children, + ...props +}: ChatThreadProps) => { + return ( +
+ {header} + {children} + {footer} +
+ ) +} diff --git a/src/components/chat/ChatMessageBubble.tsx b/src/components/chat/ChatMessageBubble.tsx new file mode 100644 index 00000000..4b6a5653 --- /dev/null +++ b/src/components/chat/ChatMessageBubble.tsx @@ -0,0 +1,39 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { CheckCheck } from 'lucide-react' + +export type ChatMessageDirection = 'incoming' | 'outgoing' + +export type ChatMessageBubbleProps = HTMLAttributes & { + direction?: ChatMessageDirection, + timestamp?: ReactNode, + readReceipt?: ReactNode, +} + +export const ChatMessageBubble = ({ + direction = 'incoming', + timestamp, + readReceipt, + children, + ...props +}: ChatMessageBubbleProps) => { + return ( +
+
+ {children} + {timestamp && ( + {timestamp} + )} +
+ {readReceipt && ( + + + {readReceipt} + + )} +
+ ) +} diff --git a/src/components/chat/ChatMessageCard.tsx b/src/components/chat/ChatMessageCard.tsx new file mode 100644 index 00000000..36648d39 --- /dev/null +++ b/src/components/chat/ChatMessageCard.tsx @@ -0,0 +1,57 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import type { ChatMessageDirection } from './ChatMessageBubble' +import type { ChipColor } from '../display-and-visualization/Chip' + +export type ChatMessageCardProps = HTMLAttributes & { + icon?: ReactNode, + title: ReactNode, + subtitle?: ReactNode, + badge?: ReactNode, + actions?: ReactNode, + color?: ChipColor, + direction?: ChatMessageDirection, +} + +export const ChatMessageCard = ({ + icon, + title, + subtitle, + badge, + actions, + color = 'primary', + direction = 'incoming', + children, + ...props +}: ChatMessageCardProps) => { + return ( +
+
+ {icon && ( + + {icon} + + )} + + {title} + {subtitle && ( + {subtitle} + )} + + {badge && ( + {badge} + )} +
+ {children && ( +
{children}
+ )} + {actions && ( +
{actions}
+ )} +
+ ) +} diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx new file mode 100644 index 00000000..848d9aa7 --- /dev/null +++ b/src/components/chat/ChatMessageList.tsx @@ -0,0 +1,26 @@ +import type { HTMLAttributes } from 'react' +import { useEffect, useRef } from 'react' + +export type ChatMessageListProps = HTMLAttributes & { + autoScroll?: boolean, +} + +export const ChatMessageList = ({ + autoScroll = true, + children, + ...props +}: ChatMessageListProps) => { + const ref = useRef(null) + + useEffect(() => { + if (autoScroll && ref.current) { + ref.current.scrollTop = ref.current.scrollHeight + } + }, [autoScroll, children]) + + return ( +
+ {children} +
+ ) +} diff --git a/src/components/chat/ChatQuickReplyChip.tsx b/src/components/chat/ChatQuickReplyChip.tsx new file mode 100644 index 00000000..5c15d1ad --- /dev/null +++ b/src/components/chat/ChatQuickReplyChip.tsx @@ -0,0 +1,23 @@ +import type { ButtonHTMLAttributes } from 'react' +import { PropsUtil } from '@/src/utils/propsUtil' + +export type ChatQuickReplyChipProps = ButtonHTMLAttributes & { + isActive?: boolean, +} + +export const ChatQuickReplyChip = ({ + isActive = false, + children, + ...props +}: ChatQuickReplyChipProps) => { + return ( + + ) +} diff --git a/src/components/chat/ChatSystemLine.tsx b/src/components/chat/ChatSystemLine.tsx new file mode 100644 index 00000000..74ed0458 --- /dev/null +++ b/src/components/chat/ChatSystemLine.tsx @@ -0,0 +1,28 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { CheckCheck } from 'lucide-react' +import type { ChipColor } from '../display-and-visualization/Chip' + +export type ChatSystemLineProps = HTMLAttributes & { + icon?: ReactNode, + color?: ChipColor, +} + +export const ChatSystemLine = ({ + icon, + color = 'primary', + children, + ...props +}: ChatSystemLineProps) => { + return ( +
+ + {icon ?? } + + {children} +
+ ) +} diff --git a/src/components/chat/ChatThreadHeader.tsx b/src/components/chat/ChatThreadHeader.tsx new file mode 100644 index 00000000..928a753a --- /dev/null +++ b/src/components/chat/ChatThreadHeader.tsx @@ -0,0 +1,36 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import type { AvatarWithStatusProps } from '../display-and-visualization/Avatar' +import { AvatarWithStatus } from '../display-and-visualization/Avatar' + +export type ChatThreadHeaderProps = HTMLAttributes & { + avatar?: AvatarWithStatusProps, + title: ReactNode, + subtitle?: ReactNode, + leading?: ReactNode, + trailing?: ReactNode, +} + +export const ChatThreadHeader = ({ + avatar, + title, + subtitle, + leading, + trailing, + ...props +}: ChatThreadHeaderProps) => { + return ( +
+ {leading} + {avatar && ()} + + {title} + {subtitle && ( + {subtitle} + )} + + {trailing && ( + {trailing} + )} +
+ ) +} diff --git a/src/style/theme/components/chat.css b/src/style/theme/components/chat.css new file mode 100644 index 00000000..b358380a --- /dev/null +++ b/src/style/theme/components/chat.css @@ -0,0 +1,312 @@ +@layer components { + [data-name="chat-message-bubble-container"] { + @apply flex-col-1 max-w-70; + + &[data-direction="incoming"] { + @apply self-start items-start; + } + + &[data-direction="outgoing"] { + @apply self-end items-end; + } + } + + [data-name="chat-message-bubble"] { + @apply flex-col-1 px-3.75 py-2.75 rounded-xl; + + &[data-direction="incoming"] { + @apply rounded-bl-sm bg-neutral text-on-neutral; + } + + &[data-direction="outgoing"] { + @apply rounded-br-sm bg-primary text-on-primary; + } + } + + [data-name="chat-message-bubble-content"] { + @apply text-base/relaxed font-light; + } + + [data-name="chat-message-bubble-timestamp"] { + @apply self-end text-xs font-medium opacity-75; + } + + [data-name="chat-message-bubble-receipt"] { + @apply flex-row-1 items-center text-xs font-medium text-description; + } + + [data-name="chat-message-bubble-receipt-icon"] { + @apply size-3.5 text-primary; + } + + [data-name="chat-message-card"] { + @apply flex-col-0 shrink-0 w-full max-w-75 rounded-xl border border-divider bg-surface text-on-surface shadow-around-md overflow-hidden; + + &[data-direction="incoming"] { + @apply self-start rounded-bl-sm; + } + + &[data-direction="outgoing"] { + @apply self-end rounded-br-sm; + } + } + + [data-name="chat-message-card-header"] { + @apply flex-row-0 gap-x-2.5 items-center px-3.75 py-3 border-b border-divider; + } + + [data-name="chat-message-card-icon"] { + @apply flex-row-0 items-center justify-center size-9 rounded-md coloring-color-detect coloring-tonal; + + > svg { + @apply size-4.5; + } + } + + [data-name="chat-message-card-heading"] { + @apply flex-col-0 gap-y-0.5 grow min-w-0; + } + + [data-name="chat-message-card-title"] { + @apply text-sm font-space font-bold coloring-color-detect coloring-text; + } + + [data-name="chat-message-card-subtitle"] { + @apply text-xs text-description; + } + + [data-name="chat-message-card-badge"] { + @apply shrink-0; + } + + [data-name="chat-message-card-body"] { + @apply flex-col-1 px-3.75 py-3; + } + + [data-name="chat-message-card-actions"] { + @apply flex-row-0 gap-x-2.5 px-3.75 pb-3.75; + + > * { + @apply flex-1; + } + + > [data-name="button"] { + @apply min-w-0; + } + } + + [data-name="chat-attachment-card"] { + @apply flex-row-3 shrink-0 items-center w-full max-w-70 p-3 rounded-xl border border-divider bg-surface text-on-surface shadow-around-md; + + &[data-direction="incoming"] { + @apply self-start rounded-bl-sm; + } + + &[data-direction="outgoing"] { + @apply self-end rounded-br-sm; + } + } + + [data-name="chat-attachment-card-icon"] { + @apply flex-row-0 shrink-0 items-center justify-center size-11 rounded-md negative coloring-tonal; + + > svg { + @apply size-5.5; + } + } + + [data-name="chat-attachment-card-info"] { + @apply flex-col-0 gap-y-0.5 grow min-w-0; + } + + [data-name="chat-attachment-card-filename"] { + @apply text-sm font-medium truncate; + } + + [data-name="chat-attachment-card-metadata"] { + @apply text-xs text-description; + } + + [data-name="chat-system-line"] { + @apply flex-row-0 gap-x-1.5 self-center items-center justify-center text-xs font-medium coloring-color-detect coloring-text; + } + + [data-name="chat-system-line-icon"] { + @apply flex-row-0 items-center; + + > svg { + @apply size-3.5; + } + } + + [data-name="chat-date-divider"] { + @apply self-center px-3.5 py-1 rounded-full bg-surface text-description text-xs font-medium shadow-around-md; + } + + [data-name="chat-quick-reply-chip"] { + @apply flex-row-0 gap-x-1.5 items-center w-fit px-3.5 py-1.5 rounded-full border text-sm font-medium cursor-pointer; + @apply border-divider text-description bg-surface hover:bg-surface-hover; + transition: var(--coloring-transitions); + + &[data-active] { + @apply border-primary text-primary; + } + } + + [data-name="chat-composer"] { + @apply flex-row-3 items-center w-full px-3.5 py-3 border-t border-divider bg-surface text-on-surface; + } + + [data-name="chat-composer-input-container"] { + @apply flex-row-0 gap-x-2.5 grow items-center h-11 px-4 rounded-full bg-background; + + &:focus-within { + @apply outline-2 outline-primary; + } + } + + [data-name="chat-composer-input"] { + @apply grow min-w-0 bg-transparent text-sm text-on-background outline-none placeholder:text-placeholder; + } + + .chat-composer-send-button { + @apply rounded-full; + } + + [data-name="chat-conversation-row"] { + @apply flex-row-3 items-center w-full px-3.5 py-3 text-left bg-transparent cursor-pointer; + transition: var(--coloring-transitions); + + &:hover { + @apply bg-surface-hover; + } + + &[data-selected] { + @apply bg-background; + } + } + + [data-name="chat-conversation-row-content"] { + @apply flex-col-1 grow min-w-0; + } + + [data-name="chat-conversation-row-top"] { + @apply flex-row-2 items-baseline justify-between; + } + + [data-name="chat-conversation-row-title"] { + @apply text-base font-space font-medium truncate; + + [data-name="chat-conversation-row"][data-unread] & { + @apply font-bold; + } + } + + [data-name="chat-conversation-row-timestamp"] { + @apply shrink-0 text-xs text-description; + + [data-name="chat-conversation-row"][data-unread] & { + @apply text-primary font-medium; + } + } + + [data-name="chat-conversation-row-bottom"] { + @apply flex-row-2 items-center justify-between; + } + + [data-name="chat-conversation-row-preview"] { + @apply flex-row-1 items-center min-w-0 text-sm text-description; + + [data-name="chat-conversation-row"][data-unread] & { + @apply text-on-surface; + } + } + + [data-name="chat-conversation-row-sent-indicator"] { + @apply size-3.5 shrink-0 text-primary; + } + + [data-name="chat-conversation-row-unread-count"] { + @apply flex-row-0 shrink-0 items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary text-on-primary text-xs font-bold; + } + + [data-name="chat-conversation-list"] { + @apply flex-col-0 h-full min-h-0 bg-surface text-on-surface; + } + + [data-name="chat-conversation-list-header"] { + @apply flex-col-3 px-3.5 py-3.5; + } + + [data-name="chat-conversation-list-scroll"] { + @apply flex-col-0 grow min-h-0 overflow-y-auto overscroll-y-contain; + } + + [data-name="chat-conversation-list-footer"] { + @apply flex-col-0 px-3.5 py-2; + } + + [data-name="chat-thread-header"] { + @apply flex-row-0 gap-x-2.5 items-center px-3.5 py-3 border-b border-divider bg-surface text-on-surface; + } + + [data-name="chat-thread-header-info"] { + @apply flex-col-0 gap-y-0.5 grow min-w-0; + } + + [data-name="chat-thread-header-title"] { + @apply text-base font-space font-bold truncate; + } + + [data-name="chat-thread-header-subtitle"] { + @apply text-xs font-light text-description truncate; + } + + [data-name="chat-thread-header-trailing"] { + @apply flex-row-1 shrink-0 items-center; + } + + [data-name="chat-message-list"] { + @apply flex-col-3 grow min-h-0 overflow-y-auto overscroll-y-contain px-4 py-4.5 bg-background text-on-background; + + > * { + @apply shrink-0; + } + } + + [data-name="chat-thread"] { + @apply flex-col-0 h-full min-h-0; + } + + [data-name="chat-layout"] { + @apply flex-row-0 h-full min-h-0 w-full rounded-lg border border-divider bg-surface text-on-surface overflow-hidden; + } + + [data-name="chat-layout-sidebar"] { + @apply flex-col-0 w-full min-h-0; + + @variant tablet { + @apply w-90 shrink-0 border-r border-divider; + } + + [data-name="chat-layout"][data-conversation-open] & { + @apply hidden; + + @variant tablet { + @apply flex; + } + } + } + + [data-name="chat-layout-thread"] { + @apply flex-col-0 grow min-w-0 min-h-0; + + [data-name="chat-layout"]:not([data-conversation-open]) & { + @apply hidden; + + @variant tablet { + @apply flex; + } + } + } +} diff --git a/src/style/theme/components/index.css b/src/style/theme/components/index.css index 5a56cc5d..ed58b082 100644 --- a/src/style/theme/components/index.css +++ b/src/style/theme/components/index.css @@ -35,3 +35,4 @@ @import "./vertical-navigation.css"; @import "./app-page.css"; @import "./searchbar.css"; +@import "./chat.css"; diff --git a/stories/Branding/AppZumDocBadge.stories.tsx b/stories/Branding/AppZumDocBadge.stories.tsx new file mode 100644 index 00000000..ffffdd09 --- /dev/null +++ b/stories/Branding/AppZumDocBadge.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { AppZumDocBadge } from '@/src/components/branding/AppZumDocBadge' + +const meta = { + component: AppZumDocBadge, +} satisfies Meta + +export default meta +type Story = StoryObj; + +export const appZumDocBadge: Story = { + args: { + size: 'sm', + title: 'App zum Doc', + className: '' + }, +} diff --git a/stories/Branding/AppZumDocLogo.stories.tsx b/stories/Branding/AppZumDocLogo.stories.tsx new file mode 100644 index 00000000..f35b8a10 --- /dev/null +++ b/stories/Branding/AppZumDocLogo.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { AppZumDocLogo } from '@/src/components/branding/AppZumDocLogo' + +const meta = { + component: AppZumDocLogo, +} satisfies Meta + +export default meta +type Story = StoryObj; + +export const appZumDocLogo: Story = { + args: { + size: 'md', + frontColor: '#095763', + backColor: '#4E97A2', + className: '' + }, +} diff --git a/stories/Chat/ChatAttachmentCard.stories.tsx b/stories/Chat/ChatAttachmentCard.stories.tsx new file mode 100644 index 00000000..6ab0e427 --- /dev/null +++ b/stories/Chat/ChatAttachmentCard.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { action } from 'storybook/actions' +import { ChatAttachmentCard } from '@/src/components/chat/ChatAttachmentCard' + +const meta: Meta = { + component: ChatAttachmentCard, +} + +export default meta +type Story = StoryObj + +export const chatAttachmentCard: Story = { + args: { + name: 'Befund_Blutbild.pdf', + metadata: 'PDF · 196 KB · 09:21', + direction: 'incoming', + downloadLabel: 'Herunterladen', + onDownload: action('onDownload'), + }, +} diff --git a/stories/Chat/ChatComposer.stories.tsx b/stories/Chat/ChatComposer.stories.tsx new file mode 100644 index 00000000..3aa201c4 --- /dev/null +++ b/stories/Chat/ChatComposer.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { action } from 'storybook/actions' +import { Camera, Paperclip } from 'lucide-react' +import { ChatComposer } from '@/src/components/chat/ChatComposer' +import { IconButton } from '@/src/components/user-interaction/IconButton' + +const meta: Meta = { + component: ChatComposer, +} + +export default meta +type Story = StoryObj + +export const chatComposer: Story = { + args: { + placeholder: 'Nachricht an Dr. med. Sophie Vogt schreiben', + sendLabel: 'Senden', + disabled: false, + onSend: action('onSend'), + }, + render: (args) => ( +
+ + + + + + + + + )} + /> +
+ ), +} diff --git a/stories/Chat/ChatConversationList.stories.tsx b/stories/Chat/ChatConversationList.stories.tsx new file mode 100644 index 00000000..72d9a060 --- /dev/null +++ b/stories/Chat/ChatConversationList.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { action } from 'storybook/actions' +import { SquarePen } from 'lucide-react' +import { ChatConversationList } from '@/src/components/chat/ChatConversationList' +import { ChatConversationRow } from '@/src/components/chat/ChatConversationRow' +import { SearchBar } from '@/src/components/user-interaction/input/SearchBar' +import { IconButton } from '@/src/components/user-interaction/IconButton' + +const meta: Meta = { + component: ChatConversationList, +} + +export default meta +type Story = StoryObj + +export const chatConversationList: Story = { + args: {}, + render: (args) => ( +
+ +
+ Chats + + + +
+ + + )} + > + + + +
+
+ ), +} diff --git a/stories/Chat/ChatConversationRow.stories.tsx b/stories/Chat/ChatConversationRow.stories.tsx new file mode 100644 index 00000000..15bbc3b6 --- /dev/null +++ b/stories/Chat/ChatConversationRow.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { action } from 'storybook/actions' +import { ChatConversationRow } from '@/src/components/chat/ChatConversationRow' + +const meta: Meta = { + component: ChatConversationRow, +} + +export default meta +type Story = StoryObj + +export const chatConversationRow: Story = { + args: { + avatar: { + name: 'Jonas Wellermann', + status: 'online', + image: { + avatarUrl: 'https://cdn.helpwave.de/test-avatar.svg', + alt: 'profile picture', + }, + }, + title: 'Jonas Wellermann', + timestamp: '14:22', + preview: 'Perfekt, ich habe den Befund erhalten. Bis Mittwoch!', + unreadCount: 2, + isSelected: false, + isUnread: true, + hasSentIndicator: false, + onClick: action('onClick'), + }, + render: (args) => ( +
+ + + +
+ ), +} diff --git a/stories/Chat/ChatDateDivider.stories.tsx b/stories/Chat/ChatDateDivider.stories.tsx new file mode 100644 index 00000000..78b66792 --- /dev/null +++ b/stories/Chat/ChatDateDivider.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { ChatDateDivider } from '@/src/components/chat/ChatDateDivider' + +const meta: Meta = { + component: ChatDateDivider, +} + +export default meta +type Story = StoryObj + +export const chatDateDivider: Story = { + args: { + children: 'Heute · 09:10', + }, + render: (args) => ( +
+ +
+ ), +} diff --git a/stories/Chat/ChatLayout.stories.tsx b/stories/Chat/ChatLayout.stories.tsx new file mode 100644 index 00000000..224bff7a --- /dev/null +++ b/stories/Chat/ChatLayout.stories.tsx @@ -0,0 +1,282 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useState } from 'react' +import { CalendarDays, Camera, ChevronLeft, EllipsisVertical, Paperclip, Phone, Pill, SquarePen, UserRound } from 'lucide-react' +import type { AvatarStatus } from '@/src/components/display-and-visualization/Avatar' +import { ChatLayout, ChatThread } from '@/src/components/chat/ChatLayout' +import { ChatConversationList } from '@/src/components/chat/ChatConversationList' +import { ChatConversationRow } from '@/src/components/chat/ChatConversationRow' +import { ChatThreadHeader } from '@/src/components/chat/ChatThreadHeader' +import { ChatMessageList } from '@/src/components/chat/ChatMessageList' +import { ChatMessageBubble } from '@/src/components/chat/ChatMessageBubble' +import { ChatMessageCard } from '@/src/components/chat/ChatMessageCard' +import { ChatAttachmentCard } from '@/src/components/chat/ChatAttachmentCard' +import { ChatDateDivider } from '@/src/components/chat/ChatDateDivider' +import { ChatSystemLine } from '@/src/components/chat/ChatSystemLine' +import { ChatQuickReplyChip } from '@/src/components/chat/ChatQuickReplyChip' +import { ChatComposer } from '@/src/components/chat/ChatComposer' +import { Chip } from '@/src/components/display-and-visualization/Chip' +import { Button } from '@/src/components/user-interaction/Button' +import { IconButton } from '@/src/components/user-interaction/IconButton' +import { SearchBar } from '@/src/components/user-interaction/input/SearchBar' + +type DemoMessage = { + id: string, + direction: 'incoming' | 'outgoing', + content: string, + timestamp: string, + readReceipt?: string, +} + +type DemoConversation = { + id: string, + name: string, + status: AvatarStatus, + meta: string, + timestamp: string, + preview: string, + unreadCount?: number, + isUnread?: boolean, + hasSentIndicator?: boolean, +} + +const conversations: DemoConversation[] = [ + { + id: 'wellermann', + name: 'Jonas Wellermann', + status: 'online', + meta: 'geb. 14.03.1982 · Vers.-Nr. K220541880 · GKV', + timestamp: '14:22', + preview: 'Perfekt, ich habe den Befund erhalten.', + unreadCount: 2, + isUnread: true, + }, + { + id: 'otte', + name: 'Miriam Otte', + status: 'offline', + meta: 'geb. 02.11.1974 · Vers.-Nr. M110236775 · GKV', + timestamp: 'Gestern', + preview: 'Vielen Dank für die schnelle Rückmeldung.', + hasSentIndicator: true, + }, + { + id: 'hagen', + name: 'Bernd Hagen', + status: 'offline', + meta: 'geb. 29.06.1958 · Vers.-Nr. B290658214 · PKV', + timestamp: 'Mo.', + preview: 'Das Rezept ist unterwegs.', + hasSentIndicator: true, + }, +] + +const initialMessages: DemoMessage[] = [ + { + id: 'm1', + direction: 'incoming', + content: 'Guten Tag, ich bräuchte ein Folgerezept für Ramipril 5mg. Die Packung reicht noch bis Ende der Woche.', + timestamp: '13:58', + }, + { + id: 'm2', + direction: 'outgoing', + content: 'Guten Tag Herr Wellermann, danke für Ihre Nachricht. Wir schauen uns das direkt an.', + timestamp: '14:05', + }, + { + id: 'm3', + direction: 'outgoing', + content: 'Passt Ihnen zusätzlich ein kurzer Termin zur Besprechung Ihrer Blutwerte?', + timestamp: '14:10', + readReceipt: 'Gelesen', + }, +] + +const meta: Meta = { + component: ChatLayout, +} + +export default meta +type Story = StoryObj + +export const chatLayout: Story = { + args: {}, + decorators: [ + (Story) => ( + <> + +
+ +
+ + ), + ], + render: (args) => { + const [selectedId, setSelectedId] = useState('wellermann') + const [messages, setMessages] = useState(initialMessages) + const selected = conversations.find(conversation => conversation.id === selectedId) + + const sendMessage = (content: string) => { + setMessages(previous => [ + ...previous, + { + id: `m${previous.length + 1}`, + direction: 'outgoing', + content, + timestamp: new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }), + }, + ]) + } + + return ( + +
+ Chats + + + +
+ {}}/> + + )} + > + {conversations.map(conversation => ( + setSelectedId(conversation.id)} + /> + ))} + + )} + > + {selected && ( + setSelectedId(null)} + > + + + )} + trailing={( + <> + + + + + + + + + + + )} + /> + )} + footer={( + <> +
+ Termin bestätigen + Rezept ausstellen + Überweisung senden +
+ + + + + + + + + )} + /> + + )} + > + + Heute + {messages.slice(0, 1).map(message => ( + + {message.content} + + ))} + } + title="Rezept-Anfrage" + subtitle="Folgeverordnung" + direction="incoming" + badge={NEU} + actions={( + <> + + + + )} + > + Ramipril 5mg + N2 · 50 Stück · zuletzt 12.05.2026 + + } + title="Terminvorschlag" + subtitle="Besprechung Blutwerte · 30 Min" + direction="outgoing" + badge={AUSSTEHEND} + > + Mi. 8. Juli 2026 + 15:00 – 15:30 Uhr · Sprechzimmer 2 + + Termin bestätigt · Mi. 8. Juli, 15:00 Uhr + {}} + /> + {messages.slice(1).map(message => ( + + {message.content} + + ))} + +
+ )} +
+ ) + }, +} diff --git a/stories/Chat/ChatMessageBubble.stories.tsx b/stories/Chat/ChatMessageBubble.stories.tsx new file mode 100644 index 00000000..db79bb10 --- /dev/null +++ b/stories/Chat/ChatMessageBubble.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { ChatMessageDirection } from '@/src/components/chat/ChatMessageBubble' +import { ChatMessageBubble } from '@/src/components/chat/ChatMessageBubble' + +const meta: Meta = { + component: ChatMessageBubble, + argTypes: { + direction: { + control: 'select', + options: ['incoming', 'outgoing'] satisfies ChatMessageDirection[], + }, + }, +} + +export default meta +type Story = StoryObj + +export const chatMessageBubble: Story = { + args: { + direction: 'outgoing', + timestamp: '09:24', + readReceipt: 'Gelesen', + children: 'Perfekt, ich habe den Befund erhalten. Bis Mittwoch!', + }, + render: (args) => ( +
+ + Guten Tag Herr Wellermann, wir haben die Ergebnisse Ihrer Blutuntersuchung erhalten. + + +
+ ), +} diff --git a/stories/Chat/ChatMessageCard.stories.tsx b/stories/Chat/ChatMessageCard.stories.tsx new file mode 100644 index 00000000..99938870 --- /dev/null +++ b/stories/Chat/ChatMessageCard.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { CalendarDays, Pill, Send } from 'lucide-react' +import { ChatMessageCard } from '@/src/components/chat/ChatMessageCard' +import { Chip } from '@/src/components/display-and-visualization/Chip' +import { Button } from '@/src/components/user-interaction/Button' + +const meta: Meta = { + component: ChatMessageCard, +} + +export default meta +type Story = StoryObj + +export const appointmentProposal: Story = { + args: { + icon: , + title: 'Terminvorschlag', + subtitle: 'Besprechung Blutwerte · 30 Min', + color: 'primary', + direction: 'incoming', + badge: ( + AUSSTEHEND + ), + children: ( + <> + Mi. 8. Juli 2026 + 15:00 – 15:30 Uhr · Sprechzimmer 2 + + ), + actions: ( + <> + + + + ), + }, +} + +export const prescriptionRequest: Story = { + args: { + icon: , + title: 'Rezept-Anfrage', + subtitle: 'Folgeverordnung', + color: 'primary', + direction: 'incoming', + badge: ( + NEU + ), + children: ( + <> + Ramipril 5mg + N2 · 50 Stück · zuletzt 12.05.2026 + + ), + actions: ( + <> + + + + ), + }, +} + +export const referral: Story = { + args: { + icon: , + title: 'Überweisung', + subtitle: 'Kardiologie', + color: 'secondary', + direction: 'outgoing', + badge: ( + GESENDET + ), + children: ( + <> + Dr. med. K. Brandt + Kardiologische Praxis am Markt + + ), + }, +} diff --git a/stories/Chat/ChatMessageList.stories.tsx b/stories/Chat/ChatMessageList.stories.tsx new file mode 100644 index 00000000..47af34cd --- /dev/null +++ b/stories/Chat/ChatMessageList.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { CalendarDays } from 'lucide-react' +import { ChatMessageList } from '@/src/components/chat/ChatMessageList' +import { ChatMessageBubble } from '@/src/components/chat/ChatMessageBubble' +import { ChatMessageCard } from '@/src/components/chat/ChatMessageCard' +import { ChatAttachmentCard } from '@/src/components/chat/ChatAttachmentCard' +import { ChatDateDivider } from '@/src/components/chat/ChatDateDivider' +import { ChatSystemLine } from '@/src/components/chat/ChatSystemLine' +import { Chip } from '@/src/components/display-and-visualization/Chip' +import { Button } from '@/src/components/user-interaction/Button' + +const meta: Meta = { + component: ChatMessageList, +} + +export default meta +type Story = StoryObj + +export const chatMessageList: Story = { + args: { + autoScroll: true, + }, + render: (args) => ( +
+ + Heute · 09:10 + + Guten Tag Herr Wellermann, wir haben die Ergebnisse Ihrer Blutuntersuchung erhalten und würden die Werte gerne mit Ihnen besprechen. + + } + title="Terminvorschlag" + subtitle="Besprechung Blutwerte · 30 Min" + direction="incoming" + badge={AUSSTEHEND} + actions={( + <> + + + + )} + > + Mi. 8. Juli 2026 + 15:00 – 15:30 Uhr · Sprechzimmer 2 + + + Vielen Dank. 15:00 Uhr passt mir gut – ich komme vorbei. + + Termin bestätigt · Mi. 8. Juli, 15:00 Uhr + {}} + /> + + Perfekt, ich habe den Befund erhalten. Bis Mittwoch! + + +
+ ), +} diff --git a/stories/Chat/ChatQuickReplyChip.stories.tsx b/stories/Chat/ChatQuickReplyChip.stories.tsx new file mode 100644 index 00000000..e1eaccd5 --- /dev/null +++ b/stories/Chat/ChatQuickReplyChip.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { action } from 'storybook/actions' +import { ChatQuickReplyChip } from '@/src/components/chat/ChatQuickReplyChip' + +const meta: Meta = { + component: ChatQuickReplyChip, +} + +export default meta +type Story = StoryObj + +export const chatQuickReplyChip: Story = { + args: { + isActive: true, + children: 'Termin bestätigen', + onClick: action('onClick'), + }, + render: (args) => ( +
+ + Rezept ausstellen + Überweisung senden +
+ ), +} diff --git a/stories/Chat/ChatSystemLine.stories.tsx b/stories/Chat/ChatSystemLine.stories.tsx new file mode 100644 index 00000000..2d81381d --- /dev/null +++ b/stories/Chat/ChatSystemLine.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { ChatSystemLine } from '@/src/components/chat/ChatSystemLine' + +const meta: Meta = { + component: ChatSystemLine, +} + +export default meta +type Story = StoryObj + +export const chatSystemLine: Story = { + args: { + color: 'primary', + children: 'Termin bestätigt · Mi. 8. Juli, 15:00 Uhr', + }, +} diff --git a/stories/Chat/ChatThreadHeader.stories.tsx b/stories/Chat/ChatThreadHeader.stories.tsx new file mode 100644 index 00000000..415b848a --- /dev/null +++ b/stories/Chat/ChatThreadHeader.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { EllipsisVertical, Phone, UserRound } from 'lucide-react' +import { ChatThreadHeader } from '@/src/components/chat/ChatThreadHeader' +import { IconButton } from '@/src/components/user-interaction/IconButton' + +const meta: Meta = { + component: ChatThreadHeader, +} + +export default meta +type Story = StoryObj + +export const chatThreadHeader: Story = { + args: { + avatar: { + name: 'Jonas Wellermann', + status: 'online', + }, + title: 'Jonas Wellermann', + subtitle: 'geb. 14.03.1982 · Vers.-Nr. K220541880 · GKV', + }, + render: (args) => ( +
+ + + + + + + + + + + + )} + /> +
+ ), +} From 8133f36469f7fea872bd58013d1618e67e555988 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:34:03 +0000 Subject: [PATCH 2/6] refactor: address chat component review feedback Style chat components via class names with className forwarding, drop the default border and rounding on ChatLayout, and render action buttons only when their handler is provided (onBack/onCall/onAddContact on ChatThreadHeader, onCamera/onAttachment on ChatComposer, onCreate and the toggleable built-in SearchBar on ChatConversationList). ChatLayout gains a listPosition option and tablet/desktop responsive sidebar widths. AppZumDocLogo gains a loading animation that fills both logo parts clockwise. TimeDisplay gains a time-only mode used for chat timestamps. Add unit tests for the chat and App zum Doc branding components. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- CHANGELOG.md | 16 +- src/components/branding/AppZumDocLogo.tsx | 81 +++- src/components/chat/ChatAttachmentCard.tsx | 11 +- src/components/chat/ChatComposer.tsx | 41 +- src/components/chat/ChatConversationList.tsx | 54 ++- src/components/chat/ChatConversationRow.tsx | 19 +- src/components/chat/ChatDateDivider.tsx | 3 +- src/components/chat/ChatLayout.tsx | 14 +- src/components/chat/ChatMessageBubble.tsx | 13 +- src/components/chat/ChatMessageCard.tsx | 19 +- src/components/chat/ChatMessageList.tsx | 3 +- src/components/chat/ChatQuickReplyChip.tsx | 3 +- src/components/chat/ChatSystemLine.tsx | 5 +- src/components/chat/ChatThreadHeader.tsx | 63 ++- .../user-interaction/date/TimeDisplay.tsx | 17 +- src/style/theme/components/chat.css | 148 ++++--- src/style/utitlity/animation.css | 29 ++ stories/Branding/AppZumDocLogo.stories.tsx | 18 + stories/Chat/ChatComposer.stories.tsx | 33 +- stories/Chat/ChatConversationList.stories.tsx | 44 ++- stories/Chat/ChatDateDivider.stories.tsx | 10 +- stories/Chat/ChatLayout.stories.tsx | 106 +++--- stories/Chat/ChatThreadHeader.stories.tsx | 37 +- tests/branding/appZumDoc.test.tsx | 40 ++ tests/chat/chatComponents.test.tsx | 360 ++++++++++++++++++ 25 files changed, 938 insertions(+), 249 deletions(-) create mode 100644 tests/branding/appZumDoc.test.tsx create mode 100644 tests/chat/chatComponents.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e401bc..17517c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,22 +9,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- `AppZumDocLogo` component with the official App zum Doc logo and configurable `frontColor`, `backColor`, and `size` +- `AppZumDocLogo` component with the official App zum Doc logo, configurable `frontColor`, `backColor`, and `size`, and an `animate` prop (`loading` fills both logo parts clockwise, plus `pulse` and `bounce`) - `AppZumDocBadge` component with the App zum Doc logo and wordmark, mirroring `HelpwaveBadge` -- Chat component set for the App zum Doc messaging surfaces: - - `ChatLayout` two-pane layout (conversation list + open thread) with responsive single-pane behavior below the tablet breakpoint, nestable into `AppPage` +- Chat component set for the App zum Doc messaging surfaces, styled via class names with `className` forwarding on every component: + - `ChatLayout` two-pane layout (conversation list + open thread) with responsive single-pane behavior below the tablet breakpoint, a `listPosition` option (`left`/`right`), no default border or rounding, nestable into `AppPage` - `ChatThread` column layout with `header` and `footer` slots - - `ChatConversationList` with optional `header` and `footer` slots and a scrollable row area + - `ChatConversationList` with optional `title`, optional create button (`onCreate`), toggleable built-in `SearchBar` (`hasSearch`, `onSearch`), and `header`/`footer` slots around a scrollable row area - `ChatConversationRow` with avatar presence, timestamp, preview, sent indicator, unread count pill, and `selected`/`unread` states - - `ChatThreadHeader` with avatar presence, title, subtitle, and `leading`/`trailing` slots + - `ChatThreadHeader` with avatar presence, title, subtitle, optional action buttons rendered only when their handler is set (`onBack`, `onCall`, `onAddContact`), and `leading`/`trailing` slots - `ChatMessageList` scrollable message pane with optional `autoScroll` - `ChatMessageBubble` incoming/outgoing bubble with asymmetric radii, inline timestamp, and optional read receipt - `ChatMessageCard` structured message card (e.g. appointment proposal, prescription request, referral) with icon, title, subtitle, badge, body, and action footer - - `ChatAttachmentCard` with file icon, filename, metadata, and download action - - `ChatComposer` with controlled or uncontrolled value, `leading`/`trailing` slots, and enter-to-send + - `ChatAttachmentCard` with file icon, filename, metadata, and a download action shown only when `onDownload` is set + - `ChatComposer` with controlled or uncontrolled value, optional camera and attachment buttons rendered only when their handler is set (`onCamera`, `onAttachment`), `leading`/`trailing` slots, and enter-to-send - `ChatQuickReplyChip` outlined pill button with active state - `ChatSystemLine` centered status line with icon - `ChatDateDivider` centered date pill +- `time` mode for `TimeDisplay` rendering only the localized time, usable for chat timestamps +- Unit tests for the chat component set and the App zum Doc branding components ## [0.14.1] - 2026-07-05 diff --git a/src/components/branding/AppZumDocLogo.tsx b/src/components/branding/AppZumDocLogo.tsx index 909ab9cc..c3a83988 100644 --- a/src/components/branding/AppZumDocLogo.tsx +++ b/src/components/branding/AppZumDocLogo.tsx @@ -1,21 +1,49 @@ -import type { SVGProps } from 'react' +import type { CSSProperties, SVGProps } from 'react' +import { useId } from 'react' import { clsx } from 'clsx' +const backFillPath = 'M33.1817 37.511L29.7804 37.511C21.7695 37.511 15.2754 44.0051 15.2754 52.016C15.2754 60.0268 21.7695 66.5209 29.7804 66.5209H35.4017C35.4208 66.5209 35.4274 66.5239 35.4301 66.5251C35.436 66.5275 35.4456 66.5331 35.4559 66.5434C35.4662 66.5536 35.4717 66.5633 35.4742 66.5691C35.4754 66.5719 35.4783 66.5785 35.4783 66.5975L35.4783 72.852C35.4783 80.8628 41.9724 87.3569 49.9833 87.3569C57.9942 87.3569 64.4883 80.8628 64.4883 72.852L64.4883 54.7684C64.4883 45.2374 56.7619 37.511 47.2309 37.511L43.6515 37.511V42.1072L47.2309 42.1072C54.2235 42.1072 59.8921 47.7758 59.8921 54.7684V72.852C59.8921 78.3244 55.4558 82.7608 49.9833 82.7608C44.5108 82.7608 40.0745 78.3244 40.0745 72.852L40.0745 66.5975C40.0745 64.0168 37.9824 61.9248 35.4017 61.9248L29.7804 61.9248C24.3079 61.9248 19.8716 57.4884 19.8716 52.016C19.8716 46.5435 24.3079 42.1072 29.7804 42.1072L33.1817 42.1072V37.511Z' + +const frontFillPath = 'M65.0734 29.3412C65.0734 21.3303 58.5793 14.8362 50.5685 14.8362C42.5576 14.8362 36.0635 21.3303 36.0635 29.3412V47.4247C36.0635 56.9557 43.7899 64.6821 53.3209 64.6821H57.4673V60.086H53.3209C46.3283 60.086 40.6597 54.4173 40.6597 47.4247V29.3412C40.6597 23.8687 45.096 19.4324 50.5685 19.4324C56.0409 19.4324 60.4773 23.8687 60.4773 29.3412V35.5956C60.4773 38.1763 62.5693 40.2684 65.15 40.2684H70.7714C76.2439 40.2684 80.6802 44.7047 80.6802 50.1772C80.6802 55.6496 76.2439 60.086 70.7714 60.086H66.7949V64.6821H70.7714C78.7823 64.6821 85.2764 58.188 85.2764 50.1772C85.2764 42.1663 78.7823 35.6722 70.7714 35.6722H65.15C65.131 35.6722 65.1244 35.6692 65.1216 35.668C65.1158 35.6656 65.1061 35.66 65.0959 35.6497C65.0856 35.6395 65.08 35.6298 65.0776 35.624L65.0775 35.6238C65.0763 35.6209 65.0734 35.6142 65.0734 35.5956V29.3412Z' + +const backStrokePath = 'M43.65 39.81 L47.23 39.81 A14.96 14.96 0 0 1 62.19 54.77 L62.19 72.85 A12.21 12.21 0 0 1 37.78 72.85 L37.78 66.6 Q37.78 64.22 35.4 64.22 L29.78 64.22 A12.21 12.21 0 0 1 29.78 39.81 L33.18 39.81' + +const frontStrokePath = 'M57.47 62.38 L53.32 62.38 A14.96 14.96 0 0 1 38.36 47.42 L38.36 29.34 A12.21 12.21 0 0 1 62.78 29.34 L62.78 35.6 Q62.78 37.97 65.15 37.97 L70.77 37.97 A12.21 12.21 0 0 1 70.77 62.38 L66.79 62.38' + export type AppZumDocLogoProps = SVGProps & { frontColor?: string, backColor?: string, + animate?: 'none' | 'loading' | 'pulse' | 'bounce', size?: 'sm' | 'md' | 'lg', + animationDuration?: number, } export const AppZumDocLogo = ({ frontColor = '#095763', backColor = '#4E97A2', + animate = 'none', size, + animationDuration = 1.7, ...props }: AppZumDocLogoProps) => { + const id = useId() + const isLoadingAnimation = animate === 'loading' + let svgAnimationKey = '' + + if (animate === 'pulse') { + svgAnimationKey = 'animate-pulse' + } else if (animate === 'bounce') { + svgAnimationKey = 'animate-bounce' + } + + const style = animationDuration !== undefined && isLoadingAnimation + ? { ...props.style, '--helpwave-loading-duration': `${animationDuration}s` } as CSSProperties + : props.style + return ( - - + + {isLoadingAnimation ? ( + <> + + + + + + + + + + + + + + ) : ( + <> + + + + )} + ) } diff --git a/src/components/chat/ChatAttachmentCard.tsx b/src/components/chat/ChatAttachmentCard.tsx index c04b59ae..1b4300c1 100644 --- a/src/components/chat/ChatAttachmentCard.tsx +++ b/src/components/chat/ChatAttachmentCard.tsx @@ -1,4 +1,5 @@ import type { HTMLAttributes, ReactNode } from 'react' +import clsx from 'clsx' import { Download, FileText } from 'lucide-react' import { IconButton } from '../user-interaction/IconButton' import type { ChatMessageDirection } from './ChatMessageBubble' @@ -24,16 +25,16 @@ export const ChatAttachmentCard = ({ return (
- + {icon ?? } - - {name} + + {name} {metadata && ( - {metadata} + {metadata} )} {onDownload && ( diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx index 85aede4f..d9b21817 100644 --- a/src/components/chat/ChatComposer.tsx +++ b/src/components/chat/ChatComposer.tsx @@ -1,5 +1,6 @@ import type { HTMLAttributes, ReactNode } from 'react' -import { SendHorizontal } from 'lucide-react' +import clsx from 'clsx' +import { Camera, Paperclip, SendHorizontal } from 'lucide-react' import { IconButton } from '../user-interaction/IconButton' import { useControlledState } from '@/src/hooks/useControlledState' @@ -11,6 +12,10 @@ export type ChatComposerProps = Omit, 'onChange'> placeholder?: string, sendLabel?: string, disabled?: boolean, + onCamera?: () => void, + cameraLabel?: string, + onAttachment?: () => void, + attachmentLabel?: string, leading?: ReactNode, trailing?: ReactNode, } @@ -23,6 +28,10 @@ export const ChatComposer = ({ placeholder, sendLabel = 'Send', disabled = false, + onCamera, + cameraLabel = 'Camera', + onAttachment, + attachmentLabel = 'Attach file', leading, trailing, ...props @@ -43,11 +52,35 @@ export const ChatComposer = ({ } return ( -
+
{leading} -
+ {onCamera && ( + + + + )} + {onAttachment && ( + + + + )} +
& { + title?: ReactNode, + onCreate?: () => void, + createLabel?: string, + hasSearch?: boolean, + searchPlaceholder?: string, + onSearch?: (value: string) => void, header?: ReactNode, footer?: ReactNode, } export const ChatConversationList = ({ + title, + onCreate, + createLabel = 'New chat', + hasSearch = false, + searchPlaceholder, + onSearch, header, footer, children, ...props }: ChatConversationListProps) => { + const hasHeader = !!title || !!onCreate || hasSearch || !!header return ( -
- {header && ( -
{header}
+
+ {hasHeader && ( +
+ {(title || onCreate) && ( +
+ {title && ( + {title} + )} + {onCreate && ( + + + + )} +
+ )} + {hasSearch && ( + {})} + /> + )} + {header} +
)} -
+
{children}
{footer && ( -
{footer}
+
{footer}
)}
) diff --git a/src/components/chat/ChatConversationRow.tsx b/src/components/chat/ChatConversationRow.tsx index e5ecaa9d..5accac80 100644 --- a/src/components/chat/ChatConversationRow.tsx +++ b/src/components/chat/ChatConversationRow.tsx @@ -1,4 +1,5 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react' +import clsx from 'clsx' import { Check } from 'lucide-react' import type { AvatarWithStatusProps } from '../display-and-visualization/Avatar' import { AvatarWithStatus } from '../display-and-visualization/Avatar' @@ -30,27 +31,27 @@ export const ChatConversationRow = ({ - + + )} > @@ -261,7 +245,7 @@ export const chatLayout: Story = { metadata="PDF · 196 KB · 14:18" direction="incoming" downloadLabel="Herunterladen" - onDownload={() => {}} + onDownload={action('onDownload')} /> {messages.slice(1).map(message => ( (
- - - - - - - - - - + + + )} />
), } + +export const withoutActions: Story = { + args: { + avatar: { + name: 'Miriam Otte', + status: 'offline', + }, + title: 'Miriam Otte', + subtitle: 'Hausarztpraxis Altstadt', + }, + render: (args) => ( +
+ +
+ ), +} diff --git a/tests/branding/appZumDoc.test.tsx b/tests/branding/appZumDoc.test.tsx new file mode 100644 index 00000000..4f838c05 --- /dev/null +++ b/tests/branding/appZumDoc.test.tsx @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react' +import { AppZumDocBadge } from '../../src/components/branding/AppZumDocBadge' +import { AppZumDocLogo } from '../../src/components/branding/AppZumDocLogo' + +describe('AppZumDocBadge', () => { + test('renders the wordmark with the logo', () => { + const { container } = render() + expect(screen.getByText('App zum Doc')).toBeTruthy() + expect(container.querySelector('svg')).toBeTruthy() + }) +}) + +describe('AppZumDocLogo', () => { + test('renders two filled paths by default', () => { + const { container } = render() + const paths = container.querySelectorAll('path[fill]') + expect(paths).toHaveLength(2) + expect(paths[0]!.getAttribute('fill')).toBe('#222222') + expect(paths[1]!.getAttribute('fill')).toBe('#111111') + expect(container.querySelector('clipPath')).toBeNull() + }) + + test('renders clipped animated strokes when loading', () => { + const { container } = render() + expect(container.querySelectorAll('clipPath')).toHaveLength(2) + const strokes = container.querySelectorAll('path[stroke]') + expect(strokes).toHaveLength(2) + expect(strokes[0]!.classList.contains('animate-azd-fill-back')).toBe(true) + expect(strokes[1]!.classList.contains('animate-azd-fill-front')).toBe(true) + }) + + test('applies the animation duration variable', () => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('style')).toContain('--helpwave-loading-duration: 3s') + }) +}) diff --git a/tests/chat/chatComponents.test.tsx b/tests/chat/chatComponents.test.tsx new file mode 100644 index 00000000..b33a74e0 --- /dev/null +++ b/tests/chat/chatComponents.test.tsx @@ -0,0 +1,360 @@ +/** + * @jest-environment jsdom + */ +import { useState } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { LocaleContext } from '../../src/global-contexts/LocaleContext' +import { ChatMessageBubble } from '../../src/components/chat/ChatMessageBubble' +import { ChatMessageCard } from '../../src/components/chat/ChatMessageCard' +import { ChatAttachmentCard } from '../../src/components/chat/ChatAttachmentCard' +import { ChatComposer } from '../../src/components/chat/ChatComposer' +import { ChatQuickReplyChip } from '../../src/components/chat/ChatQuickReplyChip' +import { ChatSystemLine } from '../../src/components/chat/ChatSystemLine' +import { ChatDateDivider } from '../../src/components/chat/ChatDateDivider' +import { ChatConversationRow } from '../../src/components/chat/ChatConversationRow' +import { ChatConversationList } from '../../src/components/chat/ChatConversationList' +import { ChatThreadHeader } from '../../src/components/chat/ChatThreadHeader' +import { ChatLayout, ChatThread } from '../../src/components/chat/ChatLayout' +import { ChatMessageList } from '../../src/components/chat/ChatMessageList' + +describe('ChatMessageBubble', () => { + test('renders content, timestamp and direction', () => { + const { container } = render( + Hello + ) + expect(screen.getByText('Hello')).toBeTruthy() + expect(screen.getByText('09:24')).toBeTruthy() + expect(container.querySelector('.chat-message-bubble-container')!.getAttribute('data-direction')).toBe('outgoing') + expect(container.querySelector('.chat-message-bubble')!.getAttribute('data-direction')).toBe('outgoing') + }) + + test('defaults to incoming and hides receipt when not provided', () => { + const { container } = render(Hi) + expect(container.querySelector('.chat-message-bubble')!.getAttribute('data-direction')).toBe('incoming') + expect(container.querySelector('.chat-message-bubble-receipt')).toBeNull() + }) + + test('shows the read receipt when provided', () => { + const { container } = render( + Hi + ) + expect(screen.getByText('Gelesen')).toBeTruthy() + expect(container.querySelector('.chat-message-bubble-receipt-icon')).toBeTruthy() + }) + + test('forwards className', () => { + const { container } = render(Hi) + expect(container.querySelector('.chat-message-bubble-container')!.classList.contains('custom')).toBe(true) + }) +}) + +describe('ChatMessageCard', () => { + test('renders header, body, badge and actions', () => { + const onAccept = jest.fn() + const { container } = render( + AUSSTEHEND} + direction="outgoing" + color="warning" + actions={} + > + Mi. 8. Juli 2026 + + ) + expect(screen.getByText('Terminvorschlag')).toBeTruthy() + expect(screen.getByText('30 Min')).toBeTruthy() + expect(screen.getByText('AUSSTEHEND')).toBeTruthy() + expect(screen.getByText('Mi. 8. Juli 2026')).toBeTruthy() + const card = container.querySelector('.chat-message-card')! + expect(card.getAttribute('data-direction')).toBe('outgoing') + expect(card.getAttribute('data-color')).toBe('warning') + fireEvent.click(screen.getByText('Zusagen')) + expect(onAccept).toHaveBeenCalled() + }) + + test('omits optional sections', () => { + const { container } = render() + expect(container.querySelector('.chat-message-card-body')).toBeNull() + expect(container.querySelector('.chat-message-card-actions')).toBeNull() + expect(container.querySelector('.chat-message-card-subtitle')).toBeNull() + expect(container.querySelector('.chat-message-card-badge')).toBeNull() + expect(container.querySelector('.chat-message-card-icon')).toBeNull() + }) +}) + +describe('ChatAttachmentCard', () => { + test('renders name and metadata', () => { + render() + expect(screen.getByText('Befund.pdf')).toBeTruthy() + expect(screen.getByText('PDF · 196 KB')).toBeTruthy() + }) + + test('shows the download button only when onDownload is provided', () => { + const onDownload = jest.fn() + const { rerender } = render() + expect(screen.queryByRole('button')).toBeNull() + rerender() + fireEvent.click(screen.getByRole('button')) + expect(onDownload).toHaveBeenCalled() + }) +}) + +describe('ChatComposer', () => { + test('sends the trimmed value on enter and clears the input', () => { + const onSend = jest.fn() + render() + const input = screen.getByPlaceholderText('Nachricht') as HTMLInputElement + fireEvent.change(input, { target: { value: ' Hallo ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSend).toHaveBeenCalledWith('Hallo') + expect(input.value).toBe('') + }) + + test('does not send empty values', () => { + const onSend = jest.fn() + render() + const input = screen.getByPlaceholderText('Nachricht') + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSend).not.toHaveBeenCalled() + }) + + test('sends via the send button', () => { + const onSend = jest.fn() + render() + fireEvent.change(screen.getByPlaceholderText('Nachricht'), { target: { value: 'Hi' } }) + fireEvent.click(screen.getAllByRole('button').at(-1)!) + expect(onSend).toHaveBeenCalledWith('Hi') + }) + + test('renders camera and attachment buttons only when handlers exist', () => { + const onCamera = jest.fn() + const onAttachment = jest.fn() + const { rerender } = render( {}} placeholder="Nachricht"/>) + expect(screen.getAllByRole('button')).toHaveLength(1) + rerender( + {}} + placeholder="Nachricht" + onCamera={onCamera} + onAttachment={onAttachment} + /> + ) + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + fireEvent.click(buttons[0]!) + expect(onCamera).toHaveBeenCalled() + fireEvent.click(buttons[1]!) + expect(onAttachment).toHaveBeenCalled() + }) + + test('supports controlled usage', () => { + const onValueChange = jest.fn() + const Wrapper = () => { + const [value, setValue] = useState('start') + return ( + { + onValueChange(next) + setValue(next) + }} + onSend={() => {}} + placeholder="Nachricht" + /> + ) + } + render() + const input = screen.getByPlaceholderText('Nachricht') as HTMLInputElement + expect(input.value).toBe('start') + fireEvent.change(input, { target: { value: 'changed' } }) + expect(onValueChange).toHaveBeenCalledWith('changed') + expect(input.value).toBe('changed') + }) +}) + +describe('ChatQuickReplyChip', () => { + test('sets the active state and handles clicks', () => { + const onClick = jest.fn() + const { rerender } = render(Termin) + const chip = screen.getByRole('button') + expect(chip.getAttribute('data-active')).toBeNull() + fireEvent.click(chip) + expect(onClick).toHaveBeenCalled() + rerender(Termin) + expect(chip.getAttribute('data-active')).toBe('') + }) +}) + +describe('ChatSystemLine', () => { + test('renders content with color', () => { + const { container } = render(Termin bestätigt) + expect(screen.getByText('Termin bestätigt')).toBeTruthy() + expect(container.querySelector('.chat-system-line')!.getAttribute('data-color')).toBe('positive') + }) +}) + +describe('ChatDateDivider', () => { + test('renders content', () => { + render(Heute) + expect(screen.getByText('Heute')).toBeTruthy() + }) +}) + +describe('ChatConversationRow', () => { + test('renders row data and handles clicks', () => { + const onClick = jest.fn() + render( + + ) + expect(screen.getByText('Jonas Wellermann')).toBeTruthy() + expect(screen.getByText('14:22')).toBeTruthy() + expect(screen.getByText('Bis Mittwoch!')).toBeTruthy() + expect(screen.getByText('2')).toBeTruthy() + const row = screen.getByRole('button') + expect(row.getAttribute('data-unread')).toBe('') + expect(row.getAttribute('data-selected')).toBeNull() + fireEvent.click(row) + expect(onClick).toHaveBeenCalled() + }) + + test('shows the sent indicator and selection state', () => { + const { container } = render( + + ) + expect(container.querySelector('.chat-conversation-row-sent-indicator')).toBeTruthy() + expect(screen.getByRole('button').getAttribute('data-selected')).toBe('') + expect(container.querySelector('.chat-conversation-row-unread-count')).toBeNull() + }) +}) + +describe('ChatConversationList', () => { + test('renders no header without header content', () => { + const { container } = render( + + row + + ) + expect(container.querySelector('.chat-conversation-list-header')).toBeNull() + expect(screen.getByText('row')).toBeTruthy() + }) + + test('renders title, create button and search when configured', () => { + const onCreate = jest.fn() + const onSearch = jest.fn() + render( + {} }}> + + + ) + expect(screen.getByText('Chats')).toBeTruthy() + const search = screen.getByPlaceholderText('Patient suchen') + fireEvent.change(search, { target: { value: 'Otte' } }) + expect(onSearch).toHaveBeenCalledWith('Otte') + fireEvent.click(screen.getAllByRole('button')[0]!) + expect(onCreate).toHaveBeenCalled() + }) + + test('hides the search bar when hasSearch is false', () => { + render() + expect(screen.queryByRole('textbox')).toBeNull() + }) +}) + +describe('ChatThreadHeader', () => { + test('renders title and subtitle without action buttons by default', () => { + render() + expect(screen.getByText('Jonas Wellermann')).toBeTruthy() + expect(screen.getByText('GKV')).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + }) + + test('renders back, call and add-contact buttons only when handlers exist', () => { + const onBack = jest.fn() + const onCall = jest.fn() + const onAddContact = jest.fn() + render( + + ) + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + fireEvent.click(buttons[0]!) + expect(onBack).toHaveBeenCalled() + fireEvent.click(buttons[1]!) + expect(onCall).toHaveBeenCalled() + fireEvent.click(buttons[2]!) + expect(onAddContact).toHaveBeenCalled() + }) +}) + +describe('ChatLayout', () => { + test('renders both panes with state attributes', () => { + const { container } = render( + list} isConversationOpen={true} listPosition="right"> + thread + + ) + const layout = container.querySelector('.chat-layout')! + expect(layout.getAttribute('data-conversation-open')).toBe('') + expect(layout.getAttribute('data-list-position')).toBe('right') + expect(screen.getByText('list')).toBeTruthy() + expect(screen.getByText('thread')).toBeTruthy() + }) + + test('defaults to left position and closed conversation', () => { + const { container } = render( + list}/> + ) + const layout = container.querySelector('.chat-layout')! + expect(layout.getAttribute('data-conversation-open')).toBeNull() + expect(layout.getAttribute('data-list-position')).toBe('left') + }) +}) + +describe('ChatThread', () => { + test('renders header, content and footer in order', () => { + const { container } = render( + header} footer={footer}> + content + + ) + const texts = Array.from(container.querySelector('.chat-thread')!.children).map(child => child.textContent) + expect(texts).toEqual(['header', 'content', 'footer']) + }) +}) + +describe('ChatMessageList', () => { + test('renders children', () => { + render( + + message + + ) + expect(screen.getByText('message')).toBeTruthy() + }) +}) From ac5c991ae97e0d9c1070d87b051188f3897b63f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:18:47 +0000 Subject: [PATCH 3/6] fix: refine App zum Doc logo loading animation Remove the stray dot at the hidden front stroke's path end by widening the dash gap and holding the offset past the dash boundary. Switch both fills to ease-in and retime the front window (33%-73%) so the front part sweeps through the teal opening at the center left exactly when the back part's tip arrives there. Restore the -webkit- prefixed wave keyframes from main alongside prefixed azd fill keyframes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- src/components/branding/AppZumDocLogo.tsx | 4 +- src/style/utitlity/animation.css | 89 +++++++++++++++++++++-- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/components/branding/AppZumDocLogo.tsx b/src/components/branding/AppZumDocLogo.tsx index c3a83988..71e9f8e6 100644 --- a/src/components/branding/AppZumDocLogo.tsx +++ b/src/components/branding/AppZumDocLogo.tsx @@ -69,7 +69,7 @@ export const AppZumDocLogo = ({ strokeWidth={7} strokeLinecap="round" pathLength={1000} - strokeDasharray="1000" + strokeDasharray="1000 2000" /> @@ -80,7 +80,7 @@ export const AppZumDocLogo = ({ strokeWidth={7} strokeLinecap="round" pathLength={1000} - strokeDasharray="1000" + strokeDasharray="1000 2000" /> diff --git a/src/style/utitlity/animation.css b/src/style/utitlity/animation.css index bd3d5631..cb3440df 100644 --- a/src/style/utitlity/animation.css +++ b/src/style/utitlity/animation.css @@ -7,6 +7,18 @@ } } +@-webkit-keyframes bigLeftUp { + 0%, + 25% { + stroke-dashoffset: 1000; + } + 50%, + 75%, + 100% { + stroke-dashoffset: 0; + } +} + @keyframes bigLeftUp { 0%, 25% { @@ -19,6 +31,18 @@ } } +@-webkit-keyframes bigRightDown { + 0%, + 25%, + 50% { + stroke-dashoffset: 0; + } + 70%, + 100% { + stroke-dashoffset: -1000; + } +} + @keyframes bigRightDown { 0%, 25%, @@ -31,6 +55,18 @@ } } +@-webkit-keyframes smallLeftUp { + 0%, + 25%, + 50% { + stroke-dashoffset: 1000; + } + 75%, + 100% { + stroke-dashoffset: 0; + } +} + @keyframes smallLeftUp { 0%, 25%, @@ -43,6 +79,18 @@ } } +@-webkit-keyframes smallRightDown { + 0%, + 25% { + stroke-dashoffset: 0; + } + 50%, + 75%, + 100% { + stroke-dashoffset: -1000; + } +} + @keyframes smallRightDown { 0%, 25% { @@ -55,11 +103,32 @@ } } +@-webkit-keyframes azdFillBack { + 0% { + stroke-dashoffset: 1005; + } + 50%, + 100% { + stroke-dashoffset: 0; + } +} + @keyframes azdFillBack { 0% { - stroke-dashoffset: 1000; + stroke-dashoffset: 1005; } - 45%, + 50%, + 100% { + stroke-dashoffset: 0; + } +} + +@-webkit-keyframes azdFillFront { + 0%, + 33% { + stroke-dashoffset: 1005; + } + 73%, 100% { stroke-dashoffset: 0; } @@ -67,10 +136,10 @@ @keyframes azdFillFront { 0%, - 35% { - stroke-dashoffset: 1000; + 33% { + stroke-dashoffset: 1005; } - 80%, + 73%, 100% { stroke-dashoffset: 0; } @@ -137,27 +206,33 @@ } @utility animate-wave-big-left-up { + -webkit-animation: bigLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: bigLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-wave-big-right-down { + -webkit-animation: bigRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; animation: bigRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; } @utility animate-wave-small-left-up { + -webkit-animation: smallLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: smallLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-wave-small-right-down { + -webkit-animation: smallRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; animation: smallRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; } @utility animate-azd-fill-back { - animation: azdFillBack var(--helpwave-loading-duration, 1.7s) ease-in-out infinite normal; + -webkit-animation: azdFillBack var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; + animation: azdFillBack var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-azd-fill-front { - animation: azdFillFront var(--helpwave-loading-duration, 1.7s) ease-in-out infinite normal; + -webkit-animation: azdFillFront var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; + animation: azdFillFront var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @layer utilities { From 548864d7c5606fa206555160c0e91f0197a0c51a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:29:39 +0000 Subject: [PATCH 4/6] refactor: drop hand-written webkit keyframes for azd fill animation Autoprefixer generates the -webkit- prefixed keyframes and animation declarations in the published CSS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- src/style/utitlity/animation.css | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/style/utitlity/animation.css b/src/style/utitlity/animation.css index cb3440df..6932a8ed 100644 --- a/src/style/utitlity/animation.css +++ b/src/style/utitlity/animation.css @@ -103,16 +103,6 @@ } } -@-webkit-keyframes azdFillBack { - 0% { - stroke-dashoffset: 1005; - } - 50%, - 100% { - stroke-dashoffset: 0; - } -} - @keyframes azdFillBack { 0% { stroke-dashoffset: 1005; @@ -123,17 +113,6 @@ } } -@-webkit-keyframes azdFillFront { - 0%, - 33% { - stroke-dashoffset: 1005; - } - 73%, - 100% { - stroke-dashoffset: 0; - } -} - @keyframes azdFillFront { 0%, 33% { @@ -226,12 +205,10 @@ } @utility animate-azd-fill-back { - -webkit-animation: azdFillBack var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: azdFillBack var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-azd-fill-front { - -webkit-animation: azdFillFront var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: azdFillFront var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } From c897defbdedcdbbd32bbcd5cddd9d9ac3cbd9472 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:46:01 +0000 Subject: [PATCH 5/6] refactor: remove hand-written webkit animation prefixes Autoprefixer generates all -webkit- keyframes and animation declarations in the published CSS, so the source keyframes stay plain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- src/style/utitlity/animation.css | 52 -------------------------------- 1 file changed, 52 deletions(-) diff --git a/src/style/utitlity/animation.css b/src/style/utitlity/animation.css index 6932a8ed..e3195090 100644 --- a/src/style/utitlity/animation.css +++ b/src/style/utitlity/animation.css @@ -7,18 +7,6 @@ } } -@-webkit-keyframes bigLeftUp { - 0%, - 25% { - stroke-dashoffset: 1000; - } - 50%, - 75%, - 100% { - stroke-dashoffset: 0; - } -} - @keyframes bigLeftUp { 0%, 25% { @@ -31,18 +19,6 @@ } } -@-webkit-keyframes bigRightDown { - 0%, - 25%, - 50% { - stroke-dashoffset: 0; - } - 70%, - 100% { - stroke-dashoffset: -1000; - } -} - @keyframes bigRightDown { 0%, 25%, @@ -55,18 +31,6 @@ } } -@-webkit-keyframes smallLeftUp { - 0%, - 25%, - 50% { - stroke-dashoffset: 1000; - } - 75%, - 100% { - stroke-dashoffset: 0; - } -} - @keyframes smallLeftUp { 0%, 25%, @@ -79,18 +43,6 @@ } } -@-webkit-keyframes smallRightDown { - 0%, - 25% { - stroke-dashoffset: 0; - } - 50%, - 75%, - 100% { - stroke-dashoffset: -1000; - } -} - @keyframes smallRightDown { 0%, 25% { @@ -185,22 +137,18 @@ } @utility animate-wave-big-left-up { - -webkit-animation: bigLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: bigLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-wave-big-right-down { - -webkit-animation: bigRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; animation: bigRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; } @utility animate-wave-small-left-up { - -webkit-animation: smallLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; animation: smallLeftUp var(--helpwave-loading-duration, 1.7s) ease-in infinite normal; } @utility animate-wave-small-right-down { - -webkit-animation: smallRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; animation: smallRightDown var(--helpwave-loading-duration, 1.7s) ease-in infinite reverse; } From 72cb89d87e8c12a20984451bf97529f143af0998 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 16:01:40 +0000 Subject: [PATCH 6/6] chore: keep only the default loading animation stories Drop the slow and fast loading-animation story variants for the helpwave and App zum Doc logos. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqYGuFVGFH73aWaiCkuj6s --- stories/Branding/AppZumDocLogo.stories.tsx | 9 --------- .../HelpwaveLoadingAnimation.stories.tsx | 20 ------------------- 2 files changed, 29 deletions(-) diff --git a/stories/Branding/AppZumDocLogo.stories.tsx b/stories/Branding/AppZumDocLogo.stories.tsx index 2a0c866e..42e57013 100644 --- a/stories/Branding/AppZumDocLogo.stories.tsx +++ b/stories/Branding/AppZumDocLogo.stories.tsx @@ -25,12 +25,3 @@ export const appZumDocLoadingAnimation: Story = { height: 128, }, } - -export const appZumDocLoadingAnimationSlow: Story = { - args: { - animate: 'loading', - animationDuration: 3, - width: 128, - height: 128, - }, -} diff --git a/stories/Branding/HelpwaveLoadingAnimation.stories.tsx b/stories/Branding/HelpwaveLoadingAnimation.stories.tsx index f7d4b93c..681cae32 100644 --- a/stories/Branding/HelpwaveLoadingAnimation.stories.tsx +++ b/stories/Branding/HelpwaveLoadingAnimation.stories.tsx @@ -16,23 +16,3 @@ export const helpwaveLoadingAnimation: Story = { height: 128, }, } - -export const helpwaveLoadingAnimationSlow: Story = { - args: { - color: 'currentColor', - animate: 'loading', - animationDuration: 3, - width: 128, - height: 128, - }, -} - -export const helpwaveLoadingAnimationFast: Story = { - args: { - color: 'currentColor', - animate: 'loading', - animationDuration: 0.8, - width: 128, - height: 128, - }, -}