@adatechnology/conversations-ui 0.6.0 → 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 (88) 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 +591 -19
  11. package/package.json +1 -1
  12. package/src/cssBlocks.test-helper.ts +31 -0
  13. package/src/legacyStyles.golden.test.ts +2 -31
  14. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  15. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  16. package/src/participant/ParticipantBackButton.tsx +14 -0
  17. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  18. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  19. package/src/participant/ParticipantComposer.tsx +8 -40
  20. package/src/participant/ParticipantComposerControls.tsx +83 -0
  21. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  22. package/src/participant/ParticipantConversations.tsx +7 -0
  23. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  24. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  26. package/src/participant/ParticipantDayDivider.tsx +18 -0
  27. package/src/participant/ParticipantInbox.tsx +3 -1
  28. package/src/participant/ParticipantInboxRow.tsx +6 -4
  29. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  30. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  31. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  32. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  33. package/src/participant/ParticipantMessageText.tsx +52 -0
  34. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  35. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  36. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  37. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  38. package/src/participant/ParticipantThread.test.tsx +37 -0
  39. package/src/participant/ParticipantThread.tsx +45 -95
  40. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  41. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  42. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  44. package/src/participant/ParticipantTimeline.tsx +103 -0
  45. package/src/participant/index.ts +1 -0
  46. package/src/participant/participantAutoGrow.test.ts +43 -0
  47. package/src/participant/participantAutoGrow.ts +12 -0
  48. package/src/participant/participantAvatar.test.ts +74 -0
  49. package/src/participant/participantAvatar.ts +48 -0
  50. package/src/participant/participantBubbleTail.test.tsx +107 -0
  51. package/src/participant/participantChatDesign.test.tsx +198 -0
  52. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  53. package/src/participant/participantCopyAnnouncement.ts +37 -0
  54. package/src/participant/participantCss.test-helper.ts +34 -0
  55. package/src/participant/participantDay.test.ts +22 -0
  56. package/src/participant/participantDay.ts +8 -0
  57. package/src/participant/participantDocuments.ts +45 -0
  58. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  59. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  60. package/src/participant/participantLabels.ts +11 -0
  61. package/src/participant/participantMessageAtoms.ts +53 -0
  62. package/src/participant/participantMessageCopyables.test.ts +141 -0
  63. package/src/participant/participantMessageCopyables.ts +76 -0
  64. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  65. package/src/participant/participantMessageFormat.test.ts +177 -0
  66. package/src/participant/participantMessageFormat.ts +135 -0
  67. package/src/participant/participantMessageFormat.types.ts +22 -0
  68. package/src/participant/participantMessageInline.test.ts +82 -0
  69. package/src/participant/participantMessageInline.ts +99 -0
  70. package/src/participant/participantMessageLinks.test.ts +100 -0
  71. package/src/participant/participantMessageLinks.ts +54 -0
  72. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  73. package/src/participant/participantScrollFocus.test.ts +22 -0
  74. package/src/participant/participantScrollFocus.ts +10 -0
  75. package/src/participant/participantScrollView.test.ts +74 -0
  76. package/src/participant/participantScrollView.ts +38 -0
  77. package/src/participant/participantScrollbars.test.ts +156 -0
  78. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  79. package/src/participant/participantSubjectIcon.ts +26 -0
  80. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  81. package/src/participant/useParticipantThreadScroll.ts +10 -2
  82. package/src/participant/useRevealActiveChip.ts +20 -0
  83. package/src/participant/useScrollAwayState.ts +24 -0
  84. package/src/participant/useStickToBottom.ts +16 -2
  85. package/src/participantLayoutShrink.test.ts +82 -0
  86. package/src/styles.css +140 -21
  87. package/dist/chunk-MRVFFJEH.js +0 -64
  88. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -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
