@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 { declarationsIn } from './participantCss.test-helper'
|
|
2
|
+
|
|
3
|
+
export type Rgba = { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
|
|
4
|
+
|
|
5
|
+
export type TokenName =
|
|
6
|
+
'surface' | 'raised' | 'text' | 'muted' | 'border' | 'accent' | 'accentContrast' | 'highlight' | 'danger'
|
|
7
|
+
|
|
8
|
+
export type Palette = Readonly<Record<TokenName, Rgba>>
|
|
9
|
+
|
|
10
|
+
const TOKEN_VARIABLE: Record<TokenName, string> = {
|
|
11
|
+
surface: '--cv-p-surface',
|
|
12
|
+
raised: '--cv-p-surface-raised',
|
|
13
|
+
text: '--cv-p-text',
|
|
14
|
+
muted: '--cv-p-text-muted',
|
|
15
|
+
border: '--cv-p-border',
|
|
16
|
+
accent: '--cv-p-accent',
|
|
17
|
+
accentContrast: '--cv-p-accent-contrast',
|
|
18
|
+
highlight: '--cv-p-highlight',
|
|
19
|
+
danger: '--cv-p-danger',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseColor(source: string): Rgba {
|
|
23
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(source.trim())
|
|
24
|
+
if (hex?.[1]) {
|
|
25
|
+
const value = hex[1]
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
28
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
29
|
+
b: parseInt(value.slice(4, 6), 16),
|
|
30
|
+
a: 1,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const functional = /^rgb\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*(\d+)%)?\s*\)$/.exec(source.trim())
|
|
34
|
+
if (!functional) throw new Error(`unsupported color: ${source}`)
|
|
35
|
+
return {
|
|
36
|
+
r: Number(functional[1]),
|
|
37
|
+
g: Number(functional[2]),
|
|
38
|
+
b: Number(functional[3]),
|
|
39
|
+
a: Number(functional[4] ?? 100) / 100,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Paints `top` over an opaque `base`; the result is opaque. */
|
|
44
|
+
export function over(top: Rgba, base: Rgba): Rgba {
|
|
45
|
+
const mixChannel = (from: number, to: number): number => Math.round(from * top.a + to * (1 - top.a))
|
|
46
|
+
return { r: mixChannel(top.r, base.r), g: mixChannel(top.g, base.g), b: mixChannel(top.b, base.b), a: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `color-mix(in srgb, top <percent>%, transparent)` paints over `base`. */
|
|
50
|
+
export function tint(top: Rgba, percent: number, base: Rgba): Rgba {
|
|
51
|
+
return over({ ...top, a: top.a * (percent / 100) }, base)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function channelLuminance(channel: number): number {
|
|
55
|
+
const unit = channel / 255
|
|
56
|
+
return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function luminance(color: Rgba): number {
|
|
60
|
+
return 0.2126 * channelLuminance(color.r) + 0.7152 * channelLuminance(color.g) + 0.0722 * channelLuminance(color.b)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** WCAG 2.x contrast ratio; both colors must already be opaque. */
|
|
64
|
+
export function contrastRatio(first: Rgba, second: Rgba): number {
|
|
65
|
+
const [lighter, darker] = [luminance(first), luminance(second)].sort((left, right) => right - left)
|
|
66
|
+
return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fallbackOf(declaration: string | undefined): string {
|
|
70
|
+
const match = /^var\(--cv-p-[a-z-]+,\s*(.+)\)$/.exec(declaration ?? '')
|
|
71
|
+
if (!match?.[1]) throw new Error(`no fallback in: ${declaration}`)
|
|
72
|
+
return match[1]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function internalVariable(token: TokenName): string {
|
|
76
|
+
return TOKEN_VARIABLE[token].replace('--cv-p-', '--cv-p-i-')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The default palette as styles.css declares it: the light one on `.cv-p`, the dark one on `.dark .cv-p`. */
|
|
80
|
+
export function defaultPalette(theme: 'light' | 'dark'): Palette {
|
|
81
|
+
const light = declarationsIn('.cv-p')
|
|
82
|
+
const dark = theme === 'dark' ? declarationsIn('.dark .cv-p') : new Map<string, string>()
|
|
83
|
+
const entries = (Object.keys(TOKEN_VARIABLE) as TokenName[]).map((token) => {
|
|
84
|
+
const variable = internalVariable(token)
|
|
85
|
+
return [token, parseColor(fallbackOf(dark.get(variable) ?? light.get(variable)))] as const
|
|
86
|
+
})
|
|
87
|
+
return Object.fromEntries(entries) as Palette
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A host mapping: only the tokens it sets; the rest keeps the light defaults, exactly as the cascade would. */
|
|
91
|
+
export function mappedPalette(overrides: Partial<Record<TokenName, string>>): Palette {
|
|
92
|
+
const base = defaultPalette('light')
|
|
93
|
+
const entries = (Object.keys(base) as TokenName[]).map((token) => {
|
|
94
|
+
const override = overrides[token]
|
|
95
|
+
return [token, override ? parseColor(override) : base[token]] as const
|
|
96
|
+
})
|
|
97
|
+
return Object.fromEntries(entries) as Palette
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Opaque version of the palette: translucent tokens are painted over the surface they sit on. */
|
|
101
|
+
export function flatten(palette: Palette): Palette {
|
|
102
|
+
const entries = (Object.keys(palette) as TokenName[]).map(
|
|
103
|
+
(token) => [token, over(palette[token], palette.surface)] as const,
|
|
104
|
+
)
|
|
105
|
+
return Object.fromEntries(entries) as Palette
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default value of a standalone token declared on `.cv-p` or `.dark .cv-p` (for example the read tick). */
|
|
109
|
+
export function declaredToken(name: string, theme: 'light' | 'dark'): string {
|
|
110
|
+
const value =
|
|
111
|
+
(theme === 'dark' ? declarationsIn('.dark .cv-p').get(name) : undefined) ?? declarationsIn('.cv-p').get(name)
|
|
112
|
+
if (!value) throw new Error(`token not declared: ${name}`)
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { fallbackOf }
|
|
@@ -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,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import { ParticipantConversations } from './ParticipantConversations'
|
|
7
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
8
|
+
import { renderGoldenScenarios, type GoldenComponents } from './participantGoldenScenarios.test-helper'
|
|
9
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
10
|
+
|
|
11
|
+
const FIXTURES_DIRECTORY = join(import.meta.dir, 'test-fixtures')
|
|
12
|
+
|
|
13
|
+
function readGolden(name: string): string {
|
|
14
|
+
return readFileSync(join(FIXTURES_DIRECTORY, `${name}.golden.html`), 'utf8')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('participant default markup against the recorded origin/main output', () => {
|
|
18
|
+
let rendered: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
process.env.TZ = 'UTC'
|
|
22
|
+
rendered = renderGoldenScenarios({
|
|
23
|
+
ParticipantThread,
|
|
24
|
+
ParticipantConversations,
|
|
25
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
26
|
+
} as unknown as GoldenComponents)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const scenarioNames = [
|
|
30
|
+
'thread-default',
|
|
31
|
+
'thread-plain',
|
|
32
|
+
'thread-avatars-initials',
|
|
33
|
+
'thread-tail-off',
|
|
34
|
+
'thread-error',
|
|
35
|
+
'thread-loading',
|
|
36
|
+
'thread-closed',
|
|
37
|
+
'conversations-list',
|
|
38
|
+
'conversations-selected',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for (const name of scenarioNames) {
|
|
42
|
+
it(`${name} is byte-identical`, () => {
|
|
43
|
+
expect(rendered[name]).toBe(readGolden(name))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
5
|
+
|
|
6
|
+
/** The components under comparison are injected so the same scenarios render against any checkout. */
|
|
7
|
+
export type GoldenComponents = {
|
|
8
|
+
readonly ParticipantThread: ComponentType<Record<string, unknown>>
|
|
9
|
+
readonly ParticipantConversations: ComponentType<Record<string, unknown>>
|
|
10
|
+
readonly labels: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const NOOP = (): void => undefined
|
|
14
|
+
const SUBJECT_GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
|
|
15
|
+
|
|
16
|
+
const ITEMS = [
|
|
17
|
+
{ kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', authorName: 'Ana Souza', text: '**oi** 11 98888-7777 a@b.com https://x.com', createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
18
|
+
{ kind: 'server', message: buildMessage({ id: 'b', direction: 'outbound', authorName: 'Op', status: 'read', createdAt: '2026-10-01T12:05:00.000Z' }) },
|
|
19
|
+
{ kind: 'server', message: buildMessage({ id: 'b2', direction: 'inbound', status: 'delivered', createdAt: '2026-10-02T12:05:00.000Z' }) },
|
|
20
|
+
{ kind: 'pending', origin: 'local', displayState: 'failed', pending: { clientMessageId: 'c', subject: { subjectType: 'invoice', subjectId: '1' }, text: 'W', createdAt: '2026-10-02T12:06:00.000Z', state: 'failed' } },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function buildThreadProps(labels: unknown): Record<string, unknown> {
|
|
24
|
+
return {
|
|
25
|
+
conversation: buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'P-1', channels: ['app', 'whatsapp'] }),
|
|
26
|
+
items: ITEMS, hasMore: true, onLoadOlder: NOOP, labels, resolveAttachmentUrl: async () => 'x',
|
|
27
|
+
draft: { value: 'abc', onChange: NOOP, files: [], onFilesChange: NOOP }, onSend: NOOP, status: 'ready', refresh: NOOP,
|
|
28
|
+
scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: true, scrollToLatest: NOOP },
|
|
29
|
+
newMessagesCount: 2, isSending: false, onBack: NOOP,
|
|
30
|
+
pendingActions: { retryLocal: NOOP, discardLocal: NOOP, editLocal: NOOP },
|
|
31
|
+
quickReplies: [{ id: '1', title: 'T', shortcut: 't', body: 'b' }],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const THREAD_VARIANTS: Record<string, Record<string, unknown>> = {
|
|
36
|
+
'thread-default': {},
|
|
37
|
+
'thread-plain': { hasMore: false, newMessagesCount: 0, quickReplies: [], scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: false, scrollToLatest: NOOP }, items: ITEMS.slice(0, 2) },
|
|
38
|
+
'thread-avatars-initials': { avatars: 'initials' },
|
|
39
|
+
'thread-tail-off': { tail: false },
|
|
40
|
+
'thread-error': { status: 'error', items: [] },
|
|
41
|
+
'thread-loading': { status: 'loading', items: [] },
|
|
42
|
+
'thread-closed': { conversation: buildConversation({ subjectId: '2', status: 'closed' }) },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderGoldenScenarios({ ParticipantThread, ParticipantConversations, labels }: GoldenComponents): Record<string, string> {
|
|
46
|
+
const base = buildThreadProps(labels)
|
|
47
|
+
const html: Record<string, string> = {}
|
|
48
|
+
for (const [name, extra] of Object.entries(THREAD_VARIANTS)) {
|
|
49
|
+
html[name] = renderToStaticMarkup(<ParticipantThread {...base} {...extra} />)
|
|
50
|
+
}
|
|
51
|
+
const api = { listConversations: async () => ({ data: [] }), fetchMessages: async () => [], sendMessage: async () => ({ outcome: 'queued' }), markRead: async () => undefined, resolveAttachmentUrl: async () => 'x' }
|
|
52
|
+
html['conversations-list'] = renderToStaticMarkup(<ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} />)
|
|
53
|
+
html['conversations-selected'] = renderToStaticMarkup(
|
|
54
|
+
<ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} selected={{ subjectType: 'invoice', subjectId: '1' }} />,
|
|
55
|
+
)
|
|
56
|
+
return html
|
|
57
|
+
}
|
|
@@ -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 {
|