@adatechnology/conversations-ui 0.6.1 → 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.
- package/README.md +126 -19
- 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.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +583 -19
- package/package.json +3 -3
- 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 +25 -81
- 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 +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -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/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/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/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/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- 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 +137 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { InlineAtom } from './participantMessageFormat.types'
|
|
2
|
+
|
|
3
|
+
const ALLOWED_LINK_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
|
4
|
+
const URL_PATTERN = /https?:\/\/[^\s<>`\u0000-\u001f]+/giu
|
|
5
|
+
const TRAILING_PUNCTUATION = '.,;:!?*_~\'"]}>'
|
|
6
|
+
|
|
7
|
+
export function isAllowedLinkHref(href: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(href)
|
|
10
|
+
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) && url.username === '' && url.password === ''
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAlphanumeric(character: string | undefined): boolean {
|
|
17
|
+
return character !== undefined && /[\p{L}\p{N}]/u.test(character)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function countOf(text: string, character: string): number {
|
|
21
|
+
let count = 0
|
|
22
|
+
for (const current of text) if (current === character) count += 1
|
|
23
|
+
return count
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trimUrl(raw: string): string {
|
|
27
|
+
let openCount = countOf(raw, '(')
|
|
28
|
+
let closeCount = countOf(raw, ')')
|
|
29
|
+
let end = raw.length
|
|
30
|
+
while (end > 0) {
|
|
31
|
+
const last = raw.charAt(end - 1)
|
|
32
|
+
if (last === ')' && closeCount > openCount) closeCount -= 1
|
|
33
|
+
else if (!TRAILING_PUNCTUATION.includes(last)) break
|
|
34
|
+
end -= 1
|
|
35
|
+
}
|
|
36
|
+
return raw.slice(0, end)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toLinkAtom(text: string, start: number, raw: string): InlineAtom | undefined {
|
|
40
|
+
const label = trimUrl(raw)
|
|
41
|
+
if (/^https?:\/\/$/i.test(label) || isAlphanumeric(text[start - 1])) return undefined
|
|
42
|
+
const href = label.replace(/^https?/i, (scheme) => scheme.toLowerCase())
|
|
43
|
+
if (!isAllowedLinkHref(href)) return undefined
|
|
44
|
+
return { start, end: start + label.length, node: { kind: 'link', href, label } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findLinkAtoms(text: string): InlineAtom[] {
|
|
48
|
+
const atoms: InlineAtom[] = []
|
|
49
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
50
|
+
const atom = toLinkAtom(text, match.index, match[0])
|
|
51
|
+
if (atom) atoms.push(atom)
|
|
52
|
+
}
|
|
53
|
+
return atoms
|
|
54
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { splitBlocks } from '../cssBlocks.test-helper'
|
|
6
|
+
import { PARTICIPANT_CSS, declarationsIn } from './participantCss.test-helper'
|
|
7
|
+
|
|
8
|
+
const TEXT_SELECTOR = /\.cv-p-(text|copyable)/
|
|
9
|
+
|
|
10
|
+
function collectBlocks(css: string): string[] {
|
|
11
|
+
return splitBlocks(css).flatMap(({ prelude, body }) => {
|
|
12
|
+
if (body === undefined) return []
|
|
13
|
+
if (prelude.startsWith('@media')) return collectBlocks(body)
|
|
14
|
+
return TEXT_SELECTOR.test(prelude) ? [`${prelude} {${body}}`] : []
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function forcedColorsBody(): string {
|
|
19
|
+
return splitBlocks(PARTICIPANT_CSS)
|
|
20
|
+
.filter(({ prelude }) => prelude === '@media (forced-colors: active)')
|
|
21
|
+
.map(({ body }) => body ?? '')
|
|
22
|
+
.join('\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TEXT_CSS = collectBlocks(PARTICIPANT_CSS).join('\n')
|
|
26
|
+
|
|
27
|
+
describe('participant message text - stylesheet', () => {
|
|
28
|
+
it('has rules for the text, the code, the block, the quote and the tokens', () => {
|
|
29
|
+
expect(collectBlocks(PARTICIPANT_CSS).length).toBeGreaterThan(12)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses no fixed colour and no url()', () => {
|
|
33
|
+
expect(TEXT_CSS).not.toMatch(/#[0-9a-f]{3,8}\b/i)
|
|
34
|
+
expect(TEXT_CSS).not.toMatch(/\b(rgb|rgba|hsl|hsla)\(/i)
|
|
35
|
+
expect(TEXT_CSS).not.toContain('url(')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('keeps the text selectable and the typography of the old text', () => {
|
|
39
|
+
const text = declarationsIn('.cv-p-text')
|
|
40
|
+
expect(text.get('user-select')).toBe('text')
|
|
41
|
+
expect(text.get('white-space')).toBe('pre-wrap')
|
|
42
|
+
expect(text.get('font-size')).toBe('14.2px')
|
|
43
|
+
expect(text.get('line-height')).toBe('19px')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('styles inline code and the block from the tokens', () => {
|
|
47
|
+
expect(declarationsIn('.cv-p-text__code').get('background')).toBe(
|
|
48
|
+
declarationsIn('.cv-p-text__pre').get('background'),
|
|
49
|
+
)
|
|
50
|
+
expect(declarationsIn('.cv-p-text__code').get('background')).toContain('color-mix(in srgb, var(--cv-p-i-border)')
|
|
51
|
+
expect(declarationsIn('.cv-p-text__code').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
52
|
+
const pre = declarationsIn('.cv-p-text__pre')
|
|
53
|
+
expect(pre.get('overflow-x')).toBe('auto')
|
|
54
|
+
expect(pre.get('white-space')).toBe('pre')
|
|
55
|
+
expect(pre.get('border-inline-start')).toBe('2px solid var(--cv-p-i-accent)')
|
|
56
|
+
expect(pre.get('scrollbar-color')).toBe('var(--cv-p-i-scrollbar-thumb) transparent')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('styles the quote, the lists and the links from the tokens', () => {
|
|
60
|
+
const quote = declarationsIn('.cv-p-text__quote')
|
|
61
|
+
expect(quote.get('border-inline-start')).toBe('3px solid var(--cv-p-i-border)')
|
|
62
|
+
expect(quote.get('color')).toBe('var(--cv-p-i-text-muted)')
|
|
63
|
+
expect(declarationsIn('.cv-p-text__list').get('padding-inline-start')).toBe('1.25rem')
|
|
64
|
+
const link = declarationsIn('.cv-p-text__link')
|
|
65
|
+
expect(link.get('color')).toBe('var(--cv-p-i-accent)')
|
|
66
|
+
expect(link.get('text-decoration')).toBe('underline')
|
|
67
|
+
expect(link.get('overflow-wrap')).toBe('anywhere')
|
|
68
|
+
expect(declarationsIn('.cv-p-text__link:focus-visible').get('outline')).toBe('2px solid var(--cv-p-i-accent)')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('highlights tokens discreetly with the accent', () => {
|
|
72
|
+
const token = declarationsIn('.cv-p-copyable')
|
|
73
|
+
expect(token.get('background')).toBe('color-mix(in srgb, var(--cv-p-i-accent) 14%, transparent)')
|
|
74
|
+
expect(token.get('border-bottom')).toBe('1px dotted var(--cv-p-i-accent)')
|
|
75
|
+
expect(token.get('font-family')).toContain('monospace')
|
|
76
|
+
expect(token.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
77
|
+
expect(token.get('user-select')).toBe('text')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps the tap target of tokens and the copy icon from invading neighbouring lines or the link text', () => {
|
|
81
|
+
expect(declarationsIn('.cv-p').get('--cv-p-i-touch')).toBe('2.75rem')
|
|
82
|
+
for (const selector of ['.cv-p-copyable::after', '.cv-p-text__link-copy::after']) {
|
|
83
|
+
expect(declarationsIn(selector).get('min-height')).toBe('1.5rem')
|
|
84
|
+
}
|
|
85
|
+
const icon = declarationsIn('.cv-p-text__link-copy::after')
|
|
86
|
+
expect(icon.get('inset-inline')).toBe('0 auto')
|
|
87
|
+
expect(icon.get('min-width')).toBe('var(--cv-p-i-touch)')
|
|
88
|
+
expect(icon.get('margin-inline-start')).toBeUndefined()
|
|
89
|
+
expect(declarationsIn('.cv-p-copyable').get('display')).toBe('inline')
|
|
90
|
+
expect(declarationsIn('.cv-p-copyable').get('position')).toBe('relative')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('gets borders instead of backgrounds under forced colors', () => {
|
|
94
|
+
const forced = forcedColorsBody()
|
|
95
|
+
expect(forced).toContain('.cv-p-copyable')
|
|
96
|
+
expect(forced).toContain('.cv-p-text__pre')
|
|
97
|
+
expect(declarationsIn('.cv-p-copyable', forced).get('border')).toBe('1px solid')
|
|
98
|
+
expect(declarationsIn('.cv-p-copyable', forced).get('background')).toBe('none')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('participant message text - hygiene', () => {
|
|
103
|
+
const FILES = [
|
|
104
|
+
'participantMessageFormat.ts',
|
|
105
|
+
'participantMessageInline.ts',
|
|
106
|
+
'participantMessageAtoms.ts',
|
|
107
|
+
'participantMessageLinks.ts',
|
|
108
|
+
'participantMessageCopyables.ts',
|
|
109
|
+
'participantDocuments.ts',
|
|
110
|
+
'ParticipantMessageText.tsx',
|
|
111
|
+
'ParticipantMessageNodes.tsx',
|
|
112
|
+
'ParticipantCopyableToken.tsx',
|
|
113
|
+
'ParticipantCopyAnnouncer.tsx',
|
|
114
|
+
'useParticipantCopyFeedback.ts',
|
|
115
|
+
] as const
|
|
116
|
+
const sources = FILES.map((file) => ({ file, source: readFileSync(join(import.meta.dir, file), 'utf8') }))
|
|
117
|
+
|
|
118
|
+
it('stays within 200 lines per file', () => {
|
|
119
|
+
for (const { file, source } of sources) {
|
|
120
|
+
expect({ file, ok: source.split('\n').length <= 200 }).toEqual({ file, ok: true })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('never sets inner HTML, never uses any, never reuses the shared MessageText', () => {
|
|
125
|
+
for (const { source } of sources) {
|
|
126
|
+
expect(source).not.toMatch(/innerHTML|dangerouslySetInnerHTML|:\s*any\b|as any|MessageText\b.*from '\.\.\//)
|
|
127
|
+
expect(source).not.toContain('whatsapp-formatting')
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('has no Tailwind utilities and no vocabulary from a specific product', () => {
|
|
132
|
+
for (const { source } of sources) {
|
|
133
|
+
expect(source).not.toMatch(/className="[^"]*\b(flex|grid|p-\d|m-\d|text-(sm|xs|lg)|bg-\w+)\b/)
|
|
134
|
+
expect(source).not.toMatch(/\b(carga|entrega|frete|cte|nf-?e|transport\w*|motorista|viagem|trip|driver)\b/i)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { SCROLLER_SELECTOR, focusScrollerOf } from './participantScrollFocus'
|
|
4
|
+
|
|
5
|
+
describe('focusScrollerOf', () => {
|
|
6
|
+
it('moves focus to the scroller ancestor without scrolling it', () => {
|
|
7
|
+
const calls: unknown[] = []
|
|
8
|
+
const selectors: string[] = []
|
|
9
|
+
focusScrollerOf({
|
|
10
|
+
closest: (selector) => {
|
|
11
|
+
selectors.push(selector)
|
|
12
|
+
return { focus: (options) => void calls.push(options) }
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
expect(selectors).toEqual([SCROLLER_SELECTOR])
|
|
16
|
+
expect(calls).toEqual([{ preventScroll: true }])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does nothing when there is no scroller', () => {
|
|
20
|
+
expect(() => focusScrollerOf({ closest: () => null })).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type FocusableAncestorSource = {
|
|
2
|
+
readonly closest: (selector: string) => { readonly focus: (options: { preventScroll: boolean }) => void } | null
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const SCROLLER_SELECTOR = '.cv-p-thread__scroll'
|
|
6
|
+
|
|
7
|
+
/** The button unmounts on click; moving focus first keeps it from falling to the body. */
|
|
8
|
+
export function focusScrollerOf(button: FocusableAncestorSource): void {
|
|
9
|
+
button.closest(SCROLLER_SELECTOR)?.focus({ preventScroll: true })
|
|
10
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { resolveScrollToLatestView, scrollIntoViewOptions, scrollToLatestOptions } from './participantScrollView'
|
|
6
|
+
|
|
7
|
+
describe('resolveScrollToLatestView', () => {
|
|
8
|
+
it('is hidden while the reader is near the end', () => {
|
|
9
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: false, newMessagesCount: 0, hasMessages: true })).toEqual({
|
|
10
|
+
isVisible: false,
|
|
11
|
+
badge: undefined,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is hidden when there is nothing to scroll to', () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: false }).isVisible,
|
|
18
|
+
).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is visible without a badge when away and nothing new arrived', () => {
|
|
22
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: true })).toEqual({
|
|
23
|
+
isVisible: true,
|
|
24
|
+
badge: undefined,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('carries the count of new incoming messages as the badge', () => {
|
|
29
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 3, hasMessages: true }).badge).toBe(
|
|
30
|
+
'3',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('caps a large count', () => {
|
|
35
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 100, hasMessages: true }).badge).toBe(
|
|
36
|
+
'99+',
|
|
37
|
+
)
|
|
38
|
+
expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 99, hasMessages: true }).badge).toBe(
|
|
39
|
+
'99',
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('scroll options', () => {
|
|
45
|
+
it('scrolls to the end of the list, smooth only without reduced motion', () => {
|
|
46
|
+
expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: false })).toEqual({
|
|
47
|
+
top: 900,
|
|
48
|
+
behavior: 'smooth',
|
|
49
|
+
})
|
|
50
|
+
expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: true })).toEqual({ top: 900, behavior: 'auto' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reveals the active chip with the nearest rule and no animation under reduced motion', () => {
|
|
54
|
+
expect(scrollIntoViewOptions(false)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
|
55
|
+
expect(scrollIntoViewOptions(true)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'auto' })
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('the near-the-end threshold has one owner', () => {
|
|
60
|
+
it('is declared only in participantScroll.ts', () => {
|
|
61
|
+
const sources = readdirSync(import.meta.dir).filter(
|
|
62
|
+
(name) => /\.tsx?$/.test(name) && !/\.test(-helper)?\.tsx?$/.test(name),
|
|
63
|
+
)
|
|
64
|
+
const declaring = sources.filter((name) =>
|
|
65
|
+
/NEAR_BOTTOM_THRESHOLD_PX\s*=/.test(readFileSync(join(import.meta.dir, name), 'utf8')),
|
|
66
|
+
)
|
|
67
|
+
expect(declaring).toEqual(['participantScroll.ts'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is not re-derived by the scroll-to-latest rule', () => {
|
|
71
|
+
const source = readFileSync(join(import.meta.dir, 'participantScrollView.ts'), 'utf8')
|
|
72
|
+
expect(source).not.toMatch(/scrollTop|clientHeight|threshold/i)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const BADGE_CAP = 99
|
|
2
|
+
|
|
3
|
+
export type ScrollToLatestViewParams = {
|
|
4
|
+
/** Already decided by shouldStickToBottom; this rule never looks at pixels. */
|
|
5
|
+
readonly isAwayFromBottom: boolean
|
|
6
|
+
readonly newMessagesCount: number
|
|
7
|
+
readonly hasMessages: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ScrollToLatestView = {
|
|
11
|
+
readonly isVisible: boolean
|
|
12
|
+
readonly badge: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ScrollToLatestOptionsParams = {
|
|
16
|
+
readonly scrollHeight: number
|
|
17
|
+
readonly isReducedMotion: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveScrollToLatestView(params: ScrollToLatestViewParams): ScrollToLatestView {
|
|
21
|
+
const { isAwayFromBottom, newMessagesCount, hasMessages } = params
|
|
22
|
+
if (!isAwayFromBottom || !hasMessages) return { isVisible: false, badge: undefined }
|
|
23
|
+
if (newMessagesCount <= 0) return { isVisible: true, badge: undefined }
|
|
24
|
+
return { isVisible: true, badge: newMessagesCount > BADGE_CAP ? `${BADGE_CAP}+` : String(newMessagesCount) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function scrollToLatestOptions(params: ScrollToLatestOptionsParams): ScrollToOptions {
|
|
28
|
+
return { top: params.scrollHeight, behavior: params.isReducedMotion ? 'auto' : 'smooth' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scrollIntoViewOptions(isReducedMotion: boolean): ScrollIntoViewOptions {
|
|
32
|
+
return { block: 'nearest', inline: 'nearest', behavior: isReducedMotion ? 'auto' : 'smooth' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readPrefersReducedMotion(): boolean {
|
|
36
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
37
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
38
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { PARTICIPANT_CSS, declarationsIn, mediaBody, toRem } from './participantCss.test-helper'
|
|
4
|
+
|
|
5
|
+
const VERTICAL_AREAS = ['.cv-p-thread__scroll', '.cv-p-inbox', '.cv-p-composer__input']
|
|
6
|
+
const HORIZONTAL_STRIPS = ['.cv-p-filters', '.cv-p-quick']
|
|
7
|
+
const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'
|
|
8
|
+
const FORCED_COLORS = '(forced-colors: active)'
|
|
9
|
+
const WEBKIT_GUARD = 'not (forced-colors: active)'
|
|
10
|
+
|
|
11
|
+
function webkitRules(selector: string): Map<string, string> {
|
|
12
|
+
return declarationsIn(`${selector}::-webkit-scrollbar`, mediaBody(WEBKIT_GUARD))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe.each(VERTICAL_AREAS)('vertical scroller %s', (selector) => {
|
|
16
|
+
const declarations = declarationsIn(selector)
|
|
17
|
+
|
|
18
|
+
it('has a thin themed scrollbar from the tokens', () => {
|
|
19
|
+
expect(declarations.get('scrollbar-width')).toBe('thin')
|
|
20
|
+
expect(declarations.get('scrollbar-color')).toBe('var(--cv-p-i-scrollbar-thumb) transparent')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not chain the scroll to the page', () => {
|
|
24
|
+
expect(declarations.get('overscroll-behavior-y')).toBe('contain')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('has a 6px track-less webkit scrollbar with a rounded thumb and no arrows', () => {
|
|
28
|
+
expect(webkitRules(selector).get('width')).toBe('6px')
|
|
29
|
+
expect(webkitRules(selector).get('height')).toBe('6px')
|
|
30
|
+
const track = declarationsIn(`${selector}::-webkit-scrollbar-track`, mediaBody(WEBKIT_GUARD))
|
|
31
|
+
expect(track.get('background')).toBe('transparent')
|
|
32
|
+
const thumb = declarationsIn(`${selector}::-webkit-scrollbar-thumb`, mediaBody(WEBKIT_GUARD))
|
|
33
|
+
expect(thumb.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb)')
|
|
34
|
+
expect(thumb.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
35
|
+
const buttons = declarationsIn(`${selector}::-webkit-scrollbar-button`, mediaBody(WEBKIT_GUARD))
|
|
36
|
+
expect(buttons.get('display')).toBe('none')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('highlights the thumb with the accent on hover and drag', () => {
|
|
40
|
+
const hover = declarationsIn(`${selector}::-webkit-scrollbar-thumb:hover`, mediaBody(WEBKIT_GUARD))
|
|
41
|
+
const active = declarationsIn(`${selector}::-webkit-scrollbar-thumb:active`, mediaBody(WEBKIT_GUARD))
|
|
42
|
+
expect(hover.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb-hover)')
|
|
43
|
+
expect(active.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb-hover)')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('goes back to the system scrollbar under forced colors', () => {
|
|
47
|
+
const forced = declarationsIn(selector, mediaBody(FORCED_COLORS))
|
|
48
|
+
expect(forced.get('scrollbar-color')).toBe('auto')
|
|
49
|
+
expect(forced.get('scrollbar-width')).toBe('auto')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('scrollbar tokens', () => {
|
|
54
|
+
const root = declarationsIn('.cv-p')
|
|
55
|
+
|
|
56
|
+
it('derive from the existing tokens and are overridable', () => {
|
|
57
|
+
expect(root.get('--cv-p-i-scrollbar-thumb')).toBe('var(--cv-p-scrollbar-thumb, var(--cv-p-i-border))')
|
|
58
|
+
expect(root.get('--cv-p-i-scrollbar-thumb-hover')).toBe('var(--cv-p-scrollbar-thumb-hover, var(--cv-p-i-accent))')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('thread scrollbar does not move the bubbles', () => {
|
|
63
|
+
it('reserves no gutter', () => {
|
|
64
|
+
expect(declarationsIn('.cv-p-thread__scroll').get('scrollbar-gutter')).toBeUndefined()
|
|
65
|
+
expect(declarationsIn('.cv-p-inbox').get('scrollbar-gutter')).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe.each(HORIZONTAL_STRIPS)('horizontal strip %s', (selector) => {
|
|
70
|
+
const declarations = declarationsIn(selector)
|
|
71
|
+
|
|
72
|
+
it('hides the scrollbar everywhere', () => {
|
|
73
|
+
expect(declarations.get('scrollbar-width')).toBe('none')
|
|
74
|
+
expect(declarationsIn(`${selector}::-webkit-scrollbar`).get('display')).toBe('none')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('fades both edges to hint that it scrolls', () => {
|
|
78
|
+
const mask = declarations.get('mask-image') ?? ''
|
|
79
|
+
expect(mask).toContain('linear-gradient(to right')
|
|
80
|
+
expect(mask).toContain('transparent')
|
|
81
|
+
expect(declarations.get('-webkit-mask-image')).toBe(mask)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('snaps with proximity and does not chain sideways', () => {
|
|
85
|
+
expect(declarations.get('scroll-snap-type')).toBe('x proximity')
|
|
86
|
+
expect(declarations.get('overscroll-behavior-x')).toBe('contain')
|
|
87
|
+
expect(declarations.get('-webkit-overflow-scrolling')).toBe('touch')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps the first chip clear of the fade', () => {
|
|
91
|
+
expect(declarations.get('scroll-padding-inline')).toBe('var(--cv-p-i-fade)')
|
|
92
|
+
expect(declarations.get('padding-inline')).toBe('var(--cv-p-i-fade)')
|
|
93
|
+
expect(toRem(declarationsIn(selector).get('--cv-p-i-fade'))).toBeGreaterThanOrEqual(0.75)
|
|
94
|
+
expect(declarationsIn(`${selector} > .cv-p-chip`).get('scroll-snap-align')).toBe('start')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('drops the mask under forced colors', () => {
|
|
98
|
+
const forced = declarationsIn(selector, mediaBody(FORCED_COLORS))
|
|
99
|
+
expect(forced.get('mask-image')).toBe('none')
|
|
100
|
+
expect(forced.get('-webkit-mask-image')).toBe('none')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('smooth scrolling', () => {
|
|
105
|
+
const SMOOTH_AREAS = ['.cv-p-inbox', '.cv-p-filters', '.cv-p-quick']
|
|
106
|
+
|
|
107
|
+
it.each(SMOOTH_AREAS)('%s is smooth only when motion is welcome', (selector) => {
|
|
108
|
+
expect(declarationsIn(selector, mediaBody(NO_PREFERENCE)).get('scroll-behavior')).toBe('smooth')
|
|
109
|
+
expect(declarationsIn(selector).get('scroll-behavior')).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('never makes the thread scroller smooth: its programmatic positioning must stay instant', () => {
|
|
113
|
+
expect(declarationsIn('.cv-p-thread__scroll').get('scroll-behavior')).toBeUndefined()
|
|
114
|
+
expect(declarationsIn('.cv-p-thread__scroll', mediaBody(NO_PREFERENCE)).get('scroll-behavior')).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('has no scroll-behavior outside the no-preference block', () => {
|
|
118
|
+
const outside = PARTICIPANT_CSS.replace(mediaBody(NO_PREFERENCE) ?? '', '')
|
|
119
|
+
expect(outside).not.toMatch(/(?<!over)scroll-behavior/)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('scroll-to-latest button styles', () => {
|
|
124
|
+
const button = declarationsIn('.cv-p-thread__latest-button')
|
|
125
|
+
|
|
126
|
+
it('is a 44px touch target', () => {
|
|
127
|
+
expect(button.get('width')).toBe('var(--cv-p-i-touch)')
|
|
128
|
+
expect(button.get('height')).toBe('var(--cv-p-i-touch)')
|
|
129
|
+
expect(toRem(declarationsIn('.cv-p').get('--cv-p-i-touch'))).toBeGreaterThanOrEqual(2.75)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('floats without taking room in the scroller', () => {
|
|
133
|
+
const anchor = declarationsIn('.cv-p-thread__latest')
|
|
134
|
+
expect(anchor.get('position')).toBe('sticky')
|
|
135
|
+
expect(anchor.get('bottom')).toBe('0')
|
|
136
|
+
expect(anchor.get('height')).toBe('0')
|
|
137
|
+
expect(button.get('position')).toBe('absolute')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('animates its entrance only when motion is welcome', () => {
|
|
141
|
+
expect(declarationsIn('.cv-p-thread__latest-button', mediaBody(NO_PREFERENCE)).get('animation')).toContain(
|
|
142
|
+
'cv-p-latest-in',
|
|
143
|
+
)
|
|
144
|
+
expect(button.get('animation')).toBeUndefined()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('outlines the badge under forced colors', () => {
|
|
148
|
+
expect(declarationsIn('.cv-p-thread__latest-badge', mediaBody(FORCED_COLORS)).get('border')).toContain('ButtonText')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('no leftovers', () => {
|
|
153
|
+
it('uses no Tailwind and no host vocabulary in the new rules', () => {
|
|
154
|
+
expect(PARTICIPANT_CSS).not.toMatch(/@apply|@tailwind/)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildConversation } from './participantFixtures.test-helper'
|
|
4
|
+
import { resolveConversationIcon } from './participantSubjectIcon'
|
|
5
|
+
|
|
6
|
+
const conversation = buildConversation({ subjectId: '1' })
|
|
7
|
+
const group = { subjectType: 'invoice', label: 'Invoices', icon: <i>group</i> }
|
|
8
|
+
|
|
9
|
+
describe('resolveConversationIcon', () => {
|
|
10
|
+
it('prefers the host icon, then the group icon, then nothing', () => {
|
|
11
|
+
const host = <b>host</b>
|
|
12
|
+
expect(resolveConversationIcon({ conversation, group, renderSubjectIcon: () => host })).toBe(host)
|
|
13
|
+
expect(resolveConversationIcon({ conversation, group })).toBe(group.icon)
|
|
14
|
+
expect(resolveConversationIcon({ conversation, group: { ...group, icon: undefined } })).toBeUndefined()
|
|
15
|
+
expect(resolveConversationIcon({ conversation, group: undefined })).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it.each([null, undefined, false, ''])('treats %p from the host as no icon, like the list row', (empty) => {
|
|
19
|
+
expect(resolveConversationIcon({ conversation, group, renderSubjectIcon: () => empty })).toBe(group.icon)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('hands the conversation to the host renderer', () => {
|
|
23
|
+
const seen: string[] = []
|
|
24
|
+
resolveConversationIcon({ conversation, group, renderSubjectIcon: (item) => seen.push(item.subjectId) })
|
|
25
|
+
expect(seen).toEqual(['1'])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
6
|
+
|
|
7
|
+
export type ResolveConversationIconParams = {
|
|
8
|
+
readonly conversation: ParticipantConversationSummary
|
|
9
|
+
readonly group: ParticipantSubjectGroup | undefined
|
|
10
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isDrawable(node: ReactNode): boolean {
|
|
14
|
+
return node !== null && node !== undefined && node !== false && node !== ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Host icon, then group icon, then nothing; the list row adds its own text fallback, the header draws no tile. */
|
|
18
|
+
export function resolveConversationIcon({
|
|
19
|
+
conversation,
|
|
20
|
+
group,
|
|
21
|
+
renderSubjectIcon,
|
|
22
|
+
}: ResolveConversationIconParams): ReactNode | undefined {
|
|
23
|
+
const custom = renderSubjectIcon?.(conversation)
|
|
24
|
+
if (isDrawable(custom)) return custom
|
|
25
|
+
return isDrawable(group?.icon) ? group?.icon : undefined
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useAnnounceCopy } from './ParticipantCopyAnnouncer'
|
|
4
|
+
import { INITIAL_PROTOCOL_COPY_STATE, copyProtocolToClipboard, protocolCopyReducer } from './participantProtocolCopy'
|
|
5
|
+
|
|
6
|
+
const COPIED_FEEDBACK_MS = 2000
|
|
7
|
+
|
|
8
|
+
export type CopyFeedback = {
|
|
9
|
+
readonly isCopied: boolean
|
|
10
|
+
readonly copy: (value: string, announcement: string) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Copied state is local to each token; a clipboard failure is silent and shows nothing. */
|
|
14
|
+
export function useParticipantCopyFeedback(): CopyFeedback {
|
|
15
|
+
const [state, dispatch] = useReducer(protocolCopyReducer, INITIAL_PROTOCOL_COPY_STATE)
|
|
16
|
+
const announce = useAnnounceCopy()
|
|
17
|
+
const { isCopied, copyCount } = state
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isCopied) return undefined
|
|
21
|
+
const timer = setTimeout(() => dispatch({ type: 'expired' }), COPIED_FEEDBACK_MS)
|
|
22
|
+
return () => clearTimeout(timer)
|
|
23
|
+
}, [isCopied, copyCount])
|
|
24
|
+
|
|
25
|
+
async function copyValue(value: string, announcement: string): Promise<void> {
|
|
26
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
27
|
+
if (!(await copyProtocolToClipboard(value, clipboard))) return
|
|
28
|
+
dispatch({ type: 'copied' })
|
|
29
|
+
announce(announcement)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { isCopied, copy: (value, announcement) => void copyValue(value, announcement) }
|
|
33
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
|
|
4
4
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
5
|
+
import { useScrollAwayState } from './useScrollAwayState'
|
|
5
6
|
import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
|
|
6
7
|
|
|
7
8
|
export type UseParticipantThreadScrollResult = {
|
|
@@ -14,8 +15,15 @@ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
|
|
|
14
15
|
return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function useParticipantThreadScroll(
|
|
18
|
-
|
|
18
|
+
export function useParticipantThreadScroll(
|
|
19
|
+
items: readonly ParticipantTimelineItem[],
|
|
20
|
+
): UseParticipantThreadScrollResult {
|
|
21
|
+
const { scroll: base, isNearBottom } = useStickToBottom({
|
|
22
|
+
firstKey: keyOf(items[0]),
|
|
23
|
+
lastKey: keyOf(items[items.length - 1]),
|
|
24
|
+
})
|
|
25
|
+
const { isAwayFromBottom, scrollToLatest } = useScrollAwayState(base.ref, isNearBottom)
|
|
26
|
+
const scroll = { ...base, isAwayFromBottom, scrollToLatest }
|
|
19
27
|
const seenIds = useRef<ReadonlySet<string>>(new Set())
|
|
20
28
|
|
|
21
29
|
useEffect(() => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { readPrefersReducedMotion, scrollIntoViewOptions } from './participantScrollView'
|
|
4
|
+
|
|
5
|
+
/** Brings the pressed chip into view when the active one changes; the first render never scrolls. */
|
|
6
|
+
export function useRevealActiveChip(active: string): RefObject<HTMLDivElement | null> {
|
|
7
|
+
const strip = useRef<HTMLDivElement | null>(null)
|
|
8
|
+
const previousActive = useRef<string | undefined>(undefined)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const wasActive = previousActive.current
|
|
12
|
+
previousActive.current = active
|
|
13
|
+
if (wasActive === undefined || wasActive === active) return
|
|
14
|
+
strip.current
|
|
15
|
+
?.querySelector<HTMLElement>('[aria-pressed="true"]')
|
|
16
|
+
?.scrollIntoView(scrollIntoViewOptions(readPrefersReducedMotion()))
|
|
17
|
+
}, [active])
|
|
18
|
+
|
|
19
|
+
return strip
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useCallback, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { readPrefersReducedMotion, scrollToLatestOptions } from './participantScrollView'
|
|
4
|
+
|
|
5
|
+
export type UseScrollAwayStateResult = {
|
|
6
|
+
readonly isAwayFromBottom: boolean
|
|
7
|
+
readonly scrollToLatest: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Away is the negation of the near-the-end state useStickToBottom already tracks with shouldStickToBottom. */
|
|
11
|
+
export function useScrollAwayState(
|
|
12
|
+
ref: RefObject<HTMLDivElement | null>,
|
|
13
|
+
isNearBottom: boolean,
|
|
14
|
+
): UseScrollAwayStateResult {
|
|
15
|
+
const scrollToLatest = useCallback((): void => {
|
|
16
|
+
const element = ref.current
|
|
17
|
+
if (!element) return
|
|
18
|
+
element.scrollTo(
|
|
19
|
+
scrollToLatestOptions({ scrollHeight: element.scrollHeight, isReducedMotion: readPrefersReducedMotion() }),
|
|
20
|
+
)
|
|
21
|
+
}, [ref])
|
|
22
|
+
|
|
23
|
+
return { isAwayFromBottom: !isNearBottom, scrollToLatest }
|
|
24
|
+
}
|