@adatechnology/conversations-ui 0.6.1 → 0.8.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.
- package/README.md +221 -19
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.d.ts +4 -4
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +62 -3
- package/dist/participant/index.js +1444 -394
- package/dist/preview/index.d.ts +3 -3
- package/dist/preview/index.js +2 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +629 -20
- package/package.json +3 -3
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +32 -84
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +54 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +83 -0
- package/src/participant/ParticipantThreadScreen.tsx +30 -56
- package/src/participant/ParticipantTimeline.tsx +106 -0
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +4 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +13 -3
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/styles.css +151 -23
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { declarationsIn, mediaBody, PARTICIPANT_CSS } from './participantCss.test-helper'
|
|
6
|
+
|
|
7
|
+
const NEW_SOURCES = [
|
|
8
|
+
'ConversationThread.tsx',
|
|
9
|
+
'conversationThreadApi.ts',
|
|
10
|
+
'conversationThreadSummary.ts',
|
|
11
|
+
'participantPerspective.ts',
|
|
12
|
+
'useParticipantUnread.ts',
|
|
13
|
+
'useParticipantThreadController.ts',
|
|
14
|
+
].map((file) => ({ file, source: readFileSync(join(import.meta.dir, file), 'utf8') }))
|
|
15
|
+
|
|
16
|
+
describe('header additions', () => {
|
|
17
|
+
it('puts the counterpart line in one muted, ellipsized line', () => {
|
|
18
|
+
const rule = declarationsIn('.cv-p-thread__counterpart')
|
|
19
|
+
expect(rule.get('color')).toBe('var(--cv-p-i-text-muted)')
|
|
20
|
+
expect(rule.get('white-space')).toBe('nowrap')
|
|
21
|
+
expect(rule.get('text-overflow')).toBe('ellipsis')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps the host actions at the end of the bar, never wider than the bar', () => {
|
|
25
|
+
const rule = declarationsIn('.cv-p-thread__actions')
|
|
26
|
+
expect(rule.get('flex')).toBe('0 0 auto')
|
|
27
|
+
expect(rule.get('margin-inline-start')).toBe('auto')
|
|
28
|
+
expect(rule.get('max-width')).toBe('100%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('lets the bar wrap and gives the title a floor only when there are host actions', () => {
|
|
32
|
+
const withActions = '.cv-p-thread__header:has(.cv-p-thread__actions)'
|
|
33
|
+
expect(declarationsIn(withActions).get('flex-wrap')).toBe('wrap')
|
|
34
|
+
expect(declarationsIn(`${withActions} .cv-p-thread__heading`).get('min-width')).toBe('6rem')
|
|
35
|
+
expect(declarationsIn('.cv-p-thread__header').has('flex-wrap')).toBe(false)
|
|
36
|
+
expect(declarationsIn('.cv-p-thread__heading').get('min-width')).toBe('0')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ticks', () => {
|
|
41
|
+
it('take their color from package tokens that a host can override, with a fallback', () => {
|
|
42
|
+
const root = declarationsIn('.cv-p')
|
|
43
|
+
expect(root.get('--cv-p-i-tick')).toBe('var(--cv-p-tick, var(--cv-p-i-text-muted))')
|
|
44
|
+
expect(root.get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
45
|
+
expect(declarationsIn('.dark .cv-p').get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('override the shared tick colors only inside the participant bubble', () => {
|
|
49
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--sent').get('color')).toBe('var(--cv-p-i-tick)')
|
|
50
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--delivered').get('color')).toBe('var(--cv-p-i-tick)')
|
|
51
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--read').get('color')).toBe('var(--cv-p-i-tick-read)')
|
|
52
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--failed').get('color')).toBe('var(--cv-p-i-danger)')
|
|
53
|
+
expect(declarationsIn('.cv-status-ticks--read').get('color')).toBe('rgb(14 165 233)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('give way to the system colors under forced-colors', () => {
|
|
57
|
+
const forced = mediaBody('(forced-colors: active)') ?? ''
|
|
58
|
+
expect(forced).toContain('.cv-p-bubble .cv-status-ticks')
|
|
59
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks', forced).get('color')).toBe('CanvasText')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('wallpaper and the strips around it', () => {
|
|
64
|
+
it('gives the load error a solid panel so no text depends on the dots', () => {
|
|
65
|
+
expect(declarationsIn('.cv-p-thread__scroll > .cv-p-error').get('background')).toBe('var(--cv-p-i-surface)')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('paints the live strip like the composer it sits on', () => {
|
|
69
|
+
expect(declarationsIn('.cv-p-thread__live').get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the wallpaper off under forced-colors', () => {
|
|
73
|
+
expect(
|
|
74
|
+
declarationsIn('.cv-p-thread__scroll', mediaBody('(forced-colors: active)') ?? '').get('background-image'),
|
|
75
|
+
).toBe('none')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('motion', () => {
|
|
80
|
+
it('adds no transition or animation outside the no-preference block', () => {
|
|
81
|
+
const rules = declarationsIn('.cv-p-thread__actions')
|
|
82
|
+
const counterpart = declarationsIn('.cv-p-thread__counterpart')
|
|
83
|
+
for (const declarations of [rules, counterpart]) {
|
|
84
|
+
expect(
|
|
85
|
+
[...declarations.keys()].some(
|
|
86
|
+
(property) => property.startsWith('transition') || property.startsWith('animation'),
|
|
87
|
+
),
|
|
88
|
+
).toBe(false)
|
|
89
|
+
}
|
|
90
|
+
expect(mediaBody('(prefers-reduced-motion: no-preference)')).toBeDefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('new sources', () => {
|
|
95
|
+
it('use no Tailwind utilities and none of the vocabulary of a specific product', () => {
|
|
96
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
97
|
+
expect(source, file).not.toMatch(/className="[^"]*\b(flex|grid|p-\d|m-\d|text-(sm|xs|lg)|bg-\w+)\b/)
|
|
98
|
+
expect(source, file).not.toMatch(
|
|
99
|
+
/\b(nota|ocorr\w+|motorista|viagem|carga|entrega|frete|cte|nf-?e|transport\w*|trip|driver)\b/i,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('stay under 200 lines and use no any', () => {
|
|
105
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
106
|
+
expect(source.split('\n').length, file).toBeLessThanOrEqual(200)
|
|
107
|
+
expect(source, file).not.toMatch(/:\s*any\b|\bas any\b|<any>/)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('declare the new tokens in the public theming comment', () => {
|
|
112
|
+
const raw = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8')
|
|
113
|
+
expect(raw).toContain('--cv-p-tick, --cv-p-tick-read')
|
|
114
|
+
expect(PARTICIPANT_CSS).toContain('--cv-p-i-tick-read')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParticipantConversationStatus,
|
|
3
|
+
ParticipantConversationSummary,
|
|
4
|
+
ParticipantSubjectRef,
|
|
5
|
+
} from '@adatechnology/conversation-contracts'
|
|
6
|
+
|
|
7
|
+
export type BuildThreadSummaryParams = {
|
|
8
|
+
readonly subject: ParticipantSubjectRef
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly protocol?: string
|
|
11
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
12
|
+
readonly status?: ParticipantConversationStatus
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The header reads a summary; a single conversation has no list to take it from, so the host's props build it. */
|
|
16
|
+
export function buildThreadSummary({
|
|
17
|
+
subject,
|
|
18
|
+
title,
|
|
19
|
+
protocol,
|
|
20
|
+
channels,
|
|
21
|
+
status,
|
|
22
|
+
}: BuildThreadSummaryParams): ParticipantConversationSummary {
|
|
23
|
+
return {
|
|
24
|
+
subjectType: subject.subjectType,
|
|
25
|
+
subjectId: subject.subjectId,
|
|
26
|
+
subjectLabel: title,
|
|
27
|
+
lastMessageAt: null,
|
|
28
|
+
unreadCount: 0,
|
|
29
|
+
awaitingParticipant: false,
|
|
30
|
+
status: status ?? 'open',
|
|
31
|
+
...(protocol ? { protocol } : {}),
|
|
32
|
+
...(channels ? { channels } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
|
|
2
|
+
export { ConversationThread, type ConversationThreadProps } from './ConversationThread'
|
|
3
|
+
export type { ConversationThreadApi } from './conversationThreadApi'
|
|
4
|
+
export type { ParticipantPerspective } from './participantPerspective'
|
|
5
|
+
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
2
6
|
export { groupParticipantConversations } from './participantGrouping'
|
|
3
7
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
4
8
|
export type {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { fitTextareaHeight, type AutoGrowTarget } from './participantAutoGrow'
|
|
4
|
+
|
|
5
|
+
describe('fitTextareaHeight', () => {
|
|
6
|
+
it('measures after resetting, so the field can also shrink', () => {
|
|
7
|
+
const heights: string[] = []
|
|
8
|
+
const target: AutoGrowTarget = {
|
|
9
|
+
style: {
|
|
10
|
+
get height() {
|
|
11
|
+
return heights.at(-1) ?? ''
|
|
12
|
+
},
|
|
13
|
+
set height(value: string) {
|
|
14
|
+
heights.push(value)
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
scrollHeight: 96,
|
|
18
|
+
offsetHeight: 96,
|
|
19
|
+
clientHeight: 96,
|
|
20
|
+
}
|
|
21
|
+
fitTextareaHeight(target)
|
|
22
|
+
expect(heights).toEqual(['auto', '96px'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('adds the border to the content height, so a border-box field does not overflow', () => {
|
|
26
|
+
const heights: string[] = []
|
|
27
|
+
const target: AutoGrowTarget = {
|
|
28
|
+
style: {
|
|
29
|
+
get height() {
|
|
30
|
+
return heights.at(-1) ?? ''
|
|
31
|
+
},
|
|
32
|
+
set height(value: string) {
|
|
33
|
+
heights.push(value)
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
scrollHeight: 61,
|
|
37
|
+
offsetHeight: 63,
|
|
38
|
+
clientHeight: 61,
|
|
39
|
+
}
|
|
40
|
+
fitTextareaHeight(target)
|
|
41
|
+
expect(heights.at(-1)).toBe('63px')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AutoGrowTarget = {
|
|
2
|
+
readonly style: { height: string }
|
|
3
|
+
readonly scrollHeight: number
|
|
4
|
+
readonly offsetHeight: number
|
|
5
|
+
readonly clientHeight: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Fits the field to its content plus its border (border-box), so no phantom scrollbar; the CSS max-height caps it. */
|
|
9
|
+
export function fitTextareaHeight(target: AutoGrowTarget): void {
|
|
10
|
+
target.style.height = 'auto'
|
|
11
|
+
target.style.height = `${target.scrollHeight + (target.offsetHeight - target.clientHeight)}px`
|
|
12
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { authorInitials, shouldShowAvatar } from './participantAvatar'
|
|
4
|
+
|
|
5
|
+
describe('authorInitials', () => {
|
|
6
|
+
it('takes the first letters of the first and last names', () => {
|
|
7
|
+
expect(authorInitials('Ana Souza')).toBe('AS')
|
|
8
|
+
expect(authorInitials('maria ana souza lima')).toBe('ML')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('uses a single initial for a single name', () => {
|
|
12
|
+
expect(authorInitials('Ana')).toBe('A')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('ignores prepositions and punctuation', () => {
|
|
16
|
+
expect(authorInitials('Maria da Silva')).toBe('MS')
|
|
17
|
+
expect(authorInitials('João de Souza e Silva')).toBe('JS')
|
|
18
|
+
expect(authorInitials(' (Ana) - Souza. ')).toBe('AS')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps an accented initial and upper-cases it', () => {
|
|
22
|
+
expect(authorInitials('élida ávila')).toBe('ÉÁ')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps the first word even when it looks like a particle', () => {
|
|
26
|
+
expect(authorInitials('Le Minh')).toBe('LM')
|
|
27
|
+
expect(authorInitials('Van Morrison')).toBe('VM')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('composes accents before taking the initial', () => {
|
|
31
|
+
expect(authorInitials('e\u0301lida')).toBe('\u00c9')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('falls back to the first word when only prepositions remain', () => {
|
|
35
|
+
expect(authorInitials('de da')).toBe('D')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns undefined for missing, empty, question-mark or emoji-only names', () => {
|
|
39
|
+
expect(authorInitials(null)).toBeUndefined()
|
|
40
|
+
expect(authorInitials(undefined)).toBeUndefined()
|
|
41
|
+
expect(authorInitials('')).toBeUndefined()
|
|
42
|
+
expect(authorInitials(' ')).toBeUndefined()
|
|
43
|
+
expect(authorInitials('?')).toBeUndefined()
|
|
44
|
+
expect(authorInitials('😀')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('ignores a trailing emoji', () => {
|
|
48
|
+
expect(authorInitials('Ana 😀')).toBe('A')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('shouldShowAvatar', () => {
|
|
53
|
+
it('shows it on the first message of a sequence', () => {
|
|
54
|
+
expect(shouldShowAvatar({ previousAuthor: undefined, author: 'Ana', isMine: false })).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('hides it while the same author keeps talking', () => {
|
|
58
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: false })).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows it again when the author changes', () => {
|
|
62
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Bruno', isMine: false })).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('treats consecutive unnamed authors as one sequence', () => {
|
|
66
|
+
expect(shouldShowAvatar({ previousAuthor: null, author: null, isMine: false })).toBe(false)
|
|
67
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: null, isMine: false })).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never shows it on an own message', () => {
|
|
71
|
+
expect(shouldShowAvatar({ previousAuthor: undefined, author: null, isMine: true })).toBe(false)
|
|
72
|
+
expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: true })).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const NAME_PARTICLES = new Set([
|
|
2
|
+
'a',
|
|
3
|
+
'e',
|
|
4
|
+
'o',
|
|
5
|
+
'da',
|
|
6
|
+
'das',
|
|
7
|
+
'de',
|
|
8
|
+
'del',
|
|
9
|
+
'di',
|
|
10
|
+
'do',
|
|
11
|
+
'dos',
|
|
12
|
+
'du',
|
|
13
|
+
'la',
|
|
14
|
+
'le',
|
|
15
|
+
'van',
|
|
16
|
+
'von',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export type ParticipantAvatarAuthor = string | null
|
|
20
|
+
|
|
21
|
+
export type ShouldShowAvatarParams = {
|
|
22
|
+
/** Author of the previous message; undefined at the start of the list or right after an own message. */
|
|
23
|
+
readonly previousAuthor: ParticipantAvatarAuthor | undefined
|
|
24
|
+
readonly author: ParticipantAvatarAuthor
|
|
25
|
+
readonly isMine: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lettersOf(word: string): string {
|
|
29
|
+
return word.replace(/[^\p{L}\p{M}]/gu, '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function firstLetter(word: string): string {
|
|
33
|
+
return (Array.from(word.normalize('NFC'))[0] ?? '').toUpperCase()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Up to two initials; undefined when the name has no letters, so the caller draws the neutral icon. */
|
|
37
|
+
export function authorInitials(name: string | null | undefined): string | undefined {
|
|
38
|
+
const [first, ...rest] = (name ?? '').split(/\s+/).map(lettersOf).filter(Boolean)
|
|
39
|
+
if (first === undefined) return undefined
|
|
40
|
+
const last = rest.filter((word) => !NAME_PARTICLES.has(word.toLowerCase())).pop()
|
|
41
|
+
return `${firstLetter(first)}${last === undefined ? '' : firstLetter(last)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function shouldShowAvatar({ previousAuthor, author, isMine }: ShouldShowAvatarParams): boolean {
|
|
45
|
+
if (isMine) return false
|
|
46
|
+
if (previousAuthor === undefined) return true
|
|
47
|
+
return previousAuthor !== author
|
|
48
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
|
|
6
|
+
import { splitBlocks } from '../cssBlocks.test-helper'
|
|
7
|
+
import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantMessageBubble, type ParticipantMessageBubbleProps } from './ParticipantMessageBubble'
|
|
10
|
+
|
|
11
|
+
const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
12
|
+
|
|
13
|
+
function declarationsOf(selector: string): Map<string, string> {
|
|
14
|
+
const declarations = new Map<string, string>()
|
|
15
|
+
for (const { prelude, body } of splitBlocks(CSS)) {
|
|
16
|
+
if (body === undefined || prelude.startsWith('@')) continue
|
|
17
|
+
if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
|
|
18
|
+
for (const declaration of body.split(';')) {
|
|
19
|
+
const separator = declaration.indexOf(':')
|
|
20
|
+
if (separator === -1) continue
|
|
21
|
+
declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return declarations
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function render(direction: 'inbound' | 'outbound', extra: Partial<ParticipantMessageBubbleProps> = {}): string {
|
|
28
|
+
return renderToStaticMarkup(
|
|
29
|
+
<ParticipantMessageBubble
|
|
30
|
+
item={{ kind: 'server', message: buildMessage({ id: 'm1', direction }) }}
|
|
31
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
32
|
+
confirmsRead
|
|
33
|
+
resolveAttachmentUrl={async () => 'https://files.example/a.png'}
|
|
34
|
+
{...extra}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('bubble tail CSS', () => {
|
|
40
|
+
it('draws two decorative triangles that never catch touches', () => {
|
|
41
|
+
for (const pseudo of ['::before', '::after']) {
|
|
42
|
+
const tail = declarationsOf(`.cv-p-bubble${pseudo}`)
|
|
43
|
+
expect(tail.get('content')).toBe("''")
|
|
44
|
+
expect(tail.get('pointer-events')).toBe('none')
|
|
45
|
+
expect(tail.get('position')).toBe('absolute')
|
|
46
|
+
expect(tail.get('border-style')).toBe('solid')
|
|
47
|
+
}
|
|
48
|
+
expect(declarationsOf('.cv-p-bubble').get('position')).toBe('relative')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('puts the received tail on the left, edge colour under fill colour', () => {
|
|
52
|
+
expect(declarationsOf('.cv-p-bubble::before').get('left')).toBe('-10px')
|
|
53
|
+
expect(declarationsOf('.cv-p-bubble::after').get('left')).toBe('-8px')
|
|
54
|
+
expect(declarationsOf('.cv-p-bubble::before').get('border-bottom-color')).toBe('var(--cv-p-tail-edge)')
|
|
55
|
+
expect(declarationsOf('.cv-p-bubble::after').get('border-bottom-color')).toBe('var(--cv-p-tail-fill)')
|
|
56
|
+
expect(declarationsOf('.cv-p-bubble').get('border-bottom-left-radius')).toBe('0')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('mirrors the own tail to the right with the own tokens', () => {
|
|
60
|
+
expect(declarationsOf('.cv-p-bubble--mine::before').get('right')).toBe('-10px')
|
|
61
|
+
expect(declarationsOf('.cv-p-bubble--mine::after').get('right')).toBe('-8px')
|
|
62
|
+
const mine = declarationsOf('.cv-p-bubble--mine')
|
|
63
|
+
expect(mine.get('border-bottom-right-radius')).toBe('0')
|
|
64
|
+
expect(mine.get('--cv-p-tail-edge')).toBe('var(--cv-p-i-accent)')
|
|
65
|
+
expect(mine.get('--cv-p-tail-fill')).toBe('var(--cv-p-i-highlight)')
|
|
66
|
+
expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-edge')).toBe('var(--cv-p-i-border)')
|
|
67
|
+
expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-fill')).toBe('var(--cv-p-i-surface-raised)')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('turns the tail off and restores the corner with --no-tail', () => {
|
|
71
|
+
expect(declarationsOf('.cv-p-bubble--no-tail::before').get('content')).toBe('none')
|
|
72
|
+
expect(declarationsOf('.cv-p-bubble--no-tail::after').get('content')).toBe('none')
|
|
73
|
+
expect(declarationsOf('.cv-p-bubble--no-tail').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('uses no fixed colour for the tail', () => {
|
|
77
|
+
const tailRules = splitBlocks(CSS).filter(({ prelude }) => /cv-p-bubble[^,{]*::(before|after)/.test(prelude))
|
|
78
|
+
expect(tailRules.length).toBeGreaterThan(0)
|
|
79
|
+
for (const { body } of tailRules) expect(body ?? '').not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('bubble tail markup', () => {
|
|
84
|
+
it('keeps the default markup free of any new class', () => {
|
|
85
|
+
expect(render('outbound')).toContain('<div class="cv-p-bubble">')
|
|
86
|
+
expect(render('inbound')).toContain('<div class="cv-p-bubble cv-p-bubble--mine">')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('adds --no-tail only when tail is false, on both sides', () => {
|
|
90
|
+
expect(render('outbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--no-tail"')
|
|
91
|
+
expect(render('inbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"')
|
|
92
|
+
expect(render('outbound', { tail: true })).not.toContain('--no-tail')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps the content and the avatar row unchanged by the tail flag', () => {
|
|
96
|
+
const withAvatar = render('outbound', { avatar: <b>A</b>, tail: false })
|
|
97
|
+
expect(withAvatar).toContain('cv-p-bubble-row')
|
|
98
|
+
expect(withAvatar).toContain('cv-p-bubble--no-tail')
|
|
99
|
+
expect(render('outbound', { tail: false })).toContain('cv-p-bubble__meta')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('stays inside the participant namespace', () => {
|
|
103
|
+
const markup = render('outbound', { tail: false })
|
|
104
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
105
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { declarationsIn, mediaBody } from './participantCss.test-helper'
|
|
5
|
+
import {
|
|
6
|
+
buildConversation,
|
|
7
|
+
buildMessage,
|
|
8
|
+
findForeignClassTokens,
|
|
9
|
+
findUtilityClassTokens,
|
|
10
|
+
} from './participantFixtures.test-helper'
|
|
11
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
12
|
+
import { ParticipantComposer } from './ParticipantComposer'
|
|
13
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
14
|
+
|
|
15
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
16
|
+
|
|
17
|
+
function renderThread(): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<ParticipantThread
|
|
20
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
21
|
+
items={[
|
|
22
|
+
{ kind: 'server', message: buildMessage({ id: 'a', text: 'Hello', createdAt: '2026-10-01T10:00:00.000Z' }) },
|
|
23
|
+
{ kind: 'server', message: buildMessage({ id: 'b', text: 'Again', createdAt: '2026-10-02T10:00:00.000Z' }) },
|
|
24
|
+
]}
|
|
25
|
+
hasMore={false}
|
|
26
|
+
labels={LABELS}
|
|
27
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
28
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
29
|
+
onSend={() => undefined}
|
|
30
|
+
status="ready"
|
|
31
|
+
refresh={() => undefined}
|
|
32
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
33
|
+
newMessagesCount={0}
|
|
34
|
+
isSending={false}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderComposer(overrides: { value?: string } = {}): string {
|
|
40
|
+
return renderToStaticMarkup(
|
|
41
|
+
<ParticipantComposer
|
|
42
|
+
value={overrides.value ?? ''}
|
|
43
|
+
onChange={() => undefined}
|
|
44
|
+
files={[]}
|
|
45
|
+
onFilesChange={() => undefined}
|
|
46
|
+
onSend={() => undefined}
|
|
47
|
+
labels={LABELS}
|
|
48
|
+
quickReplies={[{ id: '1', title: 'Hi', shortcut: 'hi', body: 'Hi' }]}
|
|
49
|
+
/>,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('wallpaper', () => {
|
|
54
|
+
it('is drawn on the message scroller from the token, defaulting to a CSS-only pattern', () => {
|
|
55
|
+
const scroller = declarationsIn('.cv-p-thread__scroll')
|
|
56
|
+
expect(scroller.get('background-image')).toBe('var(--cv-p-wallpaper, var(--cv-p-i-wallpaper))')
|
|
57
|
+
const root = declarationsIn('.cv-p')
|
|
58
|
+
expect(root.get('--cv-p-i-wallpaper')).toContain('radial-gradient')
|
|
59
|
+
expect(root.get('--cv-p-i-wallpaper')).not.toContain('url(')
|
|
60
|
+
expect(root.get('--cv-p-i-wallpaper-ink')).toBe('var(--cv-p-i-border)')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('is switched off in forced colors and in print', () => {
|
|
64
|
+
for (const query of ['(forced-colors: active)', 'print']) {
|
|
65
|
+
expect(declarationsIn('.cv-p-thread__scroll', mediaBody(query) ?? '').get('background-image')).toBe('none')
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('uses logical properties for the text, the meta and the title button, so RTL mirrors', () => {
|
|
70
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('margin-inline-end')).toBe('auto')
|
|
71
|
+
expect(declarationsIn('.cv-p-thread__title-button').get('text-align')).toBe('start')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('restores the bubble corner radius in print, where the tail is off', () => {
|
|
75
|
+
const rule = declarationsIn('.cv-p-bubble', mediaBody('print') ?? '')
|
|
76
|
+
expect(rule.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
77
|
+
expect(declarationsIn('.cv-p-bubble--mine', mediaBody('print') ?? '').get('border-radius')).toBe(
|
|
78
|
+
'var(--cv-p-i-radius)',
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('never reaches the composer, the header or the bubbles', () => {
|
|
83
|
+
for (const selector of ['.cv-p-composer', '.cv-p-thread__header', '.cv-p-bubble']) {
|
|
84
|
+
expect(declarationsIn(selector).has('background-image')).toBe(false)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('day pill', () => {
|
|
90
|
+
it('is a separator holding a time with a machine date', () => {
|
|
91
|
+
expect(renderThread()).toMatch(
|
|
92
|
+
/<div class="cv-p-day" role="separator" aria-label="[^"]+"><time class="cv-p-day__label" dateTime="2026-10-01">/,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('is a centred raised pill with a border and the radius token', () => {
|
|
97
|
+
const day = declarationsIn('.cv-p-day')
|
|
98
|
+
expect(day.get('align-self')).toBe('center')
|
|
99
|
+
expect(day.get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
100
|
+
expect(day.get('border')).toBe('1px solid var(--cv-p-i-border)')
|
|
101
|
+
expect(day.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
102
|
+
expect(day.get('font')).toContain('monospace')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('time and ticks at the foot of the bubble', () => {
|
|
107
|
+
it('keeps time and status together in the last element of the bubble', () => {
|
|
108
|
+
const markup = renderToStaticMarkup(
|
|
109
|
+
<ParticipantThread
|
|
110
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
111
|
+
items={[{ kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', text: 'Hello' }) }]}
|
|
112
|
+
hasMore={false}
|
|
113
|
+
labels={LABELS}
|
|
114
|
+
resolveAttachmentUrl={async () => ''}
|
|
115
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
116
|
+
onSend={() => undefined}
|
|
117
|
+
status="ready"
|
|
118
|
+
refresh={() => undefined}
|
|
119
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
120
|
+
newMessagesCount={0}
|
|
121
|
+
isSending={false}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
const meta = markup.slice(
|
|
125
|
+
markup.indexOf('cv-p-bubble__meta'),
|
|
126
|
+
markup.indexOf('</span></div>', markup.indexOf('cv-p-bubble__meta')),
|
|
127
|
+
)
|
|
128
|
+
expect(meta).toContain('<time')
|
|
129
|
+
expect(meta).toContain('cv-p-bubble__status')
|
|
130
|
+
expect(meta.indexOf('<time')).toBeLessThan(meta.indexOf('cv-p-bubble__status'))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('lets the text and the meta share a line, and the meta fall to the right when it does not fit', () => {
|
|
134
|
+
const bubble = declarationsIn('.cv-p-bubble')
|
|
135
|
+
expect(bubble.get('display')).toBe('flex')
|
|
136
|
+
expect(bubble.get('flex-wrap')).toBe('wrap')
|
|
137
|
+
expect(bubble.get('align-items')).toBe('flex-end')
|
|
138
|
+
expect(bubble.get('max-width')).toBe('85%')
|
|
139
|
+
const meta = declarationsIn('.cv-p-bubble__meta')
|
|
140
|
+
expect(meta.get('margin-inline-start')).toBe('auto')
|
|
141
|
+
expect(meta.get('flex')).toBe('0 0 auto')
|
|
142
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-text').get('min-width')).toBe('0')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps author, attachments and failed actions on their own full-width row', () => {
|
|
146
|
+
expect(declarationsIn('.cv-p-bubble__author').get('flex')).toBe('1 0 100%')
|
|
147
|
+
expect(declarationsIn('.cv-p-bubble > .cv-p-attachment').get('flex')).toBe('1 0 100%')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('composer', () => {
|
|
152
|
+
it('draws attach and send as lucide icons with accessible names and no text glyphs', () => {
|
|
153
|
+
const markup = renderComposer({ value: 'x' })
|
|
154
|
+
const attach = markup.slice(
|
|
155
|
+
markup.indexOf('cv-p-composer__attach'),
|
|
156
|
+
markup.indexOf('</button>', markup.indexOf('cv-p-composer__attach')),
|
|
157
|
+
)
|
|
158
|
+
expect(attach).toContain('aria-label="Attach"')
|
|
159
|
+
expect(attach).toContain('<svg')
|
|
160
|
+
const send = markup.slice(
|
|
161
|
+
markup.indexOf('type="submit"'),
|
|
162
|
+
markup.indexOf('</button>', markup.indexOf('type="submit"')),
|
|
163
|
+
)
|
|
164
|
+
expect(send).toContain('aria-label="Send"')
|
|
165
|
+
expect(send).toContain('<svg')
|
|
166
|
+
for (const glyph of ['+', '➤', '+']) expect(markup).not.toContain(glyph)
|
|
167
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
168
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('orders attach, field and send in one row, with the chips above it', () => {
|
|
172
|
+
const markup = renderComposer()
|
|
173
|
+
const order = ['cv-p-quick', 'cv-p-composer__row', 'cv-p-composer__attach', '<textarea', 'type="submit"']
|
|
174
|
+
const positions = order.map((token) => markup.indexOf(token))
|
|
175
|
+
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
|
176
|
+
expect(positions.every((position) => position >= 0)).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('aligns the row by the base, with a field that grows up to four lines and keeps 44px', () => {
|
|
180
|
+
expect(declarationsIn('.cv-p-composer__row').get('align-items')).toBe('flex-end')
|
|
181
|
+
const input = declarationsIn('.cv-p-composer__input')
|
|
182
|
+
expect(input.get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
183
|
+
expect(input.get('max-height')).toBe('6.5rem')
|
|
184
|
+
expect(input.get('overflow-y')).toBe('auto')
|
|
185
|
+
expect(input.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
186
|
+
for (const selector of ['.cv-p-composer__attach']) {
|
|
187
|
+
expect(declarationsIn(selector).get('min-width')).toBe('var(--cv-p-i-touch)')
|
|
188
|
+
expect(declarationsIn(selector).get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('sits on a raised bar with a top border; send is the primary button', () => {
|
|
193
|
+
const bar = declarationsIn('.cv-p-composer')
|
|
194
|
+
expect(bar.get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
195
|
+
expect(bar.get('border-top')).toBe('1px solid var(--cv-p-i-border)')
|
|
196
|
+
expect(renderComposer()).toContain('class="cv-p-button cv-p-button--primary cv-p-composer__send"')
|
|
197
|
+
})
|
|
198
|
+
})
|