@adatechnology/conversations-ui 0.8.0 → 0.9.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/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.js +5 -3
- package/dist/participant/index.d.ts +3 -0
- package/dist/participant/index.js +536 -276
- package/dist/styles.css +99 -23
- package/package.json +2 -1
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +1 -1
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +23 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantLabels.ts +6 -0
- package/src/participant/participantThemeContrast.test.ts +8 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/styles.css +19 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
5
|
+
import { BubbleBody } from './ParticipantBubbleBody'
|
|
6
|
+
import { RetryButton } from './ParticipantBubbleStatus'
|
|
7
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
9
8
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
9
|
import type { ParticipantPerspective } from './participantPerspective'
|
|
11
10
|
import {
|
|
@@ -20,11 +19,11 @@ export type ParticipantMessageBubbleProps = {
|
|
|
20
19
|
readonly labels: ParticipantConversationsLabels
|
|
21
20
|
readonly confirmsRead: boolean
|
|
22
21
|
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
23
|
-
/** Absent means no retry button, even when the message failed. */
|
|
22
|
+
/** Absent means no retry button beside the bubble, even when the message failed. */
|
|
24
23
|
readonly onRetry?: () => void
|
|
25
|
-
/** Absent means no discard
|
|
24
|
+
/** Absent means no discard item in the failed-message menu. */
|
|
26
25
|
readonly onDiscard?: () => void
|
|
27
|
-
/** Absent means no edit
|
|
26
|
+
/** Absent means no edit item in the failed-message menu. */
|
|
28
27
|
readonly onEdit?: () => void
|
|
29
28
|
/** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
|
|
30
29
|
readonly avatar?: ReactNode
|
|
@@ -34,7 +33,7 @@ export type ParticipantMessageBubbleProps = {
|
|
|
34
33
|
readonly perspective?: ParticipantPerspective
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
type BubbleContent = {
|
|
36
|
+
export type BubbleContent = {
|
|
38
37
|
readonly isMine: boolean
|
|
39
38
|
readonly authorName?: string
|
|
40
39
|
readonly text?: string
|
|
@@ -68,62 +67,31 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
|
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
function bubbleClassName(isMine: boolean, hasTail: boolean): string {
|
|
70
|
+
function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean): string {
|
|
72
71
|
const classes = ['cv-p-bubble']
|
|
73
72
|
if (isMine) classes.push('cv-p-bubble--mine')
|
|
73
|
+
if (hasFailed) classes.push('cv-p-bubble--failed')
|
|
74
74
|
if (!hasTail) classes.push('cv-p-bubble--no-tail')
|
|
75
75
|
return classes.join(' ')
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function ParticipantMessageBubble({
|
|
79
|
-
item,
|
|
80
|
-
|
|
81
|
-
confirmsRead,
|
|
82
|
-
resolveAttachmentUrl,
|
|
83
|
-
onRetry,
|
|
84
|
-
onDiscard,
|
|
85
|
-
onEdit,
|
|
86
|
-
avatar,
|
|
87
|
-
tail = true,
|
|
88
|
-
perspective,
|
|
89
|
-
}: ParticipantMessageBubbleProps) {
|
|
78
|
+
export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
79
|
+
const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
|
|
80
|
+
const textId = useId()
|
|
90
81
|
const content = describeItem(item, perspective)
|
|
91
|
-
const
|
|
92
|
-
const ownStatus = isMine
|
|
82
|
+
const ownStatus = content.isMine
|
|
93
83
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
94
84
|
: undefined
|
|
85
|
+
const isFailed = ownStatus === 'failed'
|
|
86
|
+
const canRetry = isFailed && onRetry !== undefined
|
|
95
87
|
|
|
96
88
|
const bubble = (
|
|
97
|
-
<div className={bubbleClassName(isMine, tail)}>
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
) : content.authorName ? (
|
|
101
|
-
<span className="cv-p-bubble__author">{content.authorName}</span>
|
|
102
|
-
) : null}
|
|
103
|
-
{content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
|
|
104
|
-
{content.attachments.map((attachment) => (
|
|
105
|
-
<ParticipantAttachmentItem
|
|
106
|
-
key={attachment.id}
|
|
107
|
-
attachment={attachment}
|
|
108
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
109
|
-
labels={labels}
|
|
110
|
-
/>
|
|
111
|
-
))}
|
|
112
|
-
{content.pendingFilenames.map((filename, index) => (
|
|
113
|
-
<span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
|
|
114
|
-
{filename}
|
|
115
|
-
</span>
|
|
116
|
-
))}
|
|
117
|
-
<span className="cv-p-bubble__meta">
|
|
118
|
-
<time className="cv-p-bubble__time" dateTime={content.createdAt}>
|
|
119
|
-
{formatTimestamp(content.createdAt)}
|
|
120
|
-
</time>
|
|
121
|
-
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
122
|
-
</span>
|
|
123
|
-
{ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
|
|
89
|
+
<div className={bubbleClassName(content.isMine, tail, isFailed)}>
|
|
90
|
+
{canRetry ? <RetryButton labels={labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
91
|
+
<BubbleBody {...props} content={content} ownStatus={ownStatus} textId={canRetry ? textId : undefined} />
|
|
124
92
|
</div>
|
|
125
93
|
)
|
|
126
|
-
if (avatar === undefined || isMine) return bubble
|
|
94
|
+
if (avatar === undefined || content.isMine) return bubble
|
|
127
95
|
return (
|
|
128
96
|
<div className="cv-p-bubble-row">
|
|
129
97
|
<span className="cv-p-bubble-row__avatar">{avatar}</span>
|
|
@@ -45,7 +45,7 @@ describe('ParticipantMessageText - links', () => {
|
|
|
45
45
|
expect(markup).toContain('target="_blank"')
|
|
46
46
|
expect(markup).toContain('<span class="cv-p-sr-only"> (opens in a new tab)</span>')
|
|
47
47
|
expect(markup).toContain(
|
|
48
|
-
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2">',
|
|
48
|
+
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2" data-cv-tooltip="Copy">',
|
|
49
49
|
)
|
|
50
50
|
})
|
|
51
51
|
|
|
@@ -7,6 +7,8 @@ import type { FormatBlock } from './participantMessageFormat.types'
|
|
|
7
7
|
export type ParticipantMessageTextProps = {
|
|
8
8
|
readonly text: string | null | undefined
|
|
9
9
|
readonly labels: ParticipantTextLabels
|
|
10
|
+
/** Lets a control point at the text with aria-describedby; absent leaves the markup as it was. */
|
|
11
|
+
readonly id?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: number) {
|
|
@@ -40,7 +42,7 @@ function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: num
|
|
|
40
42
|
)
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export function ParticipantMessageText({ text, labels }: ParticipantMessageTextProps) {
|
|
45
|
+
export function ParticipantMessageText({ text, labels, id }: ParticipantMessageTextProps) {
|
|
44
46
|
const blocks = useMemo(() => parseParticipantMessage(text), [text])
|
|
45
47
|
if (blocks.length === 0) return null
|
|
46
48
|
const [only] = blocks
|
|
@@ -48,5 +50,5 @@ export function ParticipantMessageText({ text, labels }: ParticipantMessageTextP
|
|
|
48
50
|
blocks.length === 1 && only?.kind === 'paragraph'
|
|
49
51
|
? renderInlineNodes(only.children, labels)
|
|
50
52
|
: blocks.map((block, key) => renderBlock(block, labels, key))
|
|
51
|
-
return <div className="cv-p-text">{children}</div>
|
|
53
|
+
return <div className="cv-p-text" id={id}>{children}</div>
|
|
52
54
|
}
|
|
@@ -39,6 +39,7 @@ export function ParticipantProtocolBadgeView({
|
|
|
39
39
|
type="button"
|
|
40
40
|
className="cv-p-protocol__copy"
|
|
41
41
|
aria-label={`${labels.copyProtocol} ${protocol}`}
|
|
42
|
+
data-cv-tooltip={labels.copyProtocol}
|
|
42
43
|
onClick={onCopy}
|
|
43
44
|
>
|
|
44
45
|
{isCopied ? <Check size={18} aria-hidden={true} /> : <Copy size={18} aria-hidden={true} />}
|
|
@@ -23,7 +23,7 @@ export function ParticipantScrollToLatest({
|
|
|
23
23
|
if (!isVisible || onClick === undefined) return null
|
|
24
24
|
return (
|
|
25
25
|
<div className="cv-p-thread__latest">
|
|
26
|
-
<button type="button" className="cv-p-thread__latest-button" aria-label={label} onClick={(event) => {
|
|
26
|
+
<button type="button" className="cv-p-thread__latest-button" aria-label={label} data-cv-tooltip={label} onClick={(event) => {
|
|
27
27
|
focusScrollerOf(event.currentTarget)
|
|
28
28
|
onClick()
|
|
29
29
|
}}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useRef, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
8
|
+
import { TooltipLayer } from '../Tooltip'
|
|
8
9
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
|
+
import { useSuppressTouchTooltips } from './useSuppressTouchTooltips'
|
|
9
11
|
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
10
12
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
13
|
import type { ParticipantPendingActions } from './participantBubbleActions'
|
|
@@ -93,23 +95,31 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
93
95
|
)
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
99
|
+
return (
|
|
100
|
+
<ParticipantThreadHeader
|
|
101
|
+
conversation={props.conversation}
|
|
102
|
+
labels={props.labels}
|
|
103
|
+
subjectGroups={props.subjectGroups}
|
|
104
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
105
|
+
onBack={props.onBack}
|
|
106
|
+
onOpenSubject={props.onOpenSubject}
|
|
107
|
+
counterpartLabel={props.counterpartLabel}
|
|
108
|
+
actions={props.headerActions}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
function ThreadBody(props: ParticipantThreadProps) {
|
|
97
114
|
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
98
115
|
const subjectCard = renderSubjectCard?.(conversation)
|
|
99
116
|
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
117
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
118
|
+
useSuppressTouchTooltips(rootRef)
|
|
100
119
|
|
|
101
120
|
return (
|
|
102
|
-
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
103
|
-
<
|
|
104
|
-
conversation={conversation}
|
|
105
|
-
labels={labels}
|
|
106
|
-
subjectGroups={props.subjectGroups}
|
|
107
|
-
renderSubjectIcon={props.renderSubjectIcon}
|
|
108
|
-
onBack={props.onBack}
|
|
109
|
-
onOpenSubject={props.onOpenSubject}
|
|
110
|
-
counterpartLabel={props.counterpartLabel}
|
|
111
|
-
actions={props.headerActions}
|
|
112
|
-
/>
|
|
121
|
+
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading} ref={rootRef}>
|
|
122
|
+
<Header props={props} />
|
|
113
123
|
{subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
|
|
114
124
|
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
115
125
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
@@ -138,6 +148,7 @@ export function ParticipantThread(props: ParticipantThreadProps) {
|
|
|
138
148
|
return (
|
|
139
149
|
<ParticipantCopyAnnouncer>
|
|
140
150
|
<ThreadBody {...props} />
|
|
151
|
+
<TooltipLayer />
|
|
141
152
|
</ParticipantCopyAnnouncer>
|
|
142
153
|
)
|
|
143
154
|
}
|
|
@@ -102,7 +102,7 @@ describe('copy button as an icon', () => {
|
|
|
102
102
|
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
103
103
|
expect(button).toContain('<svg')
|
|
104
104
|
expect(button.replace(/<[^>]+>/g, '')).toBe('')
|
|
105
|
-
expect(markup.replace(/aria-label="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
105
|
+
expect(markup.replace(/(aria-label|data-cv-tooltip)="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
it('shows the short confirmation next to the icon while copied', () => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Dispatch, KeyboardEvent, RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { resolveFailedMenuKey, type FailedMenuAction } from './participantFailedMenuState'
|
|
4
|
+
|
|
5
|
+
type MenuKeyParams = {
|
|
6
|
+
readonly event: KeyboardEvent<HTMLElement>
|
|
7
|
+
readonly menuRef: RefObject<HTMLElement | null>
|
|
8
|
+
readonly triggerRef: RefObject<HTMLElement | null>
|
|
9
|
+
readonly dispatch: Dispatch<FailedMenuAction>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Escape is consumed here so it does not also close a host dialog or drawer around the thread. */
|
|
13
|
+
export function handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }: MenuKeyParams): void {
|
|
14
|
+
const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
|
|
15
|
+
const index = items.findIndex((item) => item === document.activeElement)
|
|
16
|
+
const result = resolveFailedMenuKey({ key: event.key, index, count: items.length })
|
|
17
|
+
if (!result) return
|
|
18
|
+
if (result.type === 'focus') {
|
|
19
|
+
event.preventDefault()
|
|
20
|
+
items[result.index]?.focus()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (result.shouldRestoreFocus) {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
triggerRef.current?.focus()
|
|
27
|
+
}
|
|
28
|
+
dispatch({ type: 'close' })
|
|
29
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
|
|
4
|
+
import { setupDom, pressKey, type DomHarness } from '../domHarness.test-helper'
|
|
5
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
|
|
7
|
+
|
|
8
|
+
let harness: DomHarness
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
harness = await setupDom()
|
|
12
|
+
})
|
|
13
|
+
afterAll(() => harness.teardown())
|
|
14
|
+
|
|
15
|
+
const noop = (): void => undefined
|
|
16
|
+
|
|
17
|
+
function mountMenu() {
|
|
18
|
+
const tree = harness.mount(
|
|
19
|
+
<div className="cv-p-thread__scroll">
|
|
20
|
+
<div className="cv-p-bubble">
|
|
21
|
+
<ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />
|
|
22
|
+
</div>
|
|
23
|
+
</div>,
|
|
24
|
+
)
|
|
25
|
+
const trigger = tree.container.querySelector<HTMLButtonElement>('.cv-p-failed-menu__trigger')!
|
|
26
|
+
return { ...tree, trigger }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function click(element: Element): void {
|
|
30
|
+
act(() => element.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function items(container: HTMLElement): HTMLElement[] {
|
|
34
|
+
return Array.from(container.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('failed menu in a DOM', () => {
|
|
38
|
+
it('opens on click, names the menu by the trigger and focuses the first item', () => {
|
|
39
|
+
const { container, trigger } = mountMenu()
|
|
40
|
+
click(trigger)
|
|
41
|
+
|
|
42
|
+
const menu = container.querySelector('[role="menu"]')!
|
|
43
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
44
|
+
expect(menu.getAttribute('aria-labelledby')).toBe(trigger.id)
|
|
45
|
+
expect(document.activeElement).toBe(items(container)[0])
|
|
46
|
+
expect(items(container).every((item) => item.tabIndex === -1)).toBe(true)
|
|
47
|
+
expect(trigger.hasAttribute('data-cv-tooltip')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('ArrowUp on the trigger opens the menu on the last item', () => {
|
|
51
|
+
const { container, trigger } = mountMenu()
|
|
52
|
+
act(() => trigger.focus())
|
|
53
|
+
pressKey(trigger, 'ArrowUp')
|
|
54
|
+
|
|
55
|
+
expect(document.activeElement).toBe(items(container)[1])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('Escape closes, gives the focus back to the trigger and does not reach the host', () => {
|
|
59
|
+
const { container, trigger } = mountMenu()
|
|
60
|
+
click(trigger)
|
|
61
|
+
let hostSawEscape = false
|
|
62
|
+
const hostListener = (): void => void (hostSawEscape = true)
|
|
63
|
+
document.addEventListener('keydown', hostListener)
|
|
64
|
+
|
|
65
|
+
pressKey(items(container)[0]!, 'Escape')
|
|
66
|
+
document.removeEventListener('keydown', hostListener)
|
|
67
|
+
|
|
68
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
69
|
+
expect(document.activeElement).toBe(trigger)
|
|
70
|
+
expect(hostSawEscape).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('Tab closes the menu without pulling the focus back to the trigger', () => {
|
|
74
|
+
const { container, trigger } = mountMenu()
|
|
75
|
+
click(trigger)
|
|
76
|
+
pressKey(items(container)[0]!, 'Tab')
|
|
77
|
+
|
|
78
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
79
|
+
expect(document.activeElement).not.toBe(trigger)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('closes on a pointer press outside and keeps open on one inside', () => {
|
|
83
|
+
const { container, trigger } = mountMenu()
|
|
84
|
+
click(trigger)
|
|
85
|
+
act(() => void items(container)[0]!.dispatchEvent(new Event('pointerdown', { bubbles: true })))
|
|
86
|
+
expect(container.querySelector('[role="menu"]')).not.toBeNull()
|
|
87
|
+
|
|
88
|
+
act(() => void document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })))
|
|
89
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('removes its document listener when unmounted with the menu open', () => {
|
|
93
|
+
const added: string[] = []
|
|
94
|
+
const removed: string[] = []
|
|
95
|
+
const add = document.addEventListener.bind(document)
|
|
96
|
+
const remove = document.removeEventListener.bind(document)
|
|
97
|
+
document.addEventListener = ((type: string, ...rest: unknown[]) => {
|
|
98
|
+
added.push(type)
|
|
99
|
+
return (add as (...args: unknown[]) => void)(type, ...rest)
|
|
100
|
+
}) as typeof document.addEventListener
|
|
101
|
+
document.removeEventListener = ((type: string, ...rest: unknown[]) => {
|
|
102
|
+
removed.push(type)
|
|
103
|
+
return (remove as (...args: unknown[]) => void)(type, ...rest)
|
|
104
|
+
}) as typeof document.removeEventListener
|
|
105
|
+
|
|
106
|
+
const { unmount, trigger } = mountMenu()
|
|
107
|
+
click(trigger)
|
|
108
|
+
unmount()
|
|
109
|
+
document.addEventListener = add
|
|
110
|
+
document.removeEventListener = remove
|
|
111
|
+
|
|
112
|
+
expect(added.filter((type) => type === 'pointerdown')).toHaveLength(1)
|
|
113
|
+
expect(removed.filter((type) => type === 'pointerdown')).toHaveLength(1)
|
|
114
|
+
expect(() => document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))).not.toThrow()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('failed menu placement in a DOM', () => {
|
|
119
|
+
let original: typeof Element.prototype.getBoundingClientRect
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
original = Element.prototype.getBoundingClientRect
|
|
123
|
+
})
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
Element.prototype.getBoundingClientRect = original
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
function stubLayout(bubbleTop: number): void {
|
|
129
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
130
|
+
const top = this.classList.contains('cv-p-thread__scroll') ? 100 : this.classList.contains('cv-p-bubble') ? bubbleTop : 0
|
|
131
|
+
const height = this.getAttribute('role') === 'menu' ? 90 : 20
|
|
132
|
+
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('flips below when the bubble is the first of the conversation', () => {
|
|
137
|
+
stubLayout(108)
|
|
138
|
+
const { container, trigger } = mountMenu()
|
|
139
|
+
click(trigger)
|
|
140
|
+
Element.prototype.getBoundingClientRect = original
|
|
141
|
+
|
|
142
|
+
expect(container.querySelector('.cv-p-failed-menu__list--below')).not.toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('stays above when there is room', () => {
|
|
146
|
+
stubLayout(500)
|
|
147
|
+
const { container, trigger } = mountMenu()
|
|
148
|
+
click(trigger)
|
|
149
|
+
Element.prototype.getBoundingClientRect = original
|
|
150
|
+
|
|
151
|
+
expect(container.querySelector('[role="menu"]')).not.toBeNull()
|
|
152
|
+
expect(container.querySelector('.cv-p-failed-menu__list--below')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CLOSED_FAILED_MENU,
|
|
5
|
+
failedMenuReducer,
|
|
6
|
+
resolveFailedMenuKey,
|
|
7
|
+
resolveFailedMenuPlacement,
|
|
8
|
+
resolveFailedMenuTriggerKey,
|
|
9
|
+
} from './participantFailedMenuState'
|
|
10
|
+
|
|
11
|
+
describe('failed menu state', () => {
|
|
12
|
+
it('toggles open on first focus item and closes again', () => {
|
|
13
|
+
const open = failedMenuReducer(CLOSED_FAILED_MENU, { type: 'toggle' })
|
|
14
|
+
expect(open).toEqual({ isOpen: true, initialFocus: 'first' })
|
|
15
|
+
expect(failedMenuReducer(open, { type: 'toggle' })).toEqual(CLOSED_FAILED_MENU)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('opens on the last item when asked and ignores a close while closed', () => {
|
|
19
|
+
expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'open', initialFocus: 'last' }).initialFocus).toBe('last')
|
|
20
|
+
expect(failedMenuReducer(CLOSED_FAILED_MENU, { type: 'close' })).toBe(CLOSED_FAILED_MENU)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('failed menu keyboard', () => {
|
|
25
|
+
it('Escape closes and gives the focus back to the trigger', () => {
|
|
26
|
+
expect(resolveFailedMenuKey({ key: 'Escape', index: 0, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('Tab closes without trapping: focus is not pulled back', () => {
|
|
30
|
+
expect(resolveFailedMenuKey({ key: 'Tab', index: 1, count: 2 })).toEqual({ type: 'close', shouldRestoreFocus: false })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('arrows wrap around, Home and End jump', () => {
|
|
34
|
+
expect(resolveFailedMenuKey({ key: 'ArrowDown', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
|
|
35
|
+
expect(resolveFailedMenuKey({ key: 'ArrowUp', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
|
|
36
|
+
expect(resolveFailedMenuKey({ key: 'Home', index: 1, count: 2 })).toEqual({ type: 'focus', index: 0 })
|
|
37
|
+
expect(resolveFailedMenuKey({ key: 'End', index: 0, count: 2 })).toEqual({ type: 'focus', index: 1 })
|
|
38
|
+
expect(resolveFailedMenuKey({ key: 'a', index: 0, count: 2 })).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('ArrowDown and ArrowUp open the menu from the trigger on the matching end', () => {
|
|
42
|
+
expect(resolveFailedMenuTriggerKey('ArrowDown')).toBe('first')
|
|
43
|
+
expect(resolveFailedMenuTriggerKey('ArrowUp')).toBe('last')
|
|
44
|
+
expect(resolveFailedMenuTriggerKey('Enter')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('failed menu placement', () => {
|
|
49
|
+
it('opens above when the scroller leaves room for the menu', () => {
|
|
50
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 300, scrollerTop: 100, menuHeight: 90 })).toBe('above')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('flips below for the first bubble of the conversation, where the scroller would clip it', () => {
|
|
54
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 112, scrollerTop: 100, menuHeight: 90 })).toBe('below')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('needs the gap too: room for exactly the menu is not enough', () => {
|
|
58
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 190, scrollerTop: 100, menuHeight: 90 })).toBe('below')
|
|
59
|
+
expect(resolveFailedMenuPlacement({ anchorTop: 198, scrollerTop: 100, menuHeight: 90 })).toBe('above')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type FailedMenuFocus = 'first' | 'last'
|
|
2
|
+
|
|
3
|
+
export type FailedMenuState = { readonly isOpen: boolean; readonly initialFocus: FailedMenuFocus }
|
|
4
|
+
|
|
5
|
+
export type FailedMenuAction =
|
|
6
|
+
| { readonly type: 'open'; readonly initialFocus: FailedMenuFocus }
|
|
7
|
+
| { readonly type: 'close' }
|
|
8
|
+
| { readonly type: 'toggle' }
|
|
9
|
+
|
|
10
|
+
export const CLOSED_FAILED_MENU: FailedMenuState = { isOpen: false, initialFocus: 'first' }
|
|
11
|
+
|
|
12
|
+
export function failedMenuReducer(state: FailedMenuState, action: FailedMenuAction): FailedMenuState {
|
|
13
|
+
if (action.type === 'open') return { isOpen: true, initialFocus: action.initialFocus }
|
|
14
|
+
if (action.type === 'close') return state.isOpen ? CLOSED_FAILED_MENU : state
|
|
15
|
+
return state.isOpen ? CLOSED_FAILED_MENU : { isOpen: true, initialFocus: 'first' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FailedMenuKeyResult =
|
|
19
|
+
| { readonly type: 'focus'; readonly index: number }
|
|
20
|
+
| { readonly type: 'close'; readonly shouldRestoreFocus: boolean }
|
|
21
|
+
|
|
22
|
+
type MenuKeyParams = { readonly key: string; readonly index: number; readonly count: number }
|
|
23
|
+
|
|
24
|
+
/** Roving focus inside the open menu; Tab is never trapped, it closes the menu and lets focus move on. */
|
|
25
|
+
export function resolveFailedMenuKey({ key, index, count }: MenuKeyParams): FailedMenuKeyResult | undefined {
|
|
26
|
+
if (key === 'Escape') return { type: 'close', shouldRestoreFocus: true }
|
|
27
|
+
if (key === 'Tab') return { type: 'close', shouldRestoreFocus: false }
|
|
28
|
+
if (key === 'ArrowDown') return { type: 'focus', index: (index + 1) % count }
|
|
29
|
+
if (key === 'ArrowUp') return { type: 'focus', index: (index - 1 + count) % count }
|
|
30
|
+
if (key === 'Home') return { type: 'focus', index: 0 }
|
|
31
|
+
if (key === 'End') return { type: 'focus', index: count - 1 }
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Keys that open the menu from its trigger; Enter and Space already open it through the click. */
|
|
36
|
+
export function resolveFailedMenuTriggerKey(key: string): FailedMenuFocus | undefined {
|
|
37
|
+
if (key === 'ArrowDown') return 'first'
|
|
38
|
+
if (key === 'ArrowUp') return 'last'
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FailedMenuPlacement = 'above' | 'below'
|
|
43
|
+
|
|
44
|
+
const MENU_GAP_PX = 8
|
|
45
|
+
|
|
46
|
+
type MenuPlacementParams = {
|
|
47
|
+
/** Top edge of the bubble the menu hangs from. */
|
|
48
|
+
readonly anchorTop: number
|
|
49
|
+
/** Top edge of the scroller that clips the menu. */
|
|
50
|
+
readonly scrollerTop: number
|
|
51
|
+
readonly menuHeight: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The menu opens above its bubble; it flips below when the scroller would clip it. */
|
|
55
|
+
export function resolveFailedMenuPlacement({ anchorTop, scrollerTop, menuHeight }: MenuPlacementParams): FailedMenuPlacement {
|
|
56
|
+
return anchorTop - scrollerTop >= menuHeight + MENU_GAP_PX ? 'above' : 'below'
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { OwnStatus, RetryButton } from './ParticipantBubbleStatus'
|
|
5
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
6
|
+
import { declarationsIn } from './participantCss.test-helper'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
|
|
8
|
+
|
|
9
|
+
const noop = (): void => undefined
|
|
10
|
+
|
|
11
|
+
describe('failed message: markup', () => {
|
|
12
|
+
it('puts retry in a round icon button with the long text as its name, no visible text', () => {
|
|
13
|
+
const markup = renderToStaticMarkup(<RetryButton labels={LABELS} onRetry={noop} />)
|
|
14
|
+
|
|
15
|
+
expect(markup).toContain('aria-label="Failed — tap to retry"')
|
|
16
|
+
expect(markup).toContain('aria-hidden="true"')
|
|
17
|
+
expect(markup.replace(/<svg.*<\/svg>/, '')).not.toMatch(/>[A-Za-z]/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps the meta of a failed bubble to the red icon, with the short text for screen readers only', () => {
|
|
21
|
+
const withRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} onRetry={noop} />)
|
|
22
|
+
const withoutRetry = renderToStaticMarkup(<OwnStatus status="failed" labels={LABELS} />)
|
|
23
|
+
|
|
24
|
+
expect(withRetry).not.toContain('<button')
|
|
25
|
+
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
26
|
+
expect(withRetry).toContain('cv-p-sr-only">Failed<')
|
|
27
|
+
expect(withoutRetry).toContain('cv-p-bubble__status-text">Failed<')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders a closed menu trigger with the menu button semantics, and no menu without handlers', () => {
|
|
31
|
+
const markup = renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />)
|
|
32
|
+
|
|
33
|
+
expect(markup).toContain('aria-haspopup="menu"')
|
|
34
|
+
expect(markup).toContain('aria-expanded="false"')
|
|
35
|
+
expect(markup).toContain('data-cv-tooltip="Message options"')
|
|
36
|
+
expect(markup).toContain('aria-label="Message options"')
|
|
37
|
+
expect(markup).not.toContain('role="menu"')
|
|
38
|
+
expect(renderToStaticMarkup(<ParticipantFailedMenu labels={LABELS} />)).toBe('')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('failed message: styles', () => {
|
|
43
|
+
it('hangs the retry button beside the bubble: 44px target, 2rem circle, no layout space taken', () => {
|
|
44
|
+
const button = declarationsIn('.cv-p-failed-retry')
|
|
45
|
+
const icon = declarationsIn('.cv-p-failed-retry__icon')
|
|
46
|
+
|
|
47
|
+
expect(button.get('position')).toBe('absolute')
|
|
48
|
+
expect(button.get('right')).toContain('100%')
|
|
49
|
+
expect(button.get('width')).toBe('var(--cv-p-i-touch)')
|
|
50
|
+
expect(button.get('height')).toBe('var(--cv-p-i-touch)')
|
|
51
|
+
expect(icon.get('width')).toBe('2rem')
|
|
52
|
+
expect(icon.get('border-radius')).toBe('50%')
|
|
53
|
+
expect(button.get('color')).toBe('var(--cv-p-i-danger)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps the bubble cap of 85% by not wrapping the bubble in a row', () => {
|
|
57
|
+
expect(declarationsIn('.cv-p-bubble-row--failed').size).toBe(0)
|
|
58
|
+
expect(declarationsIn('.cv-p-bubble').get('max-width')).toBe('85%')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('flips the menu below the bubble with a modifier', () => {
|
|
62
|
+
expect(declarationsIn('.cv-p-failed-menu__list--below').get('top')).toContain('100%')
|
|
63
|
+
expect(declarationsIn('.cv-p-failed-menu__list--below').get('bottom')).toBe('auto')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('draws the menu with square corners, text and danger tokens, 44px items', () => {
|
|
67
|
+
expect(declarationsIn('.cv-p-failed-menu__list').get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
68
|
+
expect(declarationsIn('.cv-p-failed-menu__list').get('bottom')).toContain('100%')
|
|
69
|
+
expect(declarationsIn('.cv-p-failed-menu__item').get('color')).toBe('var(--cv-p-i-text)')
|
|
70
|
+
expect(declarationsIn('.cv-p-failed-menu__item').get('min-height')).toBe('var(--cv-p-i-touch)')
|
|
71
|
+
expect(declarationsIn('.cv-p-failed-menu__item--danger').get('color')).toBe('var(--cv-p-i-danger)')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps the trigger hit area inside the bubble padding (0.5rem), never into other rows', () => {
|
|
75
|
+
expect(declarationsIn('.cv-p-failed-menu__trigger::after').get('inset')).toBe('-0.5rem -0.4rem')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('has no leftover action-row or bordered-retry rules', () => {
|
|
79
|
+
for (const selector of ['.cv-p-bubble__retry', '.cv-p-bubble__action', '.cv-p-bubble__actions']) {
|
|
80
|
+
expect(declarationsIn(selector).size).toBe(0)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
|
|
2
|
+
|
|
1
3
|
export type ParticipantConversationsLabels = {
|
|
2
4
|
readonly inboxTitle: string
|
|
3
5
|
readonly sectionAwaiting: string
|
|
@@ -33,9 +35,12 @@ export type ParticipantConversationsLabels = {
|
|
|
33
35
|
readonly statusRead: string
|
|
34
36
|
readonly statusFailed: string
|
|
35
37
|
readonly statusFailedShort: string
|
|
38
|
+
/** @deprecated No longer rendered by the default view: the retry button is icon-only and named by `statusFailed`. */
|
|
36
39
|
readonly retry: string
|
|
37
40
|
readonly discard: string
|
|
38
41
|
readonly edit: string
|
|
42
|
+
/** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
|
|
43
|
+
readonly messageOptions?: string
|
|
39
44
|
readonly notFound: string
|
|
40
45
|
readonly loading: string
|
|
41
46
|
readonly loadError: string
|
|
@@ -98,6 +103,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
|
|
|
98
103
|
retry: 'Retry',
|
|
99
104
|
discard: 'Discard',
|
|
100
105
|
edit: 'Edit',
|
|
106
|
+
messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
|
|
101
107
|
notFound: 'Conversation not found',
|
|
102
108
|
loading: 'Loading…',
|
|
103
109
|
loadError: 'Could not load. Check your connection and try again.',
|