@adatechnology/conversations-ui 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +126 -19
  2. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  3. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  4. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  5. package/dist/flows/index.js +3 -5
  6. package/dist/index.js +834 -764
  7. package/dist/participant/index.d.ts +18 -1
  8. package/dist/participant/index.js +1188 -313
  9. package/dist/preview/index.js +2 -3
  10. package/dist/styles.css +583 -19
  11. package/package.json +3 -3
  12. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  13. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  14. package/src/participant/ParticipantBackButton.tsx +14 -0
  15. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  16. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  17. package/src/participant/ParticipantComposer.tsx +8 -40
  18. package/src/participant/ParticipantComposerControls.tsx +83 -0
  19. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  20. package/src/participant/ParticipantConversations.tsx +7 -0
  21. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  22. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  23. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  24. package/src/participant/ParticipantDayDivider.tsx +18 -0
  25. package/src/participant/ParticipantInbox.tsx +3 -1
  26. package/src/participant/ParticipantInboxRow.tsx +6 -4
  27. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  28. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  29. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  30. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  31. package/src/participant/ParticipantMessageText.tsx +52 -0
  32. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  33. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  34. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  35. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  36. package/src/participant/ParticipantThread.test.tsx +37 -0
  37. package/src/participant/ParticipantThread.tsx +45 -95
  38. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  39. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  40. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  41. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  42. package/src/participant/ParticipantTimeline.tsx +103 -0
  43. package/src/participant/index.ts +1 -0
  44. package/src/participant/participantAutoGrow.test.ts +43 -0
  45. package/src/participant/participantAutoGrow.ts +12 -0
  46. package/src/participant/participantAvatar.test.ts +74 -0
  47. package/src/participant/participantAvatar.ts +48 -0
  48. package/src/participant/participantBubbleTail.test.tsx +107 -0
  49. package/src/participant/participantChatDesign.test.tsx +198 -0
  50. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  51. package/src/participant/participantCopyAnnouncement.ts +37 -0
  52. package/src/participant/participantCss.test-helper.ts +34 -0
  53. package/src/participant/participantDay.test.ts +22 -0
  54. package/src/participant/participantDay.ts +8 -0
  55. package/src/participant/participantDocuments.ts +45 -0
  56. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  57. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  58. package/src/participant/participantLabels.ts +11 -0
  59. package/src/participant/participantMessageAtoms.ts +53 -0
  60. package/src/participant/participantMessageCopyables.test.ts +141 -0
  61. package/src/participant/participantMessageCopyables.ts +76 -0
  62. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  63. package/src/participant/participantMessageFormat.test.ts +177 -0
  64. package/src/participant/participantMessageFormat.ts +135 -0
  65. package/src/participant/participantMessageFormat.types.ts +22 -0
  66. package/src/participant/participantMessageInline.test.ts +82 -0
  67. package/src/participant/participantMessageInline.ts +99 -0
  68. package/src/participant/participantMessageLinks.test.ts +100 -0
  69. package/src/participant/participantMessageLinks.ts +54 -0
  70. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  71. package/src/participant/participantScrollFocus.test.ts +22 -0
  72. package/src/participant/participantScrollFocus.ts +10 -0
  73. package/src/participant/participantScrollView.test.ts +74 -0
  74. package/src/participant/participantScrollView.ts +38 -0
  75. package/src/participant/participantScrollbars.test.ts +156 -0
  76. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  77. package/src/participant/participantSubjectIcon.ts +26 -0
  78. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  79. package/src/participant/useParticipantThreadScroll.ts +10 -2
  80. package/src/participant/useRevealActiveChip.ts +20 -0
  81. package/src/participant/useScrollAwayState.ts +24 -0
  82. package/src/participant/useStickToBottom.ts +16 -2
  83. package/src/styles.css +137 -21
  84. package/dist/chunk-MRVFFJEH.js +0 -64
  85. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { channelCapabilityFor } from '../channelCapability'
