@adatechnology/conversations-ui 0.4.2 → 0.6.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 (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -0,0 +1,121 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ // legacyStyles.fixture.css is the styles.css shipped by 0.4.2; the stylesheet is global, so anything
6
+ // added since must be namespaced under .cv- and must not reach the host's own elements.
7
+ const LEGACY_CSS = readFileSync(join(import.meta.dir, 'legacyStyles.fixture.css'), 'utf8')
8
+ const CURRENT_CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8')
9
+
10
+ const NESTED_AT_RULES = ['@media', '@supports']
11
+ const ALLOWED_NEW_AT_RULES = ['@media', '@supports', '@keyframes']
12
+
13
+ type Block = { readonly prelude: string; readonly body: string | undefined }
14
+ type Inventory = { selectors: string[]; atRules: string[]; keyframeNames: string[] }
15
+
16
+ function splitBlocks(css: string): Block[] {
17
+ const blocks: Block[] = []
18
+ let depth = 0
19
+ let start = 0
20
+ let bodyStart = 0
21
+ let prelude = ''
22
+ for (let index = 0; index < css.length; index += 1) {
23
+ const char = css[index]
24
+ if (depth === 0 && char === ';') {
25
+ blocks.push({ prelude: css.slice(start, index).trim(), body: undefined })
26
+ start = index + 1
27
+ }
28
+ if (char === '{') {
29
+ if (depth === 0) {
30
+ prelude = css.slice(start, index).trim()
31
+ bodyStart = index + 1
32
+ }
33
+ depth += 1
34
+ }
35
+ if (char === '}') {
36
+ depth -= 1
37
+ if (depth === 0) {
38
+ blocks.push({ prelude, body: css.slice(bodyStart, index) })
39
+ start = index + 1
40
+ }
41
+ }
42
+ }
43
+ return blocks
44
+ }
45
+
46
+ function splitSelectorList(prelude: string): string[] {
47
+ const parts: string[] = []
48
+ let parenthesisDepth = 0
49
+ let current = ''
50
+ for (const char of prelude) {
51
+ if (char === '(') parenthesisDepth += 1
52
+ if (char === ')') parenthesisDepth -= 1
53
+ if (char === ',' && parenthesisDepth === 0) {
54
+ parts.push(current.trim())
55
+ current = ''
56
+ } else {
57
+ current += char
58
+ }
59
+ }
60
+ parts.push(current.trim())
61
+ return parts.filter(Boolean)
62
+ }
63
+
64
+ function collect(css: string, inventory: Inventory): Inventory {
65
+ for (const { prelude, body } of splitBlocks(css.replace(/\/\*[\s\S]*?\*\//g, ''))) {
66
+ if (!prelude.startsWith('@')) {
67
+ inventory.selectors.push(...splitSelectorList(prelude))
68
+ continue
69
+ }
70
+ const atRuleName = prelude.split(/[\s(]/)[0] ?? prelude
71
+ inventory.atRules.push(atRuleName)
72
+ if (atRuleName === '@keyframes') inventory.keyframeNames.push(prelude.replace('@keyframes', '').trim())
73
+ if (NESTED_AT_RULES.includes(atRuleName) && body !== undefined) collect(body, inventory)
74
+ }
75
+ return inventory
76
+ }
77
+
78
+ function inventoryOf(css: string): Inventory {
79
+ return collect(css, { selectors: [], atRules: [], keyframeNames: [] })
80
+ }
81
+
82
+ function addedSince(legacy: readonly string[], current: readonly string[]): string[] {
83
+ const remaining = [...legacy]
84
+ return current.filter((entry) => {
85
+ const position = remaining.indexOf(entry)
86
+ if (position === -1) return true
87
+ remaining.splice(position, 1)
88
+ return false
89
+ })
90
+ }
91
+
92
+ describe('styles.css não vaza para o host desde a 0.4.2', () => {
93
+ const legacy = inventoryOf(LEGACY_CSS)
94
+ const current = inventoryOf(CURRENT_CSS)
95
+ const addedSelectors = addedSince(legacy.selectors, current.selectors)
96
+ const addedAtRules = addedSince(legacy.atRules, current.atRules)
97
+
98
+ it('G8: o parse enxerga as regras (guarda contra um parser que não acha nada)', () => {
99
+ expect(legacy.selectors.length).toBeGreaterThan(100)
100
+ expect(addedSelectors.length).toBeGreaterThan(20)
101
+ })
102
+
103
+ it('G8: todo seletor adicionado começa por .cv- ou .dark .cv-', () => {
104
+ const offenders = addedSelectors.filter((selector) => !/^(\.dark )?\.cv-/.test(selector))
105
+
106
+ expect(offenders).toEqual([])
107
+ })
108
+
109
+ it('G8: nenhum seletor global (:root, html, body, *) foi adicionado', () => {
110
+ const globals = addedSelectors.filter((selector) => /^(:root|html|body|\*)([\s.:#[>+~,]|$)/.test(selector))
111
+
112
+ expect(globals).toEqual([])
113
+ })
114
+
115
+ it('G8: nenhum @import, @layer ou outra at-rule nova além de @media, @supports e @keyframes', () => {
116
+ expect(addedAtRules.filter((rule) => !ALLOWED_NEW_AT_RULES.includes(rule))).toEqual([])
117
+ expect(addedSince(legacy.keyframeNames, current.keyframeNames).filter((name) => !name.startsWith('cv-'))).toEqual(
118
+ [],
119
+ )
120
+ })
121
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveCopyInteraction } from './messageTextCopyInteraction'
4
+
5
+ function buildKeyEvent(key: string): { key: string; preventDefault: () => void; wasPrevented: () => boolean } {
6
+ let prevented = false
7
+ return {
8
+ key,
9
+ preventDefault: () => {
10
+ prevented = true
11
+ },
12
+ wasPrevented: () => prevented,
13
+ }
14
+ }
15
+
16
+ describe('resolveCopyInteraction', () => {
17
+ it('por padrão devolve só o onClick de cópia', () => {
18
+ const onCopy = () => undefined
19
+ const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: false, onCopy })
20
+
21
+ expect(props.onClick).toBe(onCopy)
22
+ expect('role' in props).toBe(false)
23
+ expect('tabIndex' in props).toBe(false)
24
+ expect('onKeyDown' in props).toBe(false)
25
+ })
26
+
27
+ it('com copyOnClick desligado devolve objeto vazio, mesmo com accessibleCopy', () => {
28
+ expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: false, onCopy: () => undefined })).toEqual({})
29
+ expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: true, onCopy: () => undefined })).toEqual({})
30
+ })
31
+
32
+ it('com accessibleCopy expõe role, tabIndex e o onClick', () => {
33
+ const onCopy = () => undefined
34
+ const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: true, onCopy })
35
+
36
+ expect(props.role).toBe('button')
37
+ expect(props.tabIndex).toBe(0)
38
+ expect(props.onClick).toBe(onCopy)
39
+ })
40
+
41
+ it('Enter e Espaço copiam e previnem o default', () => {
42
+ let copies = 0
43
+ const props = resolveCopyInteraction({
44
+ copyOnClick: true,
45
+ accessibleCopy: true,
46
+ onCopy: () => {
47
+ copies += 1
48
+ },
49
+ })
50
+
51
+ for (const key of ['Enter', ' ']) {
52
+ const event = buildKeyEvent(key)
53
+ props.onKeyDown?.(event)
54
+ expect(event.wasPrevented()).toBe(true)
55
+ }
56
+ expect(copies).toBe(2)
57
+ })
58
+
59
+ it('outras teclas não copiam nem previnem o default', () => {
60
+ let copies = 0
61
+ const props = resolveCopyInteraction({
62
+ copyOnClick: true,
63
+ accessibleCopy: true,
64
+ onCopy: () => {
65
+ copies += 1
66
+ },
67
+ })
68
+ const event = buildKeyEvent('Tab')
69
+
70
+ props.onKeyDown?.(event)
71
+
72
+ expect(copies).toBe(0)
73
+ expect(event.wasPrevented()).toBe(false)
74
+ })
75
+ })
@@ -0,0 +1,37 @@
1
+ export type CopyKeyboardEvent = {
2
+ readonly key: string
3
+ preventDefault: () => void
4
+ }
5
+
6
+ export type CopyInteractionProps = {
7
+ onClick?: () => void
8
+ role?: 'button'
9
+ tabIndex?: 0
10
+ onKeyDown?: (event: CopyKeyboardEvent) => void
11
+ }
12
+
13
+ export type ResolveCopyInteractionOptions = {
14
+ readonly copyOnClick: boolean
15
+ readonly accessibleCopy: boolean
16
+ readonly onCopy: () => void
17
+ }
18
+
19
+ export function resolveCopyInteraction({
20
+ copyOnClick,
21
+ accessibleCopy,
22
+ onCopy,
23
+ }: ResolveCopyInteractionOptions): CopyInteractionProps {
24
+ if (!copyOnClick) return {}
25
+ if (!accessibleCopy) return { onClick: onCopy }
26
+
27
+ return {
28
+ onClick: onCopy,
29
+ role: 'button',
30
+ tabIndex: 0,
31
+ onKeyDown: (event) => {
32
+ if (event.key !== 'Enter' && event.key !== ' ') return
33
+ event.preventDefault()
34
+ onCopy()
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+
5
+ import { formatFileSize } from '../lib/format'
6
+ import { isSafeAttachmentUrl } from './participantAttachmentUrl'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+
9
+ export type ResolveParticipantAttachmentUrl = (
10
+ attachment: ParticipantAttachment,
11
+ disposition?: 'inline' | 'attachment',
12
+ ) => Promise<string>
13
+
14
+ export type ParticipantAttachmentItemProps = {
15
+ readonly attachment: ParticipantAttachment
16
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
17
+ readonly labels: ParticipantConversationsLabels
18
+ }
19
+
20
+ function useAttachmentUrl(
21
+ attachment: ParticipantAttachment,
22
+ resolveAttachmentUrl: ResolveParticipantAttachmentUrl,
23
+ ): string | undefined {
24
+ const [url, setUrl] = useState<string | undefined>(undefined)
25
+ const disposition = attachment.kind === 'document' ? 'attachment' : 'inline'
26
+
27
+ useEffect(() => {
28
+ let isCurrent = true
29
+ resolveAttachmentUrl(attachment, disposition).then(
30
+ (resolved) => {
31
+ if (isCurrent) setUrl(isSafeAttachmentUrl(resolved) ? resolved.trim() : undefined)
32
+ },
33
+ () => {
34
+ if (isCurrent) setUrl(undefined)
35
+ },
36
+ )
37
+ return () => {
38
+ isCurrent = false
39
+ }
40
+ }, [attachment, disposition, resolveAttachmentUrl])
41
+
42
+ return url
43
+ }
44
+
45
+ export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
46
+ const url = useAttachmentUrl(attachment, resolveAttachmentUrl)
47
+
48
+ if (attachment.kind === 'image') {
49
+ return (
50
+ <figure className="cv-p-attachment cv-p-attachment--image">
51
+ {url ? <img className="cv-p-attachment__image" src={url} alt={attachment.filename} /> : null}
52
+ <figcaption className="cv-p-attachment__name">{attachment.filename}</figcaption>
53
+ </figure>
54
+ )
55
+ }
56
+
57
+ if (attachment.kind === 'audio') {
58
+ return (
59
+ <div className="cv-p-attachment cv-p-attachment--audio">
60
+ <span className="cv-p-attachment__name">{attachment.filename}</span>
61
+ <audio className="cv-p-attachment__audio" controls preload="none" src={url} />
62
+ </div>
63
+ )
64
+ }
65
+
66
+ return (
67
+ <a
68
+ className="cv-p-attachment cv-p-attachment--document"
69
+ href={url}
70
+ download={attachment.filename}
71
+ aria-label={`${labels.downloadAttachment}: ${attachment.filename}`}
72
+ >
73
+ <span className="cv-p-attachment__name">{attachment.filename}</span>
74
+ <span className="cv-p-attachment__size">{formatFileSize(attachment.sizeBytes)}</span>
75
+ </a>
76
+ )
77
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
5
+ import { findUtilityClassTokens } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+
8
+ const render = (channels: readonly string[] | undefined, variant?: 'list' | 'inline') =>
9
+ renderToStaticMarkup(
10
+ <ParticipantChannelBadges
11
+ channels={channels}
12
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
13
+ {...(variant === undefined ? {} : { variant })}
14
+ />,
15
+ )
16
+
17
+ describe('ParticipantChannelBadges', () => {
18
+ it('draws nothing without channels, not even the list', () => {
19
+ expect(render(undefined)).toBe('')
20
+ expect(render([])).toBe('')
21
+ expect(render(['sms'])).toBe('')
22
+ })
23
+
24
+ it('draws one badge per channel in a stable order with accessible text', () => {
25
+ const markup = render(['whatsapp', 'app'])
26
+
27
+ expect(markup).toContain('<ul class="cv-p-channels"')
28
+ expect(markup).toContain('aria-label="Channels in this conversation"')
29
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
30
+ expect(markup).toContain('<span class="cv-p-sr-only">App</span>')
31
+ expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
32
+ })
33
+
34
+ it('hides the icons from assistive tech and uses no utility classes', () => {
35
+ const markup = render(['app', 'whatsapp', 'email', 'portal', 'webchat'])
36
+
37
+ expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(5)
38
+ expect(findUtilityClassTokens(markup)).toEqual([])
39
+ })
40
+
41
+ it('inline variant draws spans only, with a group prefix and audible separators', () => {
42
+ const markup = render(['whatsapp', 'app', 'app'], 'inline')
43
+
44
+ expect(markup).not.toContain('<ul')
45
+ expect(markup).not.toContain('<li')
46
+ expect(markup).toContain('<span class="cv-p-channels">')
47
+ expect(markup).toContain('<span class="cv-p-sr-only">Channels in this conversation: </span>')
48
+ expect(markup.match(/class="cv-p-channel cv-p-channel--/g)).toHaveLength(2)
49
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
50
+ expect(markup).toContain('<span class="cv-p-sr-only">App, </span>')
51
+ expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
52
+ expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(2)
53
+ })
54
+
55
+ it('inline variant draws nothing without valid channels', () => {
56
+ expect(render(undefined, 'inline')).toBe('')
57
+ expect(render(['sms'], 'inline')).toBe('')
58
+ })
59
+ })
@@ -0,0 +1,75 @@
1
+ import { Globe, Mail, MessageCircle, MessagesSquare, Smartphone } from 'lucide-react'
2
+ import type { ComponentType } from 'react'
3
+
4
+ import {
5
+ resolveParticipantChannelLabel,
6
+ resolveParticipantChannels,
7
+ type ParticipantChannelDescriptor,
8
+ } from './participantChannels'
9
+ import type { ParticipantConversationsLabels } from './participantLabels'
10
+
11
+ const ICONS: Record<
12
+ ParticipantChannelDescriptor['iconName'],
13
+ ComponentType<{ size?: number; 'aria-hidden'?: boolean }>
14
+ > = {
15
+ Smartphone,
16
+ MessageCircle,
17
+ Mail,
18
+ Globe,
19
+ MessagesSquare,
20
+ }
21
+
22
+ export type ParticipantChannelBadgesProps = {
23
+ readonly channels: readonly string[] | undefined
24
+ readonly labels: ParticipantConversationsLabels
25
+ /** 'list' draws ul/li; 'inline' draws spans only, valid inside a button. */
26
+ readonly variant?: 'list' | 'inline'
27
+ }
28
+
29
+ type BadgeProps = {
30
+ readonly descriptor: ParticipantChannelDescriptor
31
+ readonly labels: ParticipantConversationsLabels
32
+ readonly separator: string
33
+ }
34
+
35
+ function BadgeContent({ descriptor, labels, separator }: BadgeProps) {
36
+ const Icon = ICONS[descriptor.iconName]
37
+ return (
38
+ <>
39
+ <Icon size={14} aria-hidden={true} />
40
+ <span className="cv-p-sr-only">{`${resolveParticipantChannelLabel(descriptor, labels)}${separator}`}</span>
41
+ </>
42
+ )
43
+ }
44
+
45
+ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }: ParticipantChannelBadgesProps) {
46
+ const descriptors = resolveParticipantChannels(channels)
47
+ if (descriptors.length === 0) return null
48
+
49
+ if (variant === 'inline') {
50
+ return (
51
+ <span className="cv-p-channels">
52
+ <span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
53
+ {descriptors.map((descriptor, index) => (
54
+ <span key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
55
+ <BadgeContent
56
+ descriptor={descriptor}
57
+ labels={labels}
58
+ separator={index < descriptors.length - 1 ? ', ' : ''}
59
+ />
60
+ </span>
61
+ ))}
62
+ </span>
63
+ )
64
+ }
65
+
66
+ return (
67
+ <ul className="cv-p-channels" aria-label={labels.channelsGroup}>
68
+ {descriptors.map((descriptor) => (
69
+ <li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
70
+ <BadgeContent descriptor={descriptor} labels={labels} separator="" />
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ )
75
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import type { QuickReply } from '../quickReplies/quickReply.types'
5
+ import { findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
8
+
9
+ const QUICK_REPLIES: readonly QuickReply[] = [
10
+ { id: '1', title: 'On my way', shortcut: 'way', body: 'I am on my way' },
11
+ { id: '2', title: 'Delivered', shortcut: 'done', body: 'Delivered' },
12
+ ]
13
+
14
+ function render(overrides: Partial<ParticipantComposerProps> = {}): string {
15
+ return renderToStaticMarkup(
16
+ <ParticipantComposer
17
+ value=""
18
+ onChange={() => undefined}
19
+ files={[]}
20
+ onFilesChange={() => undefined}
21
+ onSend={() => undefined}
22
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
23
+ {...overrides}
24
+ />,
25
+ )
26
+ }
27
+
28
+ describe('ParticipantComposer', () => {
29
+ it('renders a form with a labelled textarea and the maxLength prop', () => {
30
+ const markup = render({ maxLength: 500 })
31
+
32
+ expect(markup).toContain('<form')
33
+ expect(markup).toContain('<textarea')
34
+ expect(markup).toContain('maxLength="500"')
35
+ expect(markup).toContain('aria-label="Message"')
36
+ })
37
+
38
+ it('disables send without content and enables it with text or files', () => {
39
+ expect(render()).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
40
+ expect(render({ value: 'hi' })).not.toMatch(/<button[^>]*type="submit"[^>]*disabled/)
41
+ const file = new File(['x'], 'a.png', { type: 'image/png' })
42
+ expect(render({ files: [file] })).not.toMatch(/<button[^>]*type="submit"[^>]*disabled/)
43
+ })
44
+
45
+ it('disables send when the composer is disabled even with text', () => {
46
+ expect(render({ value: 'hi', disabled: true })).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
47
+ })
48
+
49
+ it('renders the hidden multiple file input with accept and the attach button', () => {
50
+ const markup = render({ acceptedTypes: ['image/*'] })
51
+
52
+ expect(markup).toContain('type="file"')
53
+ expect(markup).toContain('multiple')
54
+ expect(markup).toContain('accept="image/*"')
55
+ expect(markup).toContain('Attach')
56
+ })
57
+
58
+ it('lists chosen files with a remove button', () => {
59
+ const file = new File(['x'], 'photo.png', { type: 'image/png' })
60
+ const markup = render({ files: [file] })
61
+
62
+ expect(markup).toContain('photo.png')
63
+ expect(markup).toContain('Remove attachment')
64
+ })
65
+
66
+ it('renders quick reply chips only when quickReplies is non-empty', () => {
67
+ expect(render()).not.toContain('cv-p-quick')
68
+ expect(render({ quickReplies: [] })).not.toContain('cv-p-quick')
69
+ const markup = render({ quickReplies: QUICK_REPLIES })
70
+ expect(markup).toContain('cv-p-quick')
71
+ expect(markup).toContain('On my way')
72
+ expect(markup).toContain('type="button"')
73
+ })
74
+
75
+ it('renders chips as type=button so tapping one never submits the form', () => {
76
+ const markup = render({ quickReplies: QUICK_REPLIES })
77
+ const chips = [...markup.matchAll(/<button[^>]*class="cv-p-chip"[^>]*>/g)].map((match) => match[0])
78
+
79
+ expect(chips).toHaveLength(2)
80
+ for (const chip of chips) {
81
+ expect(chip).toContain('type="button"')
82
+ expect(chip).not.toContain('type="submit"')
83
+ }
84
+ expect(markup.match(/type="submit"/g)).toHaveLength(1)
85
+ })
86
+
87
+ it('has no microphone control', () => {
88
+ expect(render({ quickReplies: QUICK_REPLIES }).toLowerCase()).not.toMatch(/microphone|record/)
89
+ })
90
+
91
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
92
+ const markup = render({ quickReplies: QUICK_REPLIES, value: 'x' })
93
+
94
+ expect(findUtilityClassTokens(markup)).toEqual([])
95
+ expect(findForeignClassTokens(markup)).toEqual([])
96
+ })
97
+ })