+ })
@@ -0,0 +1,37 @@
1
+ export type CopyAnnouncementState = { readonly text: string; readonly tick: number }
2
+
3
+ export type CopyAnnouncementAction = { readonly type: 'announce'; readonly text: string } | { readonly type: 'expired' }
4
+
5
+ export const INITIAL_COPY_ANNOUNCEMENT: CopyAnnouncementState = { text: '', tick: 0 }
6
+
7
+ const REPEAT_MARKER = '​'
8
+
9
+ /** The tick changes on every announcement so a repeat inside the window restarts the timer. */
10
+ export function copyAnnouncementReducer(
11
+ state: CopyAnnouncementState,
12
+ action: CopyAnnouncementAction,
13
+ ): CopyAnnouncementState {
14
+ if (action.type === 'announce') return { text: action.text, tick: state.tick + 1 }
15
+ return { ...state, text: '' }
16
+ }
17
+
18
+ export type LiveTextResolution = {
19
+ readonly text: string
20
+ /** The notice that was already spoken when the copy took over the region; undefined while it is not suppressed. */
21
+ readonly baselineNotice: string | undefined
22
+ }
23
+
24
+ /** A zero-width character alternates so identical consecutive announcements still change the region. */
25
+ export function resolveLiveText(params: {
26
+ readonly copy: CopyAnnouncementState
27
+ readonly notice: string
28
+ readonly baselineNotice: string | undefined
29
+ }): LiveTextResolution {
30
+ const { copy, notice, baselineNotice } = params
31
+ if (copy.text !== '') {
32
+ const marker = copy.tick % 2 === 0 ? REPEAT_MARKER : ''
33
+ return { text: `${copy.text}${marker}`, baselineNotice: baselineNotice ?? notice }
34
+ }
35
+ if (baselineNotice !== undefined && baselineNotice === notice) return { text: '', baselineNotice }
36
+ return { text: notice, baselineNotice: undefined }
37
+ }
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { splitBlocks } from '../cssBlocks.test-helper'
5
+
6
+ export const PARTICIPANT_CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(
7
+ /\/\*[\s\S]*?\*\//g,
8
+ '',
9
+ )
10
+
11
+ /** Declarations of every top-level rule whose selector list contains `selector`, or of the rules inside `scope`. */
12
+ export function declarationsIn(selector: string, scope: string = PARTICIPANT_CSS): Map<string, string> {
13
+ const declarations = new Map<string, string>()
14
+ for (const { prelude, body } of splitBlocks(scope)) {
15
+ if (body === undefined || prelude.startsWith('@')) continue
16
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
17
+ for (const declaration of body.split(';')) {
18
+ const separator = declaration.indexOf(':')
19
+ if (separator === -1) continue
20
+ declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
21
+ }
22
+ }
23
+ return declarations
24
+ }
25
+
26
+ /** Inner CSS of the `@media <query>` block, or undefined when it does not exist. */
27
+ export function mediaBody(query: string): string | undefined {
28
+ return splitBlocks(PARTICIPANT_CSS).find(({ prelude }) => prelude === `@media ${query}`)?.body
29
+ }
30
+
31
+ export function toRem(value: string | undefined): number {
32
+ const match = /^(-?\d*\.?\d+)rem$/.exec(value ?? '')
33
+ return match ? Number(match[1]) : 0
34
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { toDateTimeAttribute } from './participantDay'
4
+
5
+ describe('toDateTimeAttribute', () => {
6
+ it('writes the local calendar day, zero padded', () => {
7
+ expect(toDateTimeAttribute(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05')
8
+ expect(toDateTimeAttribute(new Date(2026, 9, 12, 0, 1))).toBe('2026-10-12')
9
+ })
10
+
11
+ it('keeps the local day when the instant already crosses midnight UTC, in any timezone', () => {
12
+ const lateLocalEvening = new Date(2026, 9, 8, 23, 30)
13
+ const earlyLocalMorning = new Date(2026, 9, 9, 0, 30)
14
+ expect(toDateTimeAttribute(lateLocalEvening)).toBe('2026-10-08')
15
+ expect(toDateTimeAttribute(earlyLocalMorning)).toBe('2026-10-09')
16
+ })
17
+
18
+ it('never reads the UTC day, even in a timezone where it equals the local one', () => {
19
+ const date = Object.assign(new Date(2026, 9, 8, 23, 30), { toISOString: () => '2026-10-09T02:30:00.000Z' })
20
+ expect(toDateTimeAttribute(date)).toBe('2026-10-08')
21
+ })
22
+ })
@@ -0,0 +1,8 @@
1
+ function pad(value: number): string {
2
+ return String(value).padStart(2, '0')
3
+ }
4
+
5
+ /** Local calendar day as the `datetime` of a `<time>`: the label shows the local day, so must the machine value. */
6
+ export function toDateTimeAttribute(date: Date): string {
7
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
8
+ }
@@ -0,0 +1,45 @@
1
+ const CNPJ_FIRST_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] as const
2
+ const CNPJ_SECOND_WEIGHTS = [6, ...CNPJ_FIRST_WEIGHTS] as const
3
+
4
+ function charValue(character: string): number {
5
+ return character.charCodeAt(0) - 48
6
+ }
7
+
8
+ function isRepeatedSequence(value: string): boolean {
9
+ return new Set(value).size === 1
10
+ }
11
+
12
+ function weightedSum(characters: string, weights: readonly number[]): number {
13
+ let sum = 0
14
+ for (let index = 0; index < weights.length; index += 1) {
15
+ sum += charValue(characters.charAt(index)) * (weights[index] ?? 0)
16
+ }
17
+ return sum
18
+ }
19
+
20
+ function cnpjCheckDigit(characters: string, weights: readonly number[]): number {
21
+ const remainder = weightedSum(characters, weights) % 11
22
+ return remainder < 2 ? 0 : 11 - remainder
23
+ }
24
+
25
+ function cpfCheckDigit(digits: string, length: number): number {
26
+ const weights = Array.from({ length }, (_, index) => length + 1 - index)
27
+ return ((weightedSum(digits, weights) * 10) % 11) % 10
28
+ }
29
+
30
+ /** `digits` is the bare 11-digit CPF; repeated sequences such as 111.111.111-11 are rejected. */
31
+ export function isValidCpf(digits: string): boolean {
32
+ if (!/^\d{11}$/.test(digits) || isRepeatedSequence(digits)) return false
33
+ return (
34
+ cpfCheckDigit(digits, 9) === charValue(digits.charAt(9)) &&
35
+ cpfCheckDigit(digits, 10) === charValue(digits.charAt(10))
36
+ )
37
+ }
38
+
39
+ /** `characters` is the bare 14-position CNPJ, numeric or alphanumeric ([A-Z0-9]{12}\d{2}). */
40
+ export function isValidCnpj(characters: string): boolean {
41
+ if (!/^[A-Z0-9]{12}\d{2}$/.test(characters) || isRepeatedSequence(characters)) return false
42
+ const first = cnpjCheckDigit(characters, CNPJ_FIRST_WEIGHTS)
43
+ if (first !== charValue(characters.charAt(12))) return false
44
+ return cnpjCheckDigit(characters, CNPJ_SECOND_WEIGHTS) === charValue(characters.charAt(13))
45
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { declarationsIn, mediaBody, toRem } from './participantCss.test-helper'
5
+ import { buildConversation } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import { ParticipantThreadHeader } from './ParticipantThreadHeader'
8
+
9
+ function render(onOpenSubject?: () => void): string {
10
+ return renderToStaticMarkup(
11
+ <ParticipantThreadHeader
12
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'CV-2026-0001' })}
13
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
14
+ onOpenSubject={onOpenSubject}
15
+ />,
16
+ )
17
+ }
18
+
19
+ describe('clickable title focus ring', () => {
20
+ it('clamps the text in one inner span, inside the button and inside the plain heading', () => {
21
+ expect(render(() => undefined)).toMatch(
22
+ /<button[^>]*cv-p-thread__title-button[^>]*><span class="cv-p-thread__title-text">Invoice 4521<\/span><\/button>/,
23
+ )
24
+ expect(render()).toContain(
25
+ '<h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2>',
26
+ )
27
+ })
28
+
29
+ it('puts the only clamp on the inner span', () => {
30
+ const text = declarationsIn('.cv-p-thread__title-text')
31
+ expect(text.get('-webkit-line-clamp')).toBe('1')
32
+ expect(text.get('overflow')).toBe('hidden')
33
+ for (const selector of ['.cv-p-thread__title', '.cv-p-thread__title-button']) {
34
+ expect(declarationsIn(selector).has('-webkit-line-clamp')).toBe(false)
35
+ }
36
+ })
37
+
38
+ it('never clips the outline: no ancestor of the button hides overflow', () => {
39
+ const ancestors = [
40
+ '.cv-p-thread__title',
41
+ '.cv-p-thread__title-button',
42
+ '.cv-p-thread__heading',
43
+ '.cv-p-thread__header',
44
+ ]
45
+ for (const selector of ancestors) {
46
+ const overflow = declarationsIn(selector).get('overflow')
47
+ expect(overflow === undefined || overflow === 'visible').toBe(true)
48
+ }
49
+ })
50
+ })
51
+
52
+ describe('copy touch area', () => {
53
+ it('is at least 2.75rem wide and 2rem tall', () => {
54
+ const copy = declarationsIn('.cv-p-protocol__copy')
55
+ expect(toRem(copy.get('min-width'))).toBeGreaterThanOrEqual(2.75)
56
+ expect(toRem(copy.get('min-height'))).toBeGreaterThanOrEqual(2)
57
+ })
58
+
59
+ it('extends above the button no further than the padding the meta line reserves', () => {
60
+ const negativeTop = Math.abs(
61
+ Math.min(0, toRem(declarationsIn('.cv-p-protocol__copy').get('margin')?.split(/\s+/)[0])),
62
+ )
63
+ const reservedTop = toRem(declarationsIn('.cv-p-thread__meta').get('padding-top'))
64
+ expect(negativeTop).toBeGreaterThan(0)
65
+ expect(negativeTop).toBeLessThanOrEqual(reservedTop)
66
+ })
67
+ })
68
+
69
+ describe('bubble tail in forced colors and print', () => {
70
+ it('drops both triangles and restores the radius under forced-colors', () => {
71
+ const body = mediaBody('(forced-colors: active)') ?? ''
72
+ expect(body).not.toBe('')
73
+ const tails = declarationsIn('.cv-p-bubble::before', body)
74
+ expect(tails.get('content')).toBe('none')
75
+ expect(declarationsIn('.cv-p-bubble::after', body).get('content')).toBe('none')
76
+ expect(declarationsIn('.cv-p-bubble', body).get('border-radius')).toBe('var(--cv-p-i-radius)')
77
+ expect(declarationsIn('.cv-p-bubble--mine', body).get('border-radius')).toBe('var(--cv-p-i-radius)')
78
+ })
79
+
80
+ it('drops both triangles when printing', () => {
81
+ const body = mediaBody('print') ?? ''
82
+ expect(declarationsIn('.cv-p-bubble::before', body).get('content')).toBe('none')
83
+ expect(declarationsIn('.cv-p-bubble::after', body).get('content')).toBe('none')
84
+ })
85
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { declarationsIn, toRem } from './participantCss.test-helper'
5
+ import { buildConversation } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import { ParticipantThreadHeader, type ParticipantThreadHeaderProps } from './ParticipantThreadHeader'
8
+
9
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
10
+
11
+ function render(overrides: Partial<ParticipantThreadHeaderProps> = {}): string {
12
+ return renderToStaticMarkup(
13
+ <ParticipantThreadHeader
14
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Item 4521' })}
15
+ labels={LABELS}
16
+ {...overrides}
17
+ />,
18
+ )
19
+ }
20
+
21
+ function backButtonOf(markup: string): string {
22
+ const start = markup.indexOf('<button')
23
+ return markup.slice(start, markup.indexOf('</button>', start))
24
+ }
25
+
26
+ describe('back button', () => {
27
+ const markup = render({ onBack: () => undefined })
28
+
29
+ it('is an icon with the back label as its accessible name and no visible text or glyph', () => {
30
+ const button = backButtonOf(markup)
31
+ expect(button).toContain('class="cv-p-thread__back"')
32
+ expect(button).toContain('aria-label="Back"')
33
+ expect(button).toContain('<svg')
34
+ expect(button.replace(/<[^>]+>/g, '')).toBe('')
35
+ expect(markup).not.toContain('‹')
36
+ })
37
+
38
+ it('is not the boxed secondary button any more', () => {
39
+ expect(markup).not.toContain('cv-p-button')
40
+ const back = declarationsIn('.cv-p-thread__back')
41
+ expect(back.get('border')).toBe('0')
42
+ expect(back.get('background')).toBe('transparent')
43
+ })
44
+
45
+ it('has a 44px touch area and keeps the focus ring', () => {
46
+ const back = declarationsIn('.cv-p-thread__back')
47
+ expect(back.get('min-width')).toBe('var(--cv-p-i-touch)')
48
+ expect(back.get('min-height')).toBe('var(--cv-p-i-touch)')
49
+ expect(declarationsIn('.cv-p :focus-visible').get('outline')).toContain('2px solid')
50
+ })
51
+
52
+ it('is absent without onBack', () => {
53
+ expect(render()).not.toContain('<button')
54
+ })
55
+
56
+ it('comes first, before the avatar and the title block', () => {
57
+ const withIcon = render({ onBack: () => undefined, renderSubjectIcon: () => <i /> })
58
+ expect(withIcon.indexOf('cv-p-thread__back')).toBeLessThan(withIcon.indexOf('cv-p-thread__avatar'))
59
+ expect(withIcon.indexOf('cv-p-thread__avatar')).toBeLessThan(withIcon.indexOf('cv-p-thread__heading'))
60
+ })
61
+ })
62
+
63
+ describe('conversation avatar', () => {
64
+ const group = { subjectType: 'invoice', label: 'Invoices', icon: <i>group</i> }
65
+
66
+ it('uses the host icon over the group icon', () => {
67
+ const markup = render({ subjectGroups: [group], renderSubjectIcon: () => <b>host</b> })
68
+ expect(markup).toContain('<span class="cv-p-thread__avatar" aria-hidden="true"><b>host</b></span>')
69
+ expect(markup).not.toContain('<i>group</i>')
70
+ })
71
+
72
+ it('falls back to the group icon when the host draws nothing', () => {
73
+ const markup = render({ subjectGroups: [group], renderSubjectIcon: () => null })
74
+ expect(markup).toContain('<span class="cv-p-thread__avatar" aria-hidden="true"><i>group</i></span>')
75
+ })
76
+
77
+ it('is absent without any icon, and the group label takes its place as the eyebrow', () => {
78
+ const markup = render({ subjectGroups: [{ subjectType: 'invoice', label: 'Invoices' }] })
79
+ expect(markup).not.toContain('cv-p-thread__avatar')
80
+ expect(markup).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
81
+ expect(render()).not.toContain('cv-p-thread__avatar')
82
+ })
83
+
84
+ it('replaces the eyebrow: the tile stands for the group', () => {
85
+ expect(render({ subjectGroups: [group] })).not.toContain('cv-p-thread__eyebrow')
86
+ })
87
+
88
+ it('is a 2.5rem tile that follows the radius token', () => {
89
+ const avatar = declarationsIn('.cv-p-thread__avatar')
90
+ expect(toRem(avatar.get('width'))).toBe(2.5)
91
+ expect(toRem(avatar.get('height'))).toBe(2.5)
92
+ expect(avatar.get('border-radius')).toBe('var(--cv-p-i-radius)')
93
+ expect(avatar.get('flex')).toBe('0 0 auto')
94
+ })
95
+ })
96
+
97
+ describe('bar', () => {
98
+ it('is a raised surface with a 1px bottom border and a 4rem floor', () => {
99
+ const header = declarationsIn('.cv-p-thread__header')
100
+ expect(header.get('background')).toBe('var(--cv-p-i-surface-raised)')
101
+ expect(header.get('border-bottom')).toBe('1px solid var(--cv-p-i-border)')
102
+ expect(header.get('align-items')).toBe('center')
103
+ })
104
+
105
+ it('lets the title block shrink so nothing overflows at 320px', () => {
106
+ const heading = declarationsIn('.cv-p-thread__heading')
107
+ expect(heading.get('min-width')).toBe('0')
108
+ expect(declarationsIn('.cv-p-thread__meta').get('flex-wrap')).toBe('wrap')
109
+ expect(declarationsIn('.cv-p-thread__title-text').get('text-overflow')).toBe('ellipsis')
110
+ })
111
+ })
@@ -13,6 +13,8 @@ export type ParticipantConversationsLabels = {
13
13
  readonly me: string
14
14
  readonly newMessages: string
15
15
  readonly loadOlder: string
16
+ /** Accessible name of the floating button that returns to the end of the conversation. */
17
+ readonly scrollToLatest: string
16
18
  readonly closedNotice: string
17
19
  readonly messageInputLabel: string
18
20
  readonly messageInputPlaceholder: string
@@ -53,6 +55,11 @@ export type ParticipantConversationsLabels = {
53
55
  readonly channelEmail: string
54
56
  readonly channelPortal: string
55
57
  readonly channelWebchat: string
58
+ /** Spoken after a link that opens in a new tab. */
59
+ readonly externalLinkHint: string
60
+ /** Prefix of the accessible name of the copy buttons: "Copy 529.982.247-25". */
61
+ readonly copyValue: string
62
+ readonly valueCopied: string
56
63
  }
57
64
 
58
65
  export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
@@ -69,6 +76,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
69
76
  me: 'Me',
70
77
  newMessages: 'New messages',
71
78
  loadOlder: 'Load older messages',
79
+ scrollToLatest: 'Scroll to latest message',
72
80
  closedNotice: 'This conversation is closed',
73
81
  messageInputLabel: 'Message',
74
82
  messageInputPlaceholder: 'Write a message…',
@@ -108,6 +116,9 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
108
116
  channelEmail: 'Email',
109
117
  channelPortal: 'Portal',
110
118
  channelWebchat: 'Web chat',
119
+ externalLinkHint: 'opens in a new tab',
120
+ copyValue: 'Copy',
121
+ valueCopied: 'Copied',
111
122
  }
112
123
 
113
124
  export function formatParticipantLabel(template: string, count: number): string {