@adatechnology/conversations-ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +126 -19
  2. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  3. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  4. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  5. package/dist/flows/index.js +3 -5
  6. package/dist/index.js +834 -764
  7. package/dist/participant/index.d.ts +18 -1
  8. package/dist/participant/index.js +1188 -313
  9. package/dist/preview/index.js +2 -3
  10. package/dist/styles.css +591 -19
  11. package/package.json +1 -1
  12. package/src/cssBlocks.test-helper.ts +31 -0
  13. package/src/legacyStyles.golden.test.ts +2 -31
  14. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  15. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  16. package/src/participant/ParticipantBackButton.tsx +14 -0
  17. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  18. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  19. package/src/participant/ParticipantComposer.tsx +8 -40
  20. package/src/participant/ParticipantComposerControls.tsx +83 -0
  21. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  22. package/src/participant/ParticipantConversations.tsx +7 -0
  23. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  24. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  26. package/src/participant/ParticipantDayDivider.tsx +18 -0
  27. package/src/participant/ParticipantInbox.tsx +3 -1
  28. package/src/participant/ParticipantInboxRow.tsx +6 -4
  29. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  30. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  31. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  32. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  33. package/src/participant/ParticipantMessageText.tsx +52 -0
  34. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  35. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  36. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  37. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  38. package/src/participant/ParticipantThread.test.tsx +37 -0
  39. package/src/participant/ParticipantThread.tsx +45 -95
  40. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  41. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  42. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  44. package/src/participant/ParticipantTimeline.tsx +103 -0
  45. package/src/participant/index.ts +1 -0
  46. package/src/participant/participantAutoGrow.test.ts +43 -0
  47. package/src/participant/participantAutoGrow.ts +12 -0
  48. package/src/participant/participantAvatar.test.ts +74 -0
  49. package/src/participant/participantAvatar.ts +48 -0
  50. package/src/participant/participantBubbleTail.test.tsx +107 -0
  51. package/src/participant/participantChatDesign.test.tsx +198 -0
  52. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  53. package/src/participant/participantCopyAnnouncement.ts +37 -0
  54. package/src/participant/participantCss.test-helper.ts +34 -0
  55. package/src/participant/participantDay.test.ts +22 -0
  56. package/src/participant/participantDay.ts +8 -0
  57. package/src/participant/participantDocuments.ts +45 -0
  58. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  59. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  60. package/src/participant/participantLabels.ts +11 -0
  61. package/src/participant/participantMessageAtoms.ts +53 -0
  62. package/src/participant/participantMessageCopyables.test.ts +141 -0
  63. package/src/participant/participantMessageCopyables.ts +76 -0
  64. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  65. package/src/participant/participantMessageFormat.test.ts +177 -0
  66. package/src/participant/participantMessageFormat.ts +135 -0
  67. package/src/participant/participantMessageFormat.types.ts +22 -0
  68. package/src/participant/participantMessageInline.test.ts +82 -0
  69. package/src/participant/participantMessageInline.ts +99 -0
  70. package/src/participant/participantMessageLinks.test.ts +100 -0
  71. package/src/participant/participantMessageLinks.ts +54 -0
  72. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  73. package/src/participant/participantScrollFocus.test.ts +22 -0
  74. package/src/participant/participantScrollFocus.ts +10 -0
  75. package/src/participant/participantScrollView.test.ts +74 -0
  76. package/src/participant/participantScrollView.ts +38 -0
  77. package/src/participant/participantScrollbars.test.ts +156 -0
  78. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  79. package/src/participant/participantSubjectIcon.ts +26 -0
  80. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  81. package/src/participant/useParticipantThreadScroll.ts +10 -2
  82. package/src/participant/useRevealActiveChip.ts +20 -0
  83. package/src/participant/useScrollAwayState.ts +24 -0
  84. package/src/participant/useStickToBottom.ts +16 -2
  85. package/src/participantLayoutShrink.test.ts +82 -0
  86. package/src/styles.css +140 -21
  87. package/dist/chunk-MRVFFJEH.js +0 -64
  88. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1,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(items: readonly ParticipantTimelineItem[]): UseParticipantThreadScrollResult {
18
- const { scroll, isNearBottom } = useStickToBottom({ firstKey: keyOf(items[0]), lastKey: keyOf(items[items.length - 1]) })
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
+ }
@@ -1,6 +1,11 @@
1
1
  import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
2
2
 
3
- import { NEAR_BOTTOM_THRESHOLD_PX, resolveScrollAction, scrollAnchorAfterPrepend, shouldStickToBottom } from './participantScroll'
3
+ import {
4
+ NEAR_BOTTOM_THRESHOLD_PX,
5
+ resolveScrollAction,
6
+ scrollAnchorAfterPrepend,
7
+ shouldStickToBottom,
8
+ } from './participantScroll'
4
9
 