4
+ import type { ConversationChannel } from '../conversationChannel'
5
+ import { isSameDay } from '../lib/format'
6
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
+ import { ParticipantAuthorAvatar, type ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
8
+ import { ParticipantDayDivider } from './ParticipantDayDivider'
9
+ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
10
+ import { shouldShowAvatar, type ParticipantAvatarAuthor } from './participantAvatar'
11
+ import {
12
+ bindPendingActions,
13
+ resolveParticipantBubbleActions,
14
+ type ParticipantPendingActions,
15
+ } from './participantBubbleActions'
16
+ import type { ParticipantConversationsLabels } from './participantLabels'
17
+ import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
18
+
19
+ export type ParticipantTimelineProps = {
20
+ readonly items: readonly ParticipantTimelineItem[]
21
+ readonly labels: ParticipantConversationsLabels
22
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
23
+ readonly channel?: ConversationChannel
24
+ readonly locale?: string
25
+ readonly pendingActions?: ParticipantPendingActions
26
+ readonly avatars?: 'initials'
27
+ readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
28
+ readonly tail?: boolean
29
+ }
30
+
31
+ function createdAtOf(item: ParticipantTimelineItem): string {
32
+ return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
33
+ }
34
+
35
+ function keyOf(item: ParticipantTimelineItem): string {
36
+ return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
37
+ }
38
+
39
+ type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
40
+
41
+ function authorOf(item: ParticipantTimelineItem): AuthorOfItem {
42
+ if (item.kind === 'pending') return { isMine: true, author: null }
43
+ return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null }
44
+ }
45
+
46
+ function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
47
+ if (!pendingActions || item.kind === 'server') return {}
48
+ return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
49
+ }
50
+
51
+ type AvatarSlotParams = {
52
+ readonly isEnabled: boolean
53
+ readonly isMine: boolean
54
+ readonly showAvatar: boolean
55
+ readonly author: ParticipantAvatarAuthor
56
+ readonly render?: ParticipantAuthorAvatarRenderer
57
+ }
58
+
59
+ function resolveAvatarSlot({ isEnabled, isMine, showAvatar, author, render }: AvatarSlotParams): ReactNode {
60
+ if (!isEnabled || isMine) return undefined
61
+ return showAvatar ? <ParticipantAuthorAvatar name={author} render={render} /> : null
62
+ }
63
+
64
+ export function ParticipantTimeline(props: ParticipantTimelineProps) {
65
+ const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
66
+ const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
67
+ const isAvatarEnabled = props.avatars === 'initials' || props.renderAuthorAvatar !== undefined
68
+ const nodes: ReactNode[] = []
69
+ let previousDay: Date | undefined
70
+ let previousAuthor: ParticipantAvatarAuthor | undefined
71
+
72
+ for (const item of items) {
73
+ const day = new Date(createdAtOf(item))
74
+ if (previousDay === undefined || !isSameDay(previousDay, day)) {
75
+ nodes.push(<ParticipantDayDivider key={`day:${keyOf(item)}`} day={day} locale={locale} />)
76
+ previousAuthor = undefined
77
+ }
78
+ previousDay = day
79
+ const { isMine, author } = authorOf(item)
80
+ const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
81
+ previousAuthor = isMine ? undefined : author
82
+ const avatar = resolveAvatarSlot({
83
+ isEnabled: isAvatarEnabled,
84
+ isMine,
85
+ showAvatar,
86
+ author,
87
+ render: props.renderAuthorAvatar,
88
+ })
89
+ nodes.push(
90
+ <ParticipantMessageBubble
91
+ key={keyOf(item)}
92
+ item={item}
93
+ labels={labels}
94
+ confirmsRead={confirmsRead}
95
+ resolveAttachmentUrl={resolveAttachmentUrl}
96
+ tail={props.tail}
97
+ avatar={avatar}
98
+ {...resolveActions(item, pendingActions)}
99
+ />,
100
+ )
101
+ }
102
+ return <div className="cv-p-thread__messages">{nodes}</div>
103
+ }
@@ -1,4 +1,5 @@
1
1
  export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
2
+ export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
2
3
  export { groupParticipantConversations } from './participantGrouping'
3
4
  export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
