@adatechnology/conversations-ui 0.5.0 → 0.6.1

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 (52) hide show
  1. package/README.md +82 -20
  2. package/dist/{chunk-JXJCPN34.js → chunk-FAQLC3TX.js} +1 -1
  3. package/dist/{chunk-RJMSM3YA.js → chunk-O6457XHK.js} +17 -11
  4. package/dist/chunk-OVFCLRQQ.js +84 -0
  5. package/dist/index.d.ts +14 -4
  6. package/dist/index.js +7 -7
  7. package/dist/participant/index.d.ts +25 -1
  8. package/dist/participant/index.js +454 -194
  9. package/dist/preview/index.js +2 -2
  10. package/dist/styles.css +106 -0
  11. package/package.json +3 -3
  12. package/src/MessageBubble.test.tsx +2 -2
  13. package/src/MessageText.test.tsx +27 -10
  14. package/src/MessageText.tsx +25 -17
  15. package/src/MessageTextCopiedBadge.tsx +10 -2
  16. package/src/StatusTicks.test.tsx +27 -6
  17. package/src/StatusTicks.tsx +30 -15
  18. package/src/cssBlocks.test-helper.ts +31 -0
  19. package/src/legacyChannels.golden.test.ts +89 -0
  20. package/src/legacyDefaults.golden.test.tsx +127 -0
  21. package/src/legacyStyles.fixture.css +1121 -0
  22. package/src/legacyStyles.golden.test.ts +92 -0
  23. package/src/messageTextCopyInteraction.test.ts +75 -0
  24. package/src/messageTextCopyInteraction.ts +37 -0
  25. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  26. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  27. package/src/participant/ParticipantConversations.tsx +13 -6
  28. package/src/participant/ParticipantConversationsScreen.tsx +18 -7
  29. package/src/participant/ParticipantInbox.test.tsx +99 -2
  30. package/src/participant/ParticipantInbox.tsx +27 -46
  31. package/src/participant/ParticipantInboxRow.tsx +75 -0
  32. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  33. package/src/participant/ParticipantMessageBubble.test.tsx +2 -0
  34. package/src/participant/ParticipantMessageBubble.tsx +3 -3
  35. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  36. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  37. package/src/participant/ParticipantThread.test.tsx +19 -2
  38. package/src/participant/ParticipantThread.tsx +25 -15
  39. package/src/participant/index.ts +1 -0
  40. package/src/participant/participant.types.ts +9 -0
  41. package/src/participant/participantChannels.test.ts +42 -0
  42. package/src/participant/participantChannels.ts +33 -0
  43. package/src/participant/participantGrouping.test.ts +101 -3
  44. package/src/participant/participantGrouping.ts +20 -7
  45. package/src/participant/participantLabels.ts +29 -0
  46. package/src/participant/participantProtocol.test.ts +104 -0
  47. package/src/participant/participantProtocol.ts +52 -0
  48. package/src/participant/participantProtocolCopy.test.ts +45 -0
  49. package/src/participant/participantProtocolCopy.ts +32 -0
  50. package/src/participantLayoutShrink.test.ts +82 -0
  51. package/src/styles.css +22 -1
  52. package/dist/chunk-WSFGNR5R.js +0 -55
@@ -1,14 +1,16 @@
1
1
  import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
2
 
3
- import { formatTimestamp, isSameDay } from '../lib/format'
4
3
  import type {
5
4
  ParticipantInboxFilter,
6
5
  ParticipantInboxSection,
7
6
  ParticipantInboxView,
8
7
  ParticipantSubjectGroup,
8
+ ParticipantSubjectIconRenderer,
9
9
  } from './participant.types'
10
- import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
10
+ import { type ParticipantConversationsLabels } from './participantLabels'
11
11
  import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
12
+ import { ParticipantInboxRow } from './ParticipantInboxRow'
13
+ import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
12
14
  import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
13
15
 