5
10
  export type UseStickToBottomParams = {
6
11
  readonly firstKey: string | undefined
@@ -10,6 +15,9 @@ export type UseStickToBottomParams = {
10
15
  export type ParticipantThreadScroll = {
11
16
  readonly ref: RefObject<HTMLDivElement | null>
12
17
  readonly onScroll: (event: UIEvent<HTMLDivElement>) => void
18
+ /** Absent draws no scroll-to-latest button. */
19
+ readonly isAwayFromBottom?: boolean
20
+ readonly scrollToLatest?: () => void
13
21
  }
14
22
 
15
23
  export type UseStickToBottomResult = {
@@ -27,7 +35,13 @@ type Snapshot = {
27
35
 
28
36
  export function useStickToBottom({ firstKey, lastKey }: UseStickToBottomParams): UseStickToBottomResult {
29
37
  const ref = useRef<HTMLDivElement | null>(null)
30
- const snapshot = useRef<Snapshot>({ hasPositioned: false, firstKey: undefined, lastKey: undefined, scrollTop: 0, scrollHeight: 0 })
38
+ const snapshot = useRef<Snapshot>({
39
+ hasPositioned: false,
40
+ firstKey: undefined,
41
+ lastKey: undefined,
42
+ scrollTop: 0,
43
+ scrollHeight: 0,
44
+ })
31
45
  const [isNearBottom, setIsNearBottom] = useState(true)
32
46
  const isNearBottomRef = useRef(true)
33
47
 
@@ -0,0 +1,82 @@
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
+
7
+ const CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
8
+
9
+ const NON_SHRINKING_ITEMS = [
10
+ '.cv-p-filters',
11
+ '.cv-p-quick',
12
+ '.cv-p-search',
13
+ '.cv-p-composer',
14
+ '.cv-p-thread__header',
15
+ '.cv-p-thread__subject-card',
16
+ ]
17
+
18
+ type FlexState = { grow: number; shrink: number }
19
+
20
+ const FLEX_KEYWORDS: Record<string, FlexState> = {
21
+ none: { grow: 0, shrink: 0 },
22
+ auto: { grow: 1, shrink: 1 },
23
+ initial: { grow: 0, shrink: 1 },
24
+ }
25
+
26
+ function parseFlexShorthand(value: string): FlexState {
27
+ const keyword = FLEX_KEYWORDS[value]
28
+ if (keyword) return { ...keyword }
29
+ const numbers = value
30
+ .split(/\s+/)
31
+ .filter((token) => /^\d*\.?\d+$/.test(token))
32
+ .map(Number)
33
+ return { grow: numbers[0] ?? 0, shrink: numbers[1] ?? 1 }
34
+ }
35
+
36
+ function cleanValue(raw: string): string {
37
+ return raw
38
+ .replace(/!important/g, '')
39
+ .trim()
40
+ .toLowerCase()
41
+ }
42
+
43
+ // Only depth-0 style rules count (@media/@supports are conditional); later declarations win.
44
+ function effectiveFlex(selector: string): FlexState | undefined {
45
+ let state: FlexState | undefined
46
+ for (const { prelude, body } of splitBlocks(CSS)) {
47
+ if (body === undefined || prelude.startsWith('@')) continue
48
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
49
+ for (const declaration of body.split(';')) {
50
+ const separator = declaration.indexOf(':')
51
+ if (separator === -1) continue
52
+ const property = declaration.slice(0, separator).trim().toLowerCase()
53
+ const value = cleanValue(declaration.slice(separator + 1))
54
+ if (property === 'flex') state = parseFlexShorthand(value)
55
+ if (property === 'flex-shrink') state = { grow: state?.grow ?? 0, shrink: Number(value) }
56
+ }
57
+ }
58
+ return state
59
+ }
60
+
61
+ function hasMinHeightZero(selector: string): boolean {
62
+ return splitBlocks(CSS).some(
63
+ ({ prelude, body }) =>
64
+ body !== undefined &&
65
+ !prelude.startsWith('@') &&
66
+ prelude.split(',').some((entry) => entry.trim() === selector) &&
67
+ /min-height:\s*0\b/.test(body),
68
+ )
69
+ }
70
+
71
+ describe('participant view flex layout', () => {
72
+ it.each(NON_SHRINKING_ITEMS)('%s does not shrink inside a flex column', (selector) => {
73
+ expect(effectiveFlex(selector)?.shrink).toBe(0)
74
+ })
75
+
76
+ it('keeps the thread scroller as the only shrinking, growing region', () => {
77
+ const scroller = effectiveFlex('.cv-p-thread__scroll')
78
+ expect(scroller?.grow).toBe(1)
79
+ expect(scroller?.shrink).toBe(1)
80
+ expect(hasMinHeightZero('.cv-p-thread__scroll')).toBe(true)
81
+ })
82
+ })