diff --git a/apps/www/src/content/docs/ai-elements/message/demo.ts b/apps/www/src/content/docs/ai-elements/message/demo.ts new file mode 100644 index 000000000..6f44dc631 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/message/demo.ts @@ -0,0 +1,112 @@ +'use client'; + +export const preview = { + type: 'code', + code: ` + + + + + Assistant + + Here's the summary you asked for. + + 1:52 AM + + + + Thanks — looks great! + + +` +}; + +export const anatomyDemo = { + type: 'code', + code: ` + + + + + Assistant + + Hover me for actions. + + 1:52 AM + + + + + + + Me too — actions on my side. + + + + + +` +}; + +export const groupDemo = { + type: 'code', + code: ` + + + Ana + + Pushed the fix. + + + + + CI is green again. + + + + + + + + Deploying now. + + 1:54 AM + + + + + + Nice, thank you! + + + + + I'll verify on staging. + + 1:55 AM + + +` +}; + +export const bubbleVariantsDemo = { + type: 'code', + tabs: [ + { + name: 'Solid', + code: ` + The default: solid neutral. + High-emphasis accent bubble. + Something went wrong. +` + }, + { + name: 'Outline', + code: ` + Bordered bubble on the base background. + Accent outline bubble. + Danger outline bubble. +` + } + ] +}; diff --git a/apps/www/src/content/docs/ai-elements/message/index.mdx b/apps/www/src/content/docs/ai-elements/message/index.mdx new file mode 100644 index 000000000..cfb89efb5 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/message/index.mdx @@ -0,0 +1,85 @@ +--- +title: Message +description: Inert message layout — avatar, header, content, footer, hover actions and bubbles, aligned to either side of a conversation. +source: packages/raystack/components/message +tag: new +--- + +import { preview, anatomyDemo, groupDemo, bubbleVariantsDemo } from "./demo.ts"; + + + +## Anatomy + +```tsx +import { Message } from '@raystack/apsara' + + + {/* Avatar */} + Ana + + Hi there! + + 1:52 AM + {/* copy / edit */} + +``` + +`Message` is pure layout — no context, no effects, no chat coupling. It works +anywhere: inside a `Chat` scroller, a comment thread, or a standalone +transcript. Scroll behavior (visibility tracking, turn anchoring) belongs to +[Chat](/docs/ai-elements/chat) — wrap a message in `Chat.Item` to opt in. + +## API Reference + +### Message + +One message. `align` drives which side everything sits on. + + + +Sub-parts are layout slots: `Message.Avatar` (bottom-aligned beside the +message), `Message.Header` (sender line), `Message.Content` (the body +column), `Message.Footer` (timestamp line) and `Message.Actions` +(hover-revealed controls beside the bubble; always visible on touch devices). + +### Message.Group + +A flex column that pulls consecutive messages from the same sender closer +together. Render the avatar and footer once — typically on the group's last +message. + +### Message.Bubble + +The message surface. + + + +## Examples + +### Message anatomy + +Avatar, header, footer and hover-revealed actions around the content column. + + + +### Grouped messages + +Consecutive messages from one sender share a group; the avatar and timestamp +appear once. + + + +### Bubble variants + +`variant` picks the surface treatment (`solid` or `outline`) and `color` the +palette (`neutral`, `accent` or `danger`). + + + +## Accessibility + +- `Message.Actions` reveal on hover **and** on focus-within, and stay visible + on touch devices, so keyboard and touch users are not locked out. +- Message bodies are regular document content — screen readers announce them + in reading order with no extra semantics to configure. diff --git a/apps/www/src/content/docs/ai-elements/message/props.ts b/apps/www/src/content/docs/ai-elements/message/props.ts new file mode 100644 index 000000000..b1f055b9d --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/message/props.ts @@ -0,0 +1,29 @@ +export interface MessageProps { + /** + * Which side of the conversation the message sits on. `"end"` aligns the + * message to the trailing edge (the sender's own messages); `"start"` to + * the leading edge. + * @defaultValue "start" + */ + align?: 'start' | 'end'; + + /** Custom CSS class names. */ + className?: string; +} + +export interface MessageBubbleProps { + /** + * Visual style of the message surface. + * @defaultValue "solid" + */ + variant?: 'solid' | 'outline'; + + /** + * Color of the message surface. + * @defaultValue "neutral" + */ + color?: 'accent' | 'neutral' | 'danger'; + + /** Custom CSS class names. */ + className?: string; +} diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json index 88524ea4a..7482ceb5c 100644 --- a/apps/www/src/content/docs/ai-elements/meta.json +++ b/apps/www/src/content/docs/ai-elements/meta.json @@ -1,4 +1,4 @@ { "title": "AI Elements", - "pages": ["prompt-input"] + "pages": ["message", "prompt-input"] } diff --git a/packages/raystack/components/message/__tests__/message.test.tsx b/packages/raystack/components/message/__tests__/message.test.tsx new file mode 100644 index 000000000..7258c4fff --- /dev/null +++ b/packages/raystack/components/message/__tests__/message.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Message } from '../message'; +import styles from '../message.module.css'; + +describe('Message', () => { + it('renders alignment as a data attribute', () => { + render( + + hi + + ); + expect(screen.getByTestId('message')).toHaveAttribute('data-align', 'end'); + }); + + it('defaults to start alignment', () => { + render(hi); + expect(screen.getByTestId('message')).toHaveAttribute( + 'data-align', + 'start' + ); + }); + + it('renders all sub-parts with their classes', () => { + render( + + A + Ana + body + 1:52 + + + + + ); + + expect(screen.getByTestId('avatar')).toHaveClass(styles['message-avatar']); + expect(screen.getByTestId('header')).toHaveClass(styles['message-header']); + expect(screen.getByTestId('content')).toHaveClass( + styles['message-content'] + ); + expect(screen.getByTestId('footer')).toHaveClass(styles['message-footer']); + expect(screen.getByTestId('actions')).toHaveClass( + styles['message-actions'] + ); + }); + + it('wraps consecutive messages in a group', () => { + render( + + first + second + + ); + expect(screen.getByTestId('group')).toHaveClass(styles.group); + expect(screen.getByText('first')).toBeInTheDocument(); + expect(screen.getByText('second')).toBeInTheDocument(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(hi); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + describe('Message.Bubble', () => { + it('defaults to the solid neutral appearance', () => { + render(text); + const bubble = screen.getByTestId('bubble'); + expect(bubble).toHaveAttribute('data-variant', 'solid'); + expect(bubble).toHaveAttribute('data-color', 'neutral'); + expect(bubble).toHaveClass(styles['bubble-solid']); + expect(bubble).toHaveClass(styles['bubble-neutral']); + }); + + it.each([ + ['solid', 'accent'], + ['solid', 'neutral'], + ['solid', 'danger'], + ['outline', 'accent'], + ['outline', 'neutral'], + ['outline', 'danger'] + ] as const)('renders the %s %s appearance', (variant, color) => { + render( + + text + + ); + const bubble = screen.getByTestId('bubble'); + expect(bubble).toHaveAttribute('data-variant', variant); + expect(bubble).toHaveAttribute('data-color', color); + expect(bubble).toHaveClass(styles[`bubble-${variant}`]); + expect(bubble).toHaveClass(styles[`bubble-${color}`]); + }); + }); +}); diff --git a/packages/raystack/components/message/index.tsx b/packages/raystack/components/message/index.tsx new file mode 100644 index 000000000..f24ffffb1 --- /dev/null +++ b/packages/raystack/components/message/index.tsx @@ -0,0 +1,2 @@ +export { Message, type MessageAlign, type MessageProps } from './message'; +export type { MessageBubbleProps } from './message-bubble'; diff --git a/packages/raystack/components/message/message-bubble.tsx b/packages/raystack/components/message/message-bubble.tsx new file mode 100644 index 000000000..753017729 --- /dev/null +++ b/packages/raystack/components/message/message-bubble.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { cva, type VariantProps } from 'class-variance-authority'; +import { ComponentProps } from 'react'; +import styles from './message.module.css'; + +const bubble = cva(styles.bubble, { + variants: { + variant: { + solid: styles['bubble-solid'], + outline: styles['bubble-outline'] + }, + color: { + accent: styles['bubble-accent'], + neutral: styles['bubble-neutral'], + danger: styles['bubble-danger'] + } + }, + defaultVariants: { + variant: 'solid', + color: 'neutral' + } +}); + +export interface MessageBubbleProps + extends ComponentProps<'div'>, + VariantProps { + /** + * Visual style of the message surface. + * @defaultValue "solid" + */ + variant?: 'solid' | 'outline'; + /** + * Color of the message surface. + * @defaultValue "neutral" + */ + color?: 'accent' | 'neutral' | 'danger'; +} + +export function MessageBubble({ + className, + variant = 'solid', + color = 'neutral', + ...props +}: MessageBubbleProps) { + return ( +
+ ); +} + +MessageBubble.displayName = 'Message.Bubble'; diff --git a/packages/raystack/components/message/message.module.css b/packages/raystack/components/message/message.module.css new file mode 100644 index 000000000..f58884ca8 --- /dev/null +++ b/packages/raystack/components/message/message.module.css @@ -0,0 +1,164 @@ +/* ---------------------------------- group ---------------------------------- */ + +.group { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + min-width: 0; +} + +/* --------------------------------- message -------------------------------- */ + +/* The content column shrink-wraps (capped like the bubble used to be) with a + flexible spacer on the far side, so the hover actions sit next to the + content instead of drifting to the row's opposite edge. */ +.message { + display: grid; + grid-template-columns: auto fit-content(85%) auto 1fr; + grid-template-areas: + ". header . ." + "avatar content actions ." + ". footer . ."; + align-items: start; +} + +.message[data-align="end"] { + grid-template-columns: 1fr auto fit-content(85%) auto; + grid-template-areas: + ". . header ." + ". actions content avatar" + ". . footer ."; + justify-items: end; +} + +/* The avatar area only spans the content row, so flex-end anchors it to the + bubble's bottom edge rather than to the footer line below it. */ +.message-avatar { + grid-area: avatar; + align-self: flex-end; + flex-shrink: 0; + margin-right: var(--rs-space-3); +} + +.message[data-align="end"] .message-avatar { + margin-right: 0; + margin-left: var(--rs-space-3); +} + +.message-header { + grid-area: header; + display: flex; + align-items: center; + gap: var(--rs-space-2); + margin-bottom: var(--rs-space-1); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + font-weight: var(--rs-font-weight-medium); + color: var(--rs-color-foreground-base-secondary); +} + +.message-content { + grid-area: content; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--rs-space-2); + min-width: 0; + width: 100%; + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-primary); +} + +.message[data-align="end"] .message-content { + align-items: flex-end; +} + +.message-footer { + grid-area: footer; + display: flex; + align-items: center; + gap: var(--rs-space-2); + margin-top: var(--rs-space-1); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-tertiary); +} + +.message-actions { + grid-area: actions; + align-self: center; + display: flex; + align-items: center; + gap: var(--rs-space-1); + margin: 0 var(--rs-space-2); + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .message-actions { + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} + +.message:hover .message-actions, +.message:focus-within .message-actions { + opacity: 1; +} + +/* Touch devices have no hover: keep actions reachable. */ +@media (hover: none) { + .message-actions { + opacity: 1; + } +} + +/* --------------------------------- bubble --------------------------------- */ + +.bubble { + box-sizing: border-box; + width: fit-content; + /* The message grid's content column already caps at 85% of the row; + inside it the bubble just fills what the text needs. */ + max-width: 100%; + min-width: 0; + padding: var(--rs-space-3) var(--rs-space-4); + border-radius: var(--rs-radius-5); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +/* Variant sets the surface treatment; color picks the palette. The token + pairs mirror Button's solid/outline color mapping. */ +.bubble-solid.bubble-neutral { + background: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.bubble-solid.bubble-accent { + background: var(--rs-color-background-accent-emphasis); + color: var(--rs-color-foreground-accent-emphasis); +} + +.bubble-solid.bubble-danger { + background: var(--rs-color-background-danger-emphasis); + color: var(--rs-color-foreground-danger-emphasis); +} + +.bubble-outline { + background: var(--rs-color-background-base-primary); + border: 0.5px solid var(--rs-color-border-base-primary); + color: var(--rs-color-foreground-base-primary); +} + +.bubble-outline.bubble-accent { + border-color: var(--rs-color-border-accent-emphasis); + color: var(--rs-color-foreground-accent-primary); +} + +.bubble-outline.bubble-danger { + border-color: var(--rs-color-border-danger-emphasis); + color: var(--rs-color-foreground-danger-primary); +} diff --git a/packages/raystack/components/message/message.tsx b/packages/raystack/components/message/message.tsx new file mode 100644 index 000000000..1dd8f966b --- /dev/null +++ b/packages/raystack/components/message/message.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { ComponentProps } from 'react'; +import styles from './message.module.css'; +import { MessageBubble } from './message-bubble'; + +export type MessageAlign = 'start' | 'end'; + +export interface MessageProps extends ComponentProps<'div'> { + /** + * Which side of the conversation the message sits on. `"end"` aligns the + * message to the trailing edge (the sender's own messages); `"start"` (the + * default) to the leading edge. + * @defaultValue "start" + */ + align?: MessageAlign; +} + +function MessageRoot({ className, align = 'start', ...props }: MessageProps) { + return ( +
+ ); +} + +MessageRoot.displayName = 'Message'; + +export interface MessageGroupProps extends ComponentProps<'div'> {} + +export function MessageGroup({ className, ...props }: MessageGroupProps) { + return
; +} + +MessageGroup.displayName = 'Message.Group'; + +export interface MessageAvatarProps extends ComponentProps<'div'> {} + +export function MessageAvatar({ className, ...props }: MessageAvatarProps) { + return
; +} + +MessageAvatar.displayName = 'Message.Avatar'; + +export interface MessageHeaderProps extends ComponentProps<'div'> {} + +export function MessageHeader({ className, ...props }: MessageHeaderProps) { + return
; +} + +MessageHeader.displayName = 'Message.Header'; + +export interface MessageContentProps extends ComponentProps<'div'> {} + +export function MessageContent({ className, ...props }: MessageContentProps) { + return ( +
+ ); +} + +MessageContent.displayName = 'Message.Content'; + +export interface MessageFooterProps extends ComponentProps<'div'> {} + +export function MessageFooter({ className, ...props }: MessageFooterProps) { + return
; +} + +MessageFooter.displayName = 'Message.Footer'; + +export interface MessageActionsProps extends ComponentProps<'div'> {} + +export function MessageActions({ className, ...props }: MessageActionsProps) { + return ( +
+ ); +} + +MessageActions.displayName = 'Message.Actions'; + +export const Message = Object.assign(MessageRoot, { + Group: MessageGroup, + Avatar: MessageAvatar, + Header: MessageHeader, + Content: MessageContent, + Footer: MessageFooter, + Actions: MessageActions, + Bubble: MessageBubble +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 7f4988464..b500e4dbf 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -74,6 +74,7 @@ export { Link } from './components/link'; export { List } from './components/list'; export { Menu } from './components/menu'; export { Menubar } from './components/menubar'; +export { Message } from './components/message'; export { Meter } from './components/meter'; export { Navbar } from './components/navbar'; export { NumberField } from './components/number-field';