4
5
  export type {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { fitTextareaHeight, type AutoGrowTarget } from './participantAutoGrow'
4
+
5
+ describe('fitTextareaHeight', () => {
6
+ it('measures after resetting, so the field can also shrink', () => {
7
+ const heights: string[] = []
8
+ const target: AutoGrowTarget = {
9
+ style: {
10
+ get height() {
11
+ return heights.at(-1) ?? ''
12
+ },
13
+ set height(value: string) {
14
+ heights.push(value)
15
+ },
16
+ },
17
+ scrollHeight: 96,
18
+ offsetHeight: 96,
19
+ clientHeight: 96,
20
+ }
21
+ fitTextareaHeight(target)
22
+ expect(heights).toEqual(['auto', '96px'])
23
+ })
24
+
25
+ it('adds the border to the content height, so a border-box field does not overflow', () => {
26
+ const heights: string[] = []
27
+ const target: AutoGrowTarget = {
28
+ style: {
29
+ get height() {
30
+ return heights.at(-1) ?? ''
31
+ },
32
+ set height(value: string) {
33
+ heights.push(value)
34
+ },
35
+ },
36
+ scrollHeight: 61,
37
+ offsetHeight: 63,
38
+ clientHeight: 61,
39
+ }
40
+ fitTextareaHeight(target)
41
+ expect(heights.at(-1)).toBe('63px')
42
+ })
43
+ })
@@ -0,0 +1,12 @@
1
+ export type AutoGrowTarget = {
2
+ readonly style: { height: string }
3
+ readonly scrollHeight: number
4
+ readonly offsetHeight: number
5
+ readonly clientHeight: number
6
+ }
7
+
8
+ /** Fits the field to its content plus its border (border-box), so no phantom scrollbar; the CSS max-height caps it. */
9
+ export function fitTextareaHeight(target: AutoGrowTarget): void {
10
+ target.style.height = 'auto'
11
+ target.style.height = `${target.scrollHeight + (target.offsetHeight - target.clientHeight)}px`
12
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { authorInitials, shouldShowAvatar } from './participantAvatar'
4
+
5
+ describe('authorInitials', () => {
6
+ it('takes the first letters of the first and last names', () => {
7
+ expect(authorInitials('Ana Souza')).toBe('AS')
8
+ expect(authorInitials('maria ana souza lima')).toBe('ML')
9
+ })
10
+
11
+ it('uses a single initial for a single name', () => {
12
+ expect(authorInitials('Ana')).toBe('A')
13
+ })
14
+
15
+ it('ignores prepositions and punctuation', () => {
16
+ expect(authorInitials('Maria da Silva')).toBe('MS')
17
+ expect(authorInitials('João de Souza e Silva')).toBe('JS')
18
+ expect(authorInitials(' (Ana) - Souza. ')).toBe('AS')
19
+ })
20
+
21
+ it('keeps an accented initial and upper-cases it', () => {
22
+ expect(authorInitials('élida ávila')).toBe('ÉÁ')
23
+ })
24
+
25
+ it('keeps the first word even when it looks like a particle', () => {
26
+ expect(authorInitials('Le Minh')).toBe('LM')
27
+ expect(authorInitials('Van Morrison')).toBe('VM')
28
+ })
29
+
30
+ it('composes accents before taking the initial', () => {
31
+ expect(authorInitials('e\u0301lida')).toBe('\u00c9')
32
+ })
33
+
34
+ it('falls back to the first word when only prepositions remain', () => {
35
+ expect(authorInitials('de da')).toBe('D')
36
+ })
37
+
38
+ it('returns undefined for missing, empty, question-mark or emoji-only names', () => {
39
+ expect(authorInitials(null)).toBeUndefined()
40
+ expect(authorInitials(undefined)).toBeUndefined()
41
+ expect(authorInitials('')).toBeUndefined()
42
+ expect(authorInitials(' ')).toBeUndefined()
43
+ expect(authorInitials('?')).toBeUndefined()
44
+ expect(authorInitials('😀')).toBeUndefined()
45
+ })
46
+
47
+ it('ignores a trailing emoji', () => {
48
+ expect(authorInitials('Ana 😀')).toBe('A')
49
+ })
50
+ })
51
+
52
+ describe('shouldShowAvatar', () => {
53
+ it('shows it on the first message of a sequence', () => {
54
+ expect(shouldShowAvatar({ previousAuthor: undefined, author: 'Ana', isMine: false })).toBe(true)
55
+ })
56
+
57
+ it('hides it while the same author keeps talking', () => {
58
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: false })).toBe(false)
59
+ })
60
+
61
+ it('shows it again when the author changes', () => {
62
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Bruno', isMine: false })).toBe(true)
63
+ })
64
+
65
+ it('treats consecutive unnamed authors as one sequence', () => {
66
+ expect(shouldShowAvatar({ previousAuthor: null, author: null, isMine: false })).toBe(false)
67
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: null, isMine: false })).toBe(true)
68
+ })
69
+
70
+ it('never shows it on an own message', () => {
71
+ expect(shouldShowAvatar({ previousAuthor: undefined, author: null, isMine: true })).toBe(false)
72
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: true })).toBe(false)
73
+ })
74
+ })
@@ -0,0 +1,48 @@
1
+ const NAME_PARTICLES = new Set([
2
+ 'a',
3
+ 'e',
4
+ 'o',
5
+ 'da',
6
+ 'das',
7
+ 'de',
8
+ 'del',
9
+ 'di',
10
+ 'do',
11
+ 'dos',
12
+ 'du',
13
+ 'la',
14
+ 'le',
15
+ 'van',
16
+ 'von',
17
+ ])
18
+
19
+ export type ParticipantAvatarAuthor = string | null
20
+
21
+ export type ShouldShowAvatarParams = {
22
+ /** Author of the previous message; undefined at the start of the list or right after an own message. */
23
+ readonly previousAuthor: ParticipantAvatarAuthor | undefined
24
+ readonly author: ParticipantAvatarAuthor
25
+ readonly isMine: boolean
26
+ }
27
+
28
+ function lettersOf(word: string): string {
29
+ return word.replace(/[^\p{L}\p{M}]/gu, '')
30
+ }
31
+
32
+ function firstLetter(word: string): string {
33
+ return (Array.from(word.normalize('NFC'))[0] ?? '').toUpperCase()
34
+ }
35
+
36
+ /** Up to two initials; undefined when the name has no letters, so the caller draws the neutral icon. */
37
+ export function authorInitials(name: string | null | undefined): string | undefined {
38
+ const [first, ...rest] = (name ?? '').split(/\s+/).map(lettersOf).filter(Boolean)
39
+ if (first === undefined) return undefined
40
+ const last = rest.filter((word) => !NAME_PARTICLES.has(word.toLowerCase())).pop()
41
+ return `${firstLetter(first)}${last === undefined ? '' : firstLetter(last)}`
42
+ }
43
+
44
+ export function shouldShowAvatar({ previousAuthor, author, isMine }: ShouldShowAvatarParams): boolean {
45
+ if (isMine) return false
46
+ if (previousAuthor === undefined) return true
47
+ return previousAuthor !== author
48
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+
6
+ import { splitBlocks } from '../cssBlocks.test-helper'
7
+ import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
8
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { ParticipantMessageBubble, type ParticipantMessageBubbleProps } from './ParticipantMessageBubble'
10
+
11
+ const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
12
+
13
+ function declarationsOf(selector: string): Map<string, string> {
14
+ const declarations = new Map<string, string>()
15
+ for (const { prelude, body } of splitBlocks(CSS)) {
16
+ if (body === undefined || prelude.startsWith('@')) continue
17
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
18
+ for (const declaration of body.split(';')) {
19
+ const separator = declaration.indexOf(':')
20
+ if (separator === -1) continue
21
+ declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
22
+ }
23
+ }
24
+ return declarations
25
+ }
26
+
27
+ function render(direction: 'inbound' | 'outbound', extra: Partial<ParticipantMessageBubbleProps> = {}): string {
28
+ return renderToStaticMarkup(
29
+ <ParticipantMessageBubble
30
+ item={{ kind: 'server', message: buildMessage({ id: 'm1', direction }) }}
31
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
32
+ confirmsRead
33
+ resolveAttachmentUrl={async () => 'https://files.example/a.png'}
34
+ {...extra}
35
+ />,
36
+ )
37
+ }
38
+
39
+ describe('bubble tail CSS', () => {
40
+ it('draws two decorative triangles that never catch touches', () => {
41
+ for (const pseudo of ['::before', '::after']) {
42
+ const tail = declarationsOf(`.cv-p-bubble${pseudo}`)
43
+ expect(tail.get('content')).toBe("''")
44
+ expect(tail.get('pointer-events')).toBe('none')
45
+ expect(tail.get('position')).toBe('absolute')
46
+ expect(tail.get('border-style')).toBe('solid')
47
+ }
48
+ expect(declarationsOf('.cv-p-bubble').get('position')).toBe('relative')
49
+ })
50
+
51
+ it('puts the received tail on the left, edge colour under fill colour', () => {
52
+ expect(declarationsOf('.cv-p-bubble::before').get('left')).toBe('-10px')
53
+ expect(declarationsOf('.cv-p-bubble::after').get('left')).toBe('-8px')
54
+ expect(declarationsOf('.cv-p-bubble::before').get('border-bottom-color')).toBe('var(--cv-p-tail-edge)')
55
+ expect(declarationsOf('.cv-p-bubble::after').get('border-bottom-color')).toBe('var(--cv-p-tail-fill)')
56
+ expect(declarationsOf('.cv-p-bubble').get('border-bottom-left-radius')).toBe('0')
57
+ })
58
+
59
+ it('mirrors the own tail to the right with the own tokens', () => {
60
+ expect(declarationsOf('.cv-p-bubble--mine::before').get('right')).toBe('-10px')
61
+ expect(declarationsOf('.cv-p-bubble--mine::after').get('right')).toBe('-8px')
62
+ const mine = declarationsOf('.cv-p-bubble--mine')
63
+ expect(mine.get('border-bottom-right-radius')).toBe('0')
64
+ expect(mine.get('--cv-p-tail-edge')).toBe('var(--cv-p-i-accent)')
65
+ expect(mine.get('--cv-p-tail-fill')).toBe('var(--cv-p-i-highlight)')
66
+ expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-edge')).toBe('var(--cv-p-i-border)')
67
+ expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-fill')).toBe('var(--cv-p-i-surface-raised)')
68
+ })
69
+
70
+ it('turns the tail off and restores the corner with --no-tail', () => {
71
+ expect(declarationsOf('.cv-p-bubble--no-tail::before').get('content')).toBe('none')
72
+ expect(declarationsOf('.cv-p-bubble--no-tail::after').get('content')).toBe('none')
73
+ expect(declarationsOf('.cv-p-bubble--no-tail').get('border-radius')).toBe('var(--cv-p-i-radius)')
74
+ })
75
+
76
+ it('uses no fixed colour for the tail', () => {
77
+ const tailRules = splitBlocks(CSS).filter(({ prelude }) => /cv-p-bubble[^,{]*::(before|after)/.test(prelude))
78
+ expect(tailRules.length).toBeGreaterThan(0)
79
+ for (const { body } of tailRules) expect(body ?? '').not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i)
80
+ })
81
+ })
82
+
83
+ describe('bubble tail markup', () => {
84
+ it('keeps the default markup free of any new class', () => {
85
+ expect(render('outbound')).toContain('<div class="cv-p-bubble">')
86
+ expect(render('inbound')).toContain('<div class="cv-p-bubble cv-p-bubble--mine">')
87
+ })
88
+
89
+ it('adds --no-tail only when tail is false, on both sides', () => {
90
+ expect(render('outbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--no-tail"')
91
+ expect(render('inbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"')
92
+ expect(render('outbound', { tail: true })).not.toContain('--no-tail')
93
+ })
94
+
95
+ it('keeps the content and the avatar row unchanged by the tail flag', () => {
96
+ const withAvatar = render('outbound', { avatar: <b>A</b>, tail: false })
97
+ expect(withAvatar).toContain('cv-p-bubble-row')
98
+ expect(withAvatar).toContain('cv-p-bubble--no-tail')
99
+ expect(render('outbound', { tail: false })).toContain('cv-p-bubble__meta')
100
+ })
101
+
102
+ it('stays inside the participant namespace', () => {
103
+ const markup = render('outbound', { tail: false })
104
+ expect(findForeignClassTokens(markup)).toEqual([])
105
+ expect(findUtilityClassTokens(markup)).toEqual([])
106
+ })
107
+ })
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { declarationsIn, mediaBody } from './participantCss.test-helper'
5
+ import {
6
+ buildConversation,
7
+ buildMessage,
8
+ findForeignClassTokens,
9
+ findUtilityClassTokens,
10
+ } from './participantFixtures.test-helper'
11
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
12
+ import { ParticipantComposer } from './ParticipantComposer'
13
+ import { ParticipantThread } from './ParticipantThread'
14
+
15
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
16
+
17
+ function renderThread(): string {
18
+ return renderToStaticMarkup(
19
+ <ParticipantThread
20
+ conversation={buildConversation({ subjectId: '1' })}
21
+ items={[
22
+ { kind: 'server', message: buildMessage({ id: 'a', text: 'Hello', createdAt: '2026-10-01T10:00:00.000Z' }) },
23
+ { kind: 'server', message: buildMessage({ id: 'b', text: 'Again', createdAt: '2026-10-02T10:00:00.000Z' }) },
24
+ ]}
25
+ hasMore={false}
26
+ labels={LABELS}
27
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
28
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
29
+ onSend={() => undefined}
30
+ status="ready"
31
+ refresh={() => undefined}
32
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
33
+ newMessagesCount={0}
34
+ isSending={false}
35
+ />,
36
+ )
37
+ }
38
+
39
+ function renderComposer(overrides: { value?: string } = {}): string {
40
+ return renderToStaticMarkup(
41
+ <ParticipantComposer
42
+ value={overrides.value ?? ''}
43
+ onChange={() => undefined}
44
+ files={[]}
45
+ onFilesChange={() => undefined}
46
+ onSend={() => undefined}
47
+ labels={LABELS}
48
+ quickReplies={[{ id: '1', title: 'Hi', shortcut: 'hi', body: 'Hi' }]}
49
+ />,
50
+ )
51
+ }
52
+
53
+ describe('wallpaper', () => {
54
+ it('is drawn on the message scroller from the token, defaulting to a CSS-only pattern', () => {
55
+ const scroller = declarationsIn('.cv-p-thread__scroll')
56
+ expect(scroller.get('background-image')).toBe('var(--cv-p-wallpaper, var(--cv-p-i-wallpaper))')
57
+ const root = declarationsIn('.cv-p')
58
+ expect(root.get('--cv-p-i-wallpaper')).toContain('radial-gradient')
59
+ expect(root.get('--cv-p-i-wallpaper')).not.toContain('url(')
60
+ expect(root.get('--cv-p-i-wallpaper-ink')).toBe('var(--cv-p-i-border)')
61
+ })
62
+
63
+ it('is switched off in forced colors and in print', () => {
64
+ for (const query of ['(forced-colors: active)', 'print']) {
65
+ expect(declarationsIn('.cv-p-thread__scroll', mediaBody(query) ?? '').get('background-image')).toBe('none')
66
+ }
67
+ })
68
+
69
+ it('uses logical properties for the text, the meta and the title button, so RTL mirrors', () => {
70
+ expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('margin-inline-end')).toBe('auto')
71
+ expect(declarationsIn('.cv-p-thread__title-button').get('text-align')).toBe('start')
72
+ })
73
+
74
+ it('restores the bubble corner radius in print, where the tail is off', () => {
75
+ const rule = declarationsIn('.cv-p-bubble', mediaBody('print') ?? '')
76
+ expect(rule.get('border-radius')).toBe('var(--cv-p-i-radius)')
77
+ expect(declarationsIn('.cv-p-bubble--mine', mediaBody('print') ?? '').get('border-radius')).toBe(
78
+ 'var(--cv-p-i-radius)',
79
+ )
80
+ })
81
+
82
+ it('never reaches the composer, the header or the bubbles', () => {
83
+ for (const selector of ['.cv-p-composer', '.cv-p-thread__header', '.cv-p-bubble']) {
84
+ expect(declarationsIn(selector).has('background-image')).toBe(false)
85
+ }
86
+ })
87
+ })
88
+
89
+ describe('day pill', () => {
90
+ it('is a separator holding a time with a machine date', () => {
91
+ expect(renderThread()).toMatch(
92
+ /<div class="cv-p-day" role="separator" aria-label="[^"]+"><time class="cv-p-day__label" dateTime="2026-10-01">/,
93
+ )
94
+ })
95
+
96
+ it('is a centred raised pill with a border and the radius token', () => {
97
+ const day = declarationsIn('.cv-p-day')
98
+ expect(day.get('align-self')).toBe('center')
99
+ expect(day.get('background')).toBe('var(--cv-p-i-surface-raised)')
100
+ expect(day.get('border')).toBe('1px solid var(--cv-p-i-border)')
101
+ expect(day.get('border-radius')).toBe('var(--cv-p-i-radius)')
102
+ expect(day.get('font')).toContain('monospace')
103
+ })
104
+ })
105
+
106
+ describe('time and ticks at the foot of the bubble', () => {
107
+ it('keeps time and status together in the last element of the bubble', () => {
108
+ const markup = renderToStaticMarkup(
109
+ <ParticipantThread
110
+ conversation={buildConversation({ subjectId: '1' })}
111
+ items={[{ kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', text: 'Hello' }) }]}
112
+ hasMore={false}
113
+ labels={LABELS}
114
+ resolveAttachmentUrl={async () => ''}
115
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
116
+ onSend={() => undefined}
117
+ status="ready"
118
+ refresh={() => undefined}
119
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
120
+ newMessagesCount={0}
121
+ isSending={false}
122
+ />,
123
+ )
124
+ const meta = markup.slice(
125
+ markup.indexOf('cv-p-bubble__meta'),
126
+ markup.indexOf('</span></div>', markup.indexOf('cv-p-bubble__meta')),
127
+ )
128
+ expect(meta).toContain('<time')
129
+ expect(meta).toContain('cv-p-bubble__status')
130
+ expect(meta.indexOf('<time')).toBeLessThan(meta.indexOf('cv-p-bubble__status'))
131
+ })
132
+
133
+ it('lets the text and the meta share a line, and the meta fall to the right when it does not fit', () => {
134
+ const bubble = declarationsIn('.cv-p-bubble')
135
+ expect(bubble.get('display')).toBe('flex')
136
+ expect(bubble.get('flex-wrap')).toBe('wrap')
137
+ expect(bubble.get('align-items')).toBe('flex-end')
138
+ expect(bubble.get('max-width')).toBe('85%')
139
+ const meta = declarationsIn('.cv-p-bubble__meta')
140
+ expect(meta.get('margin-inline-start')).toBe('auto')
141
+ expect(meta.get('flex')).toBe('0 0 auto')
142
+ expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('min-width')).toBe('0')
143
+ })
144
+
145
+ it('keeps author, attachments and failed actions on their own full-width row', () => {
146
+ expect(declarationsIn('.cv-p-bubble__author').get('flex')).toBe('1 0 100%')
147
+ expect(declarationsIn('.cv-p-bubble > .cv-p-attachment').get('flex')).toBe('1 0 100%')
148
+ })
149
+ })
150
+
151
+ describe('composer', () => {
152
+ it('draws attach and send as lucide icons with accessible names and no text glyphs', () => {
153
+ const markup = renderComposer({ value: 'x' })
154
+ const attach = markup.slice(
155
+ markup.indexOf('cv-p-composer__attach'),
156
+ markup.indexOf('</button>', markup.indexOf('cv-p-composer__attach')),
157
+ )
158
+ expect(attach).toContain('aria-label="Attach"')
159
+ expect(attach).toContain('<svg')
160
+ const send = markup.slice(
161
+ markup.indexOf('type="submit"'),
162
+ markup.indexOf('</button>', markup.indexOf('type="submit"')),
163
+ )
164
+ expect(send).toContain('aria-label="Send"')
165
+ expect(send).toContain('<svg')
166
+ for (const glyph of ['+', '➤', '+']) expect(markup).not.toContain(glyph)
167
+ expect(findUtilityClassTokens(markup)).toEqual([])
168
+ expect(findForeignClassTokens(markup)).toEqual([])
169
+ })
170
+
171
+ it('orders attach, field and send in one row, with the chips above it', () => {
172
+ const markup = renderComposer()
173
+ const order = ['cv-p-quick', 'cv-p-composer__row', 'cv-p-composer__attach', '<textarea', 'type="submit"']
174
+ const positions = order.map((token) => markup.indexOf(token))
175
+ expect(positions).toEqual([...positions].sort((a, b) => a - b))
176
+ expect(positions.every((position) => position >= 0)).toBe(true)
177
+ })
178
+
179
+ it('aligns the row by the base, with a field that grows up to four lines and keeps 44px', () => {
180
+ expect(declarationsIn('.cv-p-composer__row').get('align-items')).toBe('flex-end')
181
+ const input = declarationsIn('.cv-p-composer__input')
182
+ expect(input.get('min-height')).toBe('var(--cv-p-i-touch)')
183
+ expect(input.get('max-height')).toBe('6.5rem')
184
+ expect(input.get('overflow-y')).toBe('auto')
185
+ expect(input.get('border-radius')).toBe('var(--cv-p-i-radius)')
186
+ for (const selector of ['.cv-p-composer__attach']) {
187
+ expect(declarationsIn(selector).get('min-width')).toBe('var(--cv-p-i-touch)')
188
+ expect(declarationsIn(selector).get('min-height')).toBe('var(--cv-p-i-touch)')
189
+ }
190
+ })
191
+
192
+ it('sits on a raised bar with a top border; send is the primary button', () => {
193
+ const bar = declarationsIn('.cv-p-composer')
194
+ expect(bar.get('background')).toBe('var(--cv-p-i-surface-raised)')
195
+ expect(bar.get('border-top')).toBe('1px solid var(--cv-p-i-border)')
196
+ expect(renderComposer()).toContain('class="cv-p-button cv-p-button--primary cv-p-composer__send"')
197
+ })
198
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ INITIAL_COPY_ANNOUNCEMENT,
5
+ copyAnnouncementReducer,
6
+ resolveLiveText,
7
+ type CopyAnnouncementState,
8
+ } from './participantCopyAnnouncement'
9
+
10
+ function announce(state: CopyAnnouncementState, text: string): CopyAnnouncementState {
11
+ return copyAnnouncementReducer(state, { type: 'announce', text })
12
+ }
13
+
14
+ describe('copy announcement', () => {
15
+ it('changes the tick on every announcement, so a repeat restarts the timer', () => {
16
+ const first = announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied')
17
+ const second = announce(first, 'Copied')
18
+ expect(second.tick).not.toBe(first.tick)
19
+ expect(copyAnnouncementReducer(second, { type: 'expired' }).text).toBe('')
20
+ })
21
+
22
+ it('renders two identical copies in a row as different region content', () => {
23
+ const first = announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied')
24
+ const second = announce(first, 'Copied')
25
+ const shown = (copy: CopyAnnouncementState): string =>
26
+ resolveLiveText({ copy, notice: '', baselineNotice: undefined }).text
27
+ expect(shown(first)).not.toBe(shown(second))
28
+ expect(shown(first).replace('​', '')).toBe('Copied')
29
+ expect(shown(second).replace('​', '')).toBe('Copied')
30
+ })
31
+
32
+ it('does not repeat an unchanged notice after the copy expires', () => {
33
+ const copying = resolveLiveText({
34
+ copy: announce(INITIAL_COPY_ANNOUNCEMENT, 'Copied'),
35
+ notice: 'New messages',
36
+ baselineNotice: undefined,
37
+ })
38
+ const expired = resolveLiveText({
39
+ copy: { text: '', tick: 1 },
40
+ notice: 'New messages',
41
+ baselineNotice: copying.baselineNotice,
42
+ })
43
+ expect(expired.text).toBe('')
44
+ })
45
+
46
+ it('announces a notice that really changed, and the notice of a thread without copies', () => {
47
+ const changed = resolveLiveText({ copy: { text: '', tick: 1 }, notice: 'New messages', baselineNotice: '' })
48
+ expect(changed).toEqual({ text: 'New messages', baselineNotice: undefined })
49
+ const plain = resolveLiveText({ copy: INITIAL_COPY_ANNOUNCEMENT, notice: 'New messages', baselineNotice: undefined })
50
+ expect(plain.text).toBe('New messages')
51
+ })
52
+ })