14
16
  export type ParticipantInboxProps = {
@@ -24,14 +26,17 @@ export type ParticipantInboxProps = {
24
26
  readonly hasMore: boolean
25
27
  readonly loadMore: () => void
26
28
  readonly locale?: string
29
+ /** Absent = no search field. The host narrows the view with the query; the field only edits it. */
30
+ readonly search?: ParticipantInboxSearch
31
+ /** Absent, or returning null/undefined, = the subject group icon. */
32
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
27
33
  }
28
34
 
29
35
  const ALL_FILTER = 'all'
30
36
 
31
- function formatRowTime(iso: string, locale: string | undefined): string {
32
- const date = new Date(iso)
33
- if (isSameDay(date, new Date())) return formatTimestamp(iso)
34
- return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
37
+ function resolveNoResultsLabel({ view, hasMore, labels }: ParticipantInboxProps): string {
38
+ if (view.hasMatchesOutsideFilter) return labels.noResultsInFilter
39
+ return hasMore ? labels.noResultsLoadedOnly : labels.noResults
35
40
  }
36
41
 
37
42
  function sectionTitle(section: ParticipantInboxSection, props: ParticipantInboxProps): string {
@@ -67,42 +72,6 @@ function Filters({ filters, active, labels, onChange }: FiltersProps) {
67
72
  )
68
73
  }
69
74
 
70
- type RowProps = {
71
- readonly conversation: ParticipantConversationSummary
72
- readonly group: ParticipantSubjectGroup | undefined
73
- readonly labels: ParticipantConversationsLabels
74
- readonly locale?: string
75
- readonly onSelect: (subject: ParticipantSubjectRef) => void
76
- }
77
-
78
- function Row({ conversation, group, labels, locale, onSelect }: RowProps) {
79
- const kind = group?.label ?? conversation.subjectType
80
- const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
81
- const { subjectType, subjectId } = conversation
82
-
83
- return (
84
- <button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
85
- <span className="cv-p-row__icon" aria-hidden="true">
86
- {group?.icon ?? kind.slice(0, 2)}
87
- </span>
88
- <span className="cv-p-row__body">
89
- <span className="cv-p-row__kind">{kind}</span>
90
- <span className="cv-p-row__title">{conversation.subjectLabel}</span>
91
- {conversation.lastMessagePreview ? <span className="cv-p-row__preview">{conversation.lastMessagePreview}</span> : null}
92
- </span>
93
- <span className="cv-p-row__meta">
94
- {conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
95
- {conversation.unreadCount > 0 ? (
96
- <span className="cv-p-row__unread">
97
- <span aria-hidden="true">{conversation.unreadCount}</span>
98
- <span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
99
- </span>
100
- ) : null}
101
- </span>
102
- </button>
103
- )
104
- }
105
-
106
75
  function SectionHeading({ title, count }: { readonly title: string; readonly count: number }) {
107
76
  return (
108
77
  <>
@@ -112,15 +81,22 @@ function SectionHeading({ title, count }: { readonly title: string; readonly cou
112
81
  )
113
82
  }
114
83
 
115
- function Section({ section, props }: { readonly section: ParticipantInboxSection; readonly props: ParticipantInboxProps }) {
84
+ function Section({
85
+ section,
86
+ props,
87
+ }: {
88
+ readonly section: ParticipantInboxSection
89
+ readonly props: ParticipantInboxProps
90
+ }) {
116
91
  const rows = section.conversations.map((conversation) => (
117
- <Row
92
+ <ParticipantInboxRow
118
93
  key={`${conversation.subjectType}:${conversation.subjectId}`}
119
94
  conversation={conversation}
120
95
  group={props.subjectGroups.find((group) => group.subjectType === conversation.subjectType)}
121
96
  labels={props.labels}
122
97
  locale={props.locale}
123
98
  onSelect={props.onSelect}
99
+ renderSubjectIcon={props.renderSubjectIcon}
124
100
  />
125
101
  ))
126
102
  const heading = <SectionHeading title={sectionTitle(section, props)} count={section.conversations.length} />
@@ -144,17 +120,22 @@ function Section({ section, props }: { readonly section: ParticipantInboxSection
144
120
 
145
121
  export function ParticipantInbox(props: ParticipantInboxProps) {
146
122
  const { view, labels, filter, onFilterChange } = props
147
- const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 })
123
+ const isSearching = (props.search?.value.trim() ?? '') !== ''
124
+ const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 || isSearching })
125
+ const hasNoResults = isSearching && view.sections.length === 0 && props.status === 'ready'
126
+ const noResultsLabel = resolveNoResultsLabel(props)
148
127
 
149
128
  return (
150
129
  <div className="cv-p cv-p-inbox" aria-busy={loadView.isLoading}>
151
130
  <h2 className="cv-p-inbox__title">{labels.inboxTitle}</h2>
152
131
  {view.showFilters ? (
153
- <Filters filters={view.filters} active={filter} labels={labels} onChange={onFilterChange} />
132
+ <Filters filters={view.filters} active={view.activeFilter} labels={labels} onChange={onFilterChange} />
154
133
  ) : null}
134
+ {props.search?.isVisible ? <ParticipantInboxSearchField search={props.search} labels={labels} /> : null}
155
135
  {loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
156
136
  {loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
157
137
  {loadView.isEmpty ? <p className="cv-p-empty">{labels.emptyInbox}</p> : null}
138
+ {hasNoResults ? <p className="cv-p-empty">{noResultsLabel}</p> : null}
158
139
  {view.sections.map((section) => (
159
140
  <Section key={section.key} section={section} props={props} />
160
141
  ))}
@@ -0,0 +1,75 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import { formatTimestamp, isSameDay } from '../lib/format'
4
+ import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
5
+ import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
6
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
7
+
8
+ export type ParticipantInboxRowProps = {
9
+ readonly conversation: ParticipantConversationSummary
10
+ readonly group: ParticipantSubjectGroup | undefined
11
+ readonly labels: ParticipantConversationsLabels
12
+ readonly locale?: string
13
+ readonly onSelect: (subject: ParticipantSubjectRef) => void
14
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
15
+ }
16
+
17
+ function formatRowTime(iso: string, locale: string | undefined): string {
18
+ const date = new Date(iso)
19
+ if (isSameDay(date, new Date())) return formatTimestamp(iso)
20
+ return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
21
+ }
22
+
23
+ function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
24
+ const custom = props.renderSubjectIcon?.(props.conversation)
25
+ if (custom !== null && custom !== undefined && custom !== false && custom !== '') {
26
+ return (
27
+ <span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
28
+ {custom}
29
+ </span>
30
+ )
31
+ }
32
+ return (
33
+ <span className="cv-p-row__icon" aria-hidden="true">
34
+ {props.group?.icon ?? kind.slice(0, 2)}
35
+ </span>
36
+ )
37
+ }
38
+
39
+ export function ParticipantInboxRow(props: ParticipantInboxRowProps) {
40
+ const { conversation, group, labels, locale, onSelect } = props
41
+ const kind = group?.label ?? conversation.subjectType
42
+ const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
43
+ const { subjectType, subjectId } = conversation
44
+
45
+ return (
46
+ <button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
47
+ <RowIcon props={props} kind={kind} />
48
+ <span className="cv-p-row__body">
49
+ <span className="cv-p-row__kind">{kind}</span>
50
+ <span className="cv-p-row__title">{conversation.subjectLabel}</span>
51
+ <span className="cv-p-row__tags">
52
+ {conversation.protocol ? (
53
+ <span className="cv-p-protocol">
54
+ <span className="cv-p-sr-only">{labels.protocolPrefix} </span>
55
+ {conversation.protocol}
56
+ </span>
57
+ ) : null}
58
+ <ParticipantChannelBadges channels={conversation.channels} labels={labels} variant="inline" />
59
+ </span>
60
+ {conversation.lastMessagePreview ? (
61
+ <span className="cv-p-row__preview">{conversation.lastMessagePreview}</span>
62
+ ) : null}
63
+ </span>
64
+ <span className="cv-p-row__meta">
65
+ {conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
66
+ {conversation.unreadCount > 0 ? (
67
+ <span className="cv-p-row__unread">
68
+ <span aria-hidden="true">{conversation.unreadCount}</span>
69
+ <span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
70
+ </span>
71
+ ) : null}
72
+ </span>
73
+ </button>
74
+ )
75
+ }
@@ -0,0 +1,34 @@
1
+ import { useId } from 'react'
2
+
3
+ import type { ParticipantConversationsLabels } from './participantLabels'
4
+
5
+ export type ParticipantInboxSearch = {
6
+ readonly value: string
7
+ readonly onChange: (value: string) => void
8
+ readonly isVisible: boolean
9
+ }
10
+
11
+ export type ParticipantInboxSearchFieldProps = {
12
+ readonly search: ParticipantInboxSearch
13
+ readonly labels: ParticipantConversationsLabels
14
+ }
15
+
16
+ export function ParticipantInboxSearchField({ search, labels }: ParticipantInboxSearchFieldProps) {
17
+ const inputId = useId()
18
+ return (
19
+ <div className="cv-p-search">
20
+ <label className="cv-p-sr-only" htmlFor={inputId}>
21
+ {labels.searchLabel}
22
+ </label>
23
+ <input
24
+ id={inputId}
25
+ type="search"
26
+ className="cv-p-search__input"
27
+ value={search.value}
28
+ placeholder={labels.searchPlaceholder}
29
+ autoComplete="off"
30
+ onChange={(event) => search.onChange(event.target.value)}
31
+ />
32
+ </div>
33
+ )
34
+ }
@@ -105,6 +105,8 @@ describe('ParticipantMessageBubble', () => {
105
105
 
106
106
  expect(withRetry).toContain('<button')
107
107
  expect(withRetry).toContain('Failed — tap to retry')
108
+ expect(withRetry).toContain('cv-status-ticks--failed')
109
+ expect(findUtilityClassTokens(withRetry)).toEqual([])
108
110
  expect(withoutRetry).not.toContain('<button')
109
111
  expect(withoutRetry).toContain('cv-status-ticks--failed')
110
112
  })
@@ -85,7 +85,7 @@ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
85
85
  if (status === 'failed' && onRetry) {
86
86
  return (
87
87
  <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
88
- <StatusTicks status="failed" />
88
+ <StatusTicks status="failed" appearance="stylesheet" />
89
89
  <span>{labels.statusFailed}</span>
90
90
  </button>
91
91
  )
@@ -94,7 +94,7 @@ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
94
94
  const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
95
95
  return (
96
96
  <span className="cv-p-bubble__status">
97
- <StatusTicks status={ticksStatus} />
97
+ <StatusTicks status={ticksStatus} appearance="stylesheet" />
98
98
  <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
99
99
  </span>
100
100
  )
@@ -158,7 +158,7 @@ export function ParticipantMessageBubble({
158
158
  <span className="cv-p-bubble__author">{content.authorName}</span>
159
159
  ) : null}
160
160
  {content.text ? (
161
- <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} />
161
+ <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
162
162
  ) : null}
163
163
  {content.attachments.map((attachment) => (
164
164
  <ParticipantAttachmentItem
@@ -0,0 +1,74 @@
1
+ import { useEffect, useReducer } from 'react'
2
+
3
+ import type { ParticipantConversationsLabels } from './participantLabels'
4
+ import {
5
+ INITIAL_PROTOCOL_COPY_STATE,
6
+ copyProtocolToClipboard,
7
+ protocolCopyReducer,
8
+ type ProtocolCopyState,
9
+ } from './participantProtocolCopy'
10
+
11
+ const COPIED_ANNOUNCEMENT_MS = 3000
12
+
13
+ export type ParticipantProtocolBadgeProps = {
14
+ readonly protocol: string
15
+ readonly labels: ParticipantConversationsLabels
16
+ }
17
+
18
+ export type ParticipantProtocolBadgeViewProps = ParticipantProtocolBadgeProps & {
19
+ readonly copyState: ProtocolCopyState
20
+ readonly onCopy: () => void
21
+ }
22
+
23
+ export function ParticipantProtocolBadgeView({
24
+ protocol,
25
+ labels,
26
+ copyState,
27
+ onCopy,
28
+ }: ParticipantProtocolBadgeViewProps) {
29
+ const { isCopied, copyCount } = copyState
30
+ return (
31
+ <>
32
+ <div className="cv-p-protocol cv-p-protocol--header">
33
+ <span className="cv-p-protocol__code">
34
+ <span className="cv-p-sr-only">{labels.protocolPrefix} </span>
35
+ {protocol}
36
+ </span>
37
+ <button type="button" className="cv-p-protocol__copy" onClick={onCopy}>
38
+ <span aria-hidden="true">{isCopied ? labels.protocolCopied : labels.copyProtocol}</span>
39
+ <span className="cv-p-sr-only">
40
+ {labels.copyProtocol} {protocol}
41
+ </span>
42
+ </button>
43
+ </div>
44
+ <div className="cv-p-sr-only" role="status" aria-live="polite">
45
+ {isCopied ? <span key={copyCount}>{labels.protocolCopied}</span> : null}
46
+ </div>
47
+ </>
48
+ )
49
+ }
50
+
51
+ export function ParticipantProtocolBadge({ protocol, labels }: ParticipantProtocolBadgeProps) {
52
+ const [copyState, dispatch] = useReducer(protocolCopyReducer, INITIAL_PROTOCOL_COPY_STATE)
53
+ const { isCopied, copyCount } = copyState
54
+
55
+ useEffect(() => {
56
+ if (!isCopied) return undefined
57
+ const timer = setTimeout(() => dispatch({ type: 'expired' }), COPIED_ANNOUNCEMENT_MS)
58
+ return () => clearTimeout(timer)
59
+ }, [isCopied, copyCount])
60
+
61
+ async function handleCopy(): Promise<void> {
62
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
63
+ if (await copyProtocolToClipboard(protocol, clipboard)) dispatch({ type: 'copied' })
64
+ }
65
+
66
+ return (
67
+ <ParticipantProtocolBadgeView
68
+ protocol={protocol}
69
+ labels={labels}
70
+ copyState={copyState}
71
+ onCopy={() => void handleCopy()}
72
+ />
73
+ )
74
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+
6
+ import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
7
+ import { groupParticipantConversations } from './participantGrouping'
8
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { ParticipantInbox } from './ParticipantInbox'
10
+ import { ParticipantProtocolBadgeView } from './ParticipantProtocolBadge'
11
+ import { ParticipantThread } from './ParticipantThread'
12
+
13
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
14
+ const WITH_PROTOCOL = buildConversation({ subjectId: '1', subjectLabel: 'Item 4521', protocol: '261009-K7M2' })
15
+ const WITHOUT_PROTOCOL = buildConversation({ subjectId: '2', subjectLabel: 'Item 9999' })
16
+
17
+ type InboxOverrides = {
18
+ search?: { value: string; isVisible: boolean }
19
+ hasMore?: boolean
20
+ filter?: string
21
+ hasMatchesOutsideFilter?: boolean
22
+ }
23
+
24
+ function renderInbox(conversations: readonly (typeof WITH_PROTOCOL)[], overrides: InboxOverrides = {}): string {
25
+ const view = {
26
+ ...groupParticipantConversations({ conversations, subjectGroups: [], filter: 'all' }),
27
+ ...(overrides.hasMatchesOutsideFilter === undefined
28
+ ? {}
29
+ : { hasMatchesOutsideFilter: overrides.hasMatchesOutsideFilter }),
30
+ }
31
+ return renderToStaticMarkup(
32
+ <ParticipantInbox
33
+ view={view}
34
+ subjectGroups={[]}
35
+ filter="all"
36
+ onFilterChange={() => undefined}
37
+ onSelect={() => undefined}
38
+ labels={LABELS}
39
+ status="ready"
40
+ refresh={() => undefined}
41
+ hasMore={overrides.hasMore ?? false}
42
+ loadMore={() => undefined}
43
+ search={overrides.search ? { ...overrides.search, onChange: () => undefined } : undefined}
44
+ />,
45
+ )
46
+ }
47
+
48
+ function renderThread(conversation: typeof WITH_PROTOCOL): string {
49
+ return renderToStaticMarkup(
50
+ <ParticipantThread
51
+ conversation={conversation}
52
+ items={[]}
53
+ hasMore={false}
54
+ labels={LABELS}
55
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
56
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
57
+ onSend={() => undefined}
58
+ status="ready"
59
+ refresh={() => undefined}
60
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
61
+ newMessagesCount={0}
62
+ isSending={false}
63
+ />,
64
+ )
65
+ }
66
+
67
+ describe('protocol in the inbox row', () => {
68
+ it('shows the protocol with an accessible name', () => {
69
+ const markup = renderInbox([WITH_PROTOCOL])
70
+ expect(markup).toContain('cv-p-protocol')
71
+ expect(markup).toContain('261009-K7M2')
72
+ expect(markup).toContain('<span class="cv-p-sr-only">Protocol </span>261009-K7M2')
73
+ expect(markup).not.toMatch(/<span[^>]*aria-label/)
74
+ })
75
+
76
+ it('draws nothing when the protocol is absent', () => {
77
+ expect(renderInbox([WITHOUT_PROTOCOL])).not.toContain('cv-p-protocol')
78
+ })
79
+ })
80
+
81
+ describe('inbox search', () => {
82
+ it('is drawn with a label, placeholder and type=search when visible', () => {
83
+ const markup = renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } })
84
+ expect(markup).toContain('type="search"')
85
+ expect(markup).toContain('cv-p-search')
86
+ expect(markup).toContain(LABELS.searchLabel)
87
+ expect(markup).toContain(`placeholder="${LABELS.searchPlaceholder}"`)
88
+ })
89
+
90
+ it('is not drawn when hidden or when the prop is absent', () => {
91
+ expect(renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: false } })).not.toContain('cv-p-search')
92
+ expect(renderInbox([WITH_PROTOCOL])).not.toContain('cv-p-search')
93
+ })
94
+
95
+ it('shows the no-results state instead of the empty inbox when a query finds nothing', () => {
96
+ const markup = renderInbox([], { search: { value: 'zzz', isVisible: true } })
97
+ expect(markup).toContain(LABELS.noResults)
98
+ expect(markup).not.toContain(LABELS.emptyInbox)
99
+ })
100
+ })
101
+
102
+ describe('protocol in the thread header', () => {
103
+ it('shows the protocol and a copy button of at least a touch target', () => {
104
+ const markup = renderThread(WITH_PROTOCOL)
105
+ expect(markup).toContain('261009-K7M2')
106
+ expect(markup).toContain('cv-p-protocol__copy')
107
+ expect(markup).toContain('type="button"')
108
+ expect(markup).toContain(LABELS.copyProtocol)
109
+ })
110
+
111
+ it('names the code with a visually hidden prefix and no aria-label on the generic span', () => {
112
+ const markup = renderThread(WITH_PROTOCOL)
113
+ expect(markup).toContain(`<span class="cv-p-sr-only">${LABELS.protocolPrefix} </span>261009-K7M2`)
114
+ expect(markup).not.toMatch(/<span[^>]*aria-label/)
115
+ })
116
+
117
+ it('keeps an empty polite live region in the tree', () => {
118
+ const markup = renderThread(WITH_PROTOCOL)
119
+ expect(markup).toMatch(/<div class="cv-p-sr-only" role="status" aria-live="polite"><\/div>/)
120
+ expect(markup).not.toContain(LABELS.protocolCopied)
121
+ })
122
+
123
+ it('draws neither protocol nor button nor live region when absent', () => {
124
+ const markup = renderThread(WITHOUT_PROTOCOL)
125
+ expect(markup).not.toContain('cv-p-protocol')
126
+ expect(markup.match(/aria-live="polite"/g)?.length).toBe(1)
127
+ })
128
+
129
+ it('does not nest a status region inside the protocol block', () => {
130
+ const markup = renderThread(WITH_PROTOCOL)
131
+ const block = markup.slice(
132
+ markup.indexOf('cv-p-protocol--header'),
133
+ markup.indexOf('</button>', markup.indexOf('cv-p-protocol__copy')),
134
+ )
135
+ expect(block).not.toContain('role="status"')
136
+ expect(block).not.toContain('aria-live')
137
+ })
138
+ })
139
+
140
+ describe('copy button', () => {
141
+ const render = (isCopied: boolean) =>
142
+ renderToStaticMarkup(
143
+ <ParticipantProtocolBadgeView
144
+ protocol="261009-K7M2"
145
+ labels={LABELS}
146
+ copyState={{ copyCount: isCopied ? 1 : 0, isCopied }}
147
+ onCopy={() => undefined}
148
+ />,
149
+ )
150
+
151
+ it('has a default visible label starting the accessible name', () => {
152
+ expect(LABELS.copyProtocol).toBe('Copy protocol')
153
+ const markup = render(false)
154
+ expect(markup).toContain('<span aria-hidden="true">Copy protocol</span>')
155
+ expect(markup).toContain('<span class="cv-p-sr-only">Copy protocol 261009-K7M2</span>')
156
+ })
157
+
158
+ it('swaps the visible text for the confirmation while copied, hidden from assistive tech', () => {
159
+ const markup = render(true)
160
+ expect(markup).toContain(`<span aria-hidden="true">${LABELS.protocolCopied}</span>`)
161
+ expect(markup).toContain(`<span>${LABELS.protocolCopied}</span></div>`)
162
+ })
163
+
164
+ it('announces only through the live region, keyed by the copy count', () => {
165
+ expect(render(false)).toMatch(/aria-live="polite"><\/div>/)
166
+ expect(render(true)).toMatch(/aria-live="polite"><span>Protocol copied<\/span><\/div>/)
167
+ })
168
+ })
169
+
170
+ describe('inbox empty states', () => {
171
+ const search = { value: 'zzz', isVisible: true }
172
+
173
+ it('says the filter is the reason when the query matches in another subject', () => {
174
+ const markup = renderInbox([], { search, hasMatchesOutsideFilter: true })
175
+ expect(markup).toContain(LABELS.noResultsInFilter)
176
+ expect(markup).not.toContain(`>${LABELS.noResults}<`)
177
+ })
178
+
179
+ it('says the search covers only the loaded conversations when more exist', () => {
180
+ const markup = renderInbox([], { search, hasMore: true })
181
+ expect(markup).toContain(LABELS.noResultsLoadedOnly)
182
+ expect(markup).toContain(LABELS.loadMore)
183
+ })
184
+
185
+ it('keeps the plain message when nothing more can be loaded', () => {
186
+ expect(renderInbox([], { search })).toContain(`>${LABELS.noResults}<`)
187
+ })
188
+ })
189
+
190
+ describe('protocol markup hygiene', () => {
191
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
192
+ const markup =
193
+ renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } }) + renderThread(WITH_PROTOCOL)
194
+ expect(findUtilityClassTokens(markup)).toEqual([])
195
+ expect(findForeignClassTokens(markup)).toEqual([])
196
+ })
197
+
198
+ it('keeps product vocabulary out of the new sources and labels', () => {
199
+ const files = readdirSync(fileURLToPath(new URL('.', import.meta.url))).filter(
200
+ (file) => /\.tsx?$/.test(file) && !/\.test(-helper)?\.tsx?$/.test(file),
201
+ )
202
+ const source = files.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n')
203
+ expect(files.length).toBeGreaterThan(20)
204
+ expect(source).not.toMatch(
205
+ /\b(carga|entrega|frete|cte|nf-?e|transport\w*|nota|ocorr\w*|motorista|viagem|trip|driver)\b/i,
206
+ )
207
+ })
208
+ })
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
 
4
- import { buildConversation, buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
4
+ import {
5
+ buildConversation,
6
+ buildMessage,
7
+ findForeignClassTokens,
8
+ findUtilityClassTokens,
9
+ } from './participantFixtures.test-helper'
5
10
  import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
6
11
  import type { ParticipantTimelineItem } from './participantMessages'
7
12
  import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
@@ -54,6 +59,15 @@ describe('ParticipantThread', () => {
54
59
  expect(markup).toContain('Card body')
55
60
  })
56
61
 
62
+ it('shows channel badges in the header only with channels', () => {
63
+ expect(render()).not.toContain('cv-p-channels')
64
+ const markup = render({
65
+ conversation: buildConversation({ subjectId: '1', channels: ['email', 'app'] }),
66
+ })
67
+ expect(markup).toContain('cv-p-channel--app')
68
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--email'))
69
+ })
70
+
57
71
  it('renders day dividers between days', () => {
58
72
  expect(render().match(/cv-p-day"/g)?.length).toBe(2)
59
73
  })
@@ -92,7 +106,10 @@ describe('ParticipantThread', () => {
92
106
  })
93
107
 
94
108
  it('disables only the send button while sending, never the field', () => {
95
- const markup = render({ isSending: true, draft: { value: 'hi', onChange: () => undefined, files: [], onFilesChange: () => undefined } })
109
+ const markup = render({
110
+ isSending: true,
111
+ draft: { value: 'hi', onChange: () => undefined, files: [], onFilesChange: () => undefined },
112
+ })
96
113
 
97
114
  expect(markup).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
98
115
  expect(markup).not.toMatch(/<textarea[^>]*disabled/)
@@ -8,13 +8,19 @@ import { isSameDay } from '../lib/format'
8
8
  import type { QuickReply } from '../quickReplies/quickReply.types'
9
9
  import { ParticipantComposer } from './ParticipantComposer'
10
10
  import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
11
- import { bindPendingActions, resolveParticipantBubbleActions, type ParticipantPendingActions } from './participantBubbleActions'
11
+ import {
12
+ bindPendingActions,
13
+ resolveParticipantBubbleActions,
14
+ type ParticipantPendingActions,
15
+ } from './participantBubbleActions'
12
16
  import type { ParticipantConversationsLabels } from './participantLabels'
13
17
  import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
14
18
  import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
15
19
  import type { ParticipantThreadScroll } from './useStickToBottom'
16
20
  import type { ParticipantTimelineItem } from './participantMessages'
17
21
  import { ParticipantMessageBubble } from './ParticipantMessageBubble'
22
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
23
+ import { ParticipantProtocolBadge } from './ParticipantProtocolBadge'
18
24
 
19
25
  export type ParticipantThreadDraft = {
20
26
  readonly value: string
@@ -76,20 +82,24 @@ function Header({ props }: { readonly props: ParticipantThreadProps }) {
76
82
  <span className="cv-p-sr-only">{labels.back}</span>
77
83
  </button>
78
84
  ) : null}
79
- <h2 className="cv-p-thread__title">
80
- {onOpenSubject ? (
81
- <button
82
- type="button"
83
- className="cv-p-thread__title-button"
84
- aria-label={`${labels.openSubject}: ${conversation.subjectLabel}`}
85
- onClick={() => onOpenSubject({ subjectType, subjectId })}
86
- >
87
- {conversation.subjectLabel}
88
- </button>
89
- ) : (
90
- conversation.subjectLabel
91
- )}
92
- </h2>
85
+ <div className="cv-p-thread__heading">
86
+ <h2 className="cv-p-thread__title">
87
+ {onOpenSubject ? (
88
+ <button
89
+ type="button"
90
+ className="cv-p-thread__title-button"
91
+ aria-label={`${labels.openSubject}: ${conversation.subjectLabel}`}
92
+ onClick={() => onOpenSubject({ subjectType, subjectId })}
93
+ >
94
+ {conversation.subjectLabel}
95
+ </button>
96
+ ) : (
97
+ conversation.subjectLabel
98
+ )}
99
+ </h2>
100
+ {conversation.protocol ? <ParticipantProtocolBadge protocol={conversation.protocol} labels={labels} /> : null}
101
+ <ParticipantChannelBadges channels={conversation.channels} labels={labels} />
102
+ </div>
93
103
  </header>
94
104
  )
95
105
  }