@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
package/dist/styles.css
CHANGED
|
@@ -1382,8 +1382,8 @@
|
|
|
1382
1382
|
.cv-p-chip,
|
|
1383
1383
|
.cv-p-row,
|
|
1384
1384
|
.cv-p-thread__older,
|
|
1385
|
-
.cv-p-
|
|
1386
|
-
.cv-p-
|
|
1385
|
+
.cv-p-failed-menu__trigger,
|
|
1386
|
+
.cv-p-failed-menu__item {
|
|
1387
1387
|
font: inherit;
|
|
1388
1388
|
color: inherit;
|
|
1389
1389
|
cursor: pointer;
|
|
@@ -1970,8 +1970,7 @@
|
|
|
1970
1970
|
min-width: 0;
|
|
1971
1971
|
margin-inline-end: auto;
|
|
1972
1972
|
}
|
|
1973
|
-
.cv-p-bubble > .cv-p-attachment
|
|
1974
|
-
.cv-p-bubble > .cv-p-bubble__actions {
|
|
1973
|
+
.cv-p-bubble > .cv-p-attachment {
|
|
1975
1974
|
flex: 1 0 100%;
|
|
1976
1975
|
}
|
|
1977
1976
|
.cv-p-bubble__meta {
|
|
@@ -2004,33 +2003,103 @@
|
|
|
2004
2003
|
.cv-p-bubble .cv-status-ticks--failed {
|
|
2005
2004
|
color: var(--cv-p-i-danger);
|
|
2006
2005
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2006
|
+
@supports (color: color-mix(in srgb, red, blue)) {
|
|
2007
|
+
.cv-p-bubble--failed {
|
|
2008
|
+
border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border));
|
|
2009
|
+
--cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border));
|
|
2010
|
+
}
|
|
2011
|
+
.cv-p-bubble--mine.cv-p-bubble--failed {
|
|
2012
|
+
border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent));
|
|
2013
|
+
--cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent));
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
.cv-p-failed-retry {
|
|
2017
|
+
position: absolute;
|
|
2018
|
+
top: 50%;
|
|
2019
|
+
right: calc(100% + 0.125rem);
|
|
2020
|
+
transform: translateY(-50%);
|
|
2021
|
+
display: grid;
|
|
2022
|
+
place-items: center;
|
|
2023
|
+
width: var(--cv-p-i-touch);
|
|
2024
|
+
height: var(--cv-p-i-touch);
|
|
2025
|
+
padding: 0;
|
|
2013
2026
|
background: transparent;
|
|
2014
|
-
border:
|
|
2027
|
+
border: 0;
|
|
2015
2028
|
color: var(--cv-p-i-danger);
|
|
2016
|
-
|
|
2017
|
-
|
|
2029
|
+
font: inherit;
|
|
2030
|
+
cursor: pointer;
|
|
2018
2031
|
}
|
|
2019
|
-
.cv-p-
|
|
2020
|
-
display:
|
|
2021
|
-
|
|
2022
|
-
|
|
2032
|
+
.cv-p-failed-retry__icon {
|
|
2033
|
+
display: grid;
|
|
2034
|
+
place-items: center;
|
|
2035
|
+
width: 2rem;
|
|
2036
|
+
height: 2rem;
|
|
2037
|
+
border: 1px solid var(--cv-p-i-danger);
|
|
2038
|
+
border-radius: 50%;
|
|
2023
2039
|
}
|
|
2024
|
-
.cv-p-
|
|
2025
|
-
|
|
2040
|
+
.cv-p-failed-retry:hover .cv-p-failed-retry__icon,
|
|
2041
|
+
.cv-p-failed-retry:focus-visible .cv-p-failed-retry__icon {
|
|
2042
|
+
background: var(--cv-p-i-surface-raised);
|
|
2043
|
+
}
|
|
2044
|
+
.cv-p-failed-menu {
|
|
2026
2045
|
display: inline-flex;
|
|
2027
|
-
|
|
2028
|
-
|
|
2046
|
+
}
|
|
2047
|
+
.cv-p-failed-menu__trigger {
|
|
2048
|
+
position: relative;
|
|
2049
|
+
display: inline-grid;
|
|
2050
|
+
place-items: center;
|
|
2051
|
+
min-width: 1.25rem;
|
|
2052
|
+
height: 0.75rem;
|
|
2053
|
+
padding: 0;
|
|
2029
2054
|
background: transparent;
|
|
2055
|
+
border: 0;
|
|
2056
|
+
color: var(--cv-p-i-text-muted);
|
|
2057
|
+
font: inherit;
|
|
2058
|
+
line-height: 0.75rem;
|
|
2059
|
+
cursor: pointer;
|
|
2060
|
+
}
|
|
2061
|
+
.cv-p-failed-menu__trigger::after {
|
|
2062
|
+
content: "";
|
|
2063
|
+
position: absolute;
|
|
2064
|
+
inset: -0.5rem -0.4rem;
|
|
2065
|
+
}
|
|
2066
|
+
.cv-p-failed-menu__list {
|
|
2067
|
+
position: absolute;
|
|
2068
|
+
right: 0;
|
|
2069
|
+
bottom: calc(100% + 0.25rem);
|
|
2070
|
+
z-index: 2;
|
|
2071
|
+
display: flex;
|
|
2072
|
+
flex-direction: column;
|
|
2073
|
+
min-width: 8rem;
|
|
2074
|
+
background: var(--cv-p-i-surface-raised);
|
|
2030
2075
|
border: 1px solid var(--cv-p-i-border);
|
|
2031
|
-
color: inherit;
|
|
2032
2076
|
border-radius: var(--cv-p-i-radius);
|
|
2033
|
-
|
|
2077
|
+
}
|
|
2078
|
+
.cv-p-failed-menu__item {
|
|
2079
|
+
min-height: var(--cv-p-i-touch);
|
|
2080
|
+
padding: 0 0.75rem;
|
|
2081
|
+
background: transparent;
|
|
2082
|
+
border: 0;
|
|
2083
|
+
color: var(--cv-p-i-text);
|
|
2084
|
+
font:
|
|
2085
|
+
0.7rem "SFMono-Regular",
|
|
2086
|
+
Consolas,
|
|
2087
|
+
monospace;
|
|
2088
|
+
text-align: start;
|
|
2089
|
+
text-transform: uppercase;
|
|
2090
|
+
letter-spacing: 0.04em;
|
|
2091
|
+
cursor: pointer;
|
|
2092
|
+
}
|
|
2093
|
+
.cv-p-failed-menu__list--below {
|
|
2094
|
+
bottom: auto;
|
|
2095
|
+
top: calc(100% + 0.25rem);
|
|
2096
|
+
}
|
|
2097
|
+
.cv-p-failed-menu__item--danger {
|
|
2098
|
+
color: var(--cv-p-i-danger);
|
|
2099
|
+
}
|
|
2100
|
+
.cv-p-failed-menu__item:hover,
|
|
2101
|
+
.cv-p-failed-menu__item:focus-visible {
|
|
2102
|
+
background: var(--cv-p-i-highlight);
|
|
2034
2103
|
}
|
|
2035
2104
|
.cv-p-attachment {
|
|
2036
2105
|
display: flex;
|
|
@@ -2529,6 +2598,13 @@
|
|
|
2529
2598
|
.cv-p-copyable__done {
|
|
2530
2599
|
color: CanvasText;
|
|
2531
2600
|
}
|
|
2601
|
+
.cv-p-failed-menu__list {
|
|
2602
|
+
border-color: CanvasText;
|
|
2603
|
+
}
|
|
2604
|
+
.cv-p-failed-menu__item:focus-visible,
|
|
2605
|
+
.cv-p-failed-menu__item:hover {
|
|
2606
|
+
outline: 2px solid Highlight;
|
|
2607
|
+
}
|
|
2532
2608
|
}
|
|
2533
2609
|
@media (forced-colors: active) {
|
|
2534
2610
|
.cv-p-text__code,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
+
"@happy-dom/global-registrator": "^18.0.1",
|
|
52
53
|
"@types/bun": "1.3.14",
|
|
53
54
|
"@types/react": "^18 || ^19",
|
|
54
55
|
"@types/react-dom": "^18 || ^19",
|
package/src/Tooltip.tsx
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { useEffect, useRef, useState } from 'react'
|
|
15
15
|
import { createPortal } from 'react-dom'
|
|
16
16
|
|
|
17
|
+
import { createTooltipLayerRegistry } from './tooltipLayerRegistry'
|
|
18
|
+
|
|
17
19
|
/** Atributo que marca um elemento como portador de dica. */
|
|
18
20
|
export const TOOLTIP_ATTRIBUTE = 'data-cv-tooltip'
|
|
19
21
|
|
|
@@ -64,25 +66,16 @@ function tooltipStateFor(target: Element, text: string): TooltipState {
|
|
|
64
66
|
/**
|
|
65
67
|
* Só uma camada desenha por vez. A camada é montada por cada superfície do pacote (inbox, fluxos,
|
|
66
68
|
* documentos) para o host não precisar montá-la à mão; duas superfícies na mesma tela renderizariam
|
|
67
|
-
* dois balões sobrepostos sem essa trava.
|
|
69
|
+
* dois balões sobrepostos sem essa trava. Quando a dona desmonta, a posse passa à próxima montada.
|
|
68
70
|
*/
|
|
69
|
-
|
|
71
|
+
const layerRegistry = createTooltipLayerRegistry()
|
|
70
72
|
|
|
71
73
|
export function TooltipLayer() {
|
|
72
74
|
const identity = useRef({})
|
|
73
75
|
const [isOwner, setIsOwner] = useState(false)
|
|
74
76
|
const [tooltip, setTooltip] = useState<TooltipState | undefined>(undefined)
|
|
75
77
|
|
|
76
|
-
useEffect(() =>
|
|
77
|
-
const owned = identity.current
|
|
78
|
-
if (layerOwner && layerOwner !== owned) return undefined
|
|
79
|
-
layerOwner = owned
|
|
80
|
-
setIsOwner(true)
|
|
81
|
-
|
|
82
|
-
return () => {
|
|
83
|
-
if (layerOwner === owned) layerOwner = undefined
|
|
84
|
-
}
|
|
85
|
-
}, [])
|
|
78
|
+
useEffect(() => layerRegistry.register(identity.current, setIsOwner), [])
|
|
86
79
|
|
|
87
80
|
useEffect(() => {
|
|
88
81
|
if (!isOwner) return undefined
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { act, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import { setupDom, wait, type DomHarness } from './domHarness.test-helper'
|
|
5
|
+
import { TooltipLayer } from './Tooltip'
|
|
6
|
+
import { useSuppressTouchTooltips } from './participant/useSuppressTouchTooltips'
|
|
7
|
+
|
|
8
|
+
let harness: DomHarness
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
harness = await setupDom()
|
|
12
|
+
})
|
|
13
|
+
afterAll(() => harness.teardown())
|
|
14
|
+
|
|
15
|
+
function hover(target: Element): void {
|
|
16
|
+
act(() => void target.dispatchEvent(new Event('pointerover', { bubbles: true })))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function balloons(): number {
|
|
20
|
+
return document.body.querySelectorAll('[role="tooltip"]').length
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Target() {
|
|
24
|
+
return <button type="button" data-cv-tooltip="Hint">x</button>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('TooltipLayer ownership', () => {
|
|
28
|
+
it('draws one balloon with two layers, and keeps drawing after the owner unmounts', async () => {
|
|
29
|
+
const first = harness.mount(<TooltipLayer />)
|
|
30
|
+
harness.mount(<TooltipLayer />)
|
|
31
|
+
const target = harness.mount(<Target />).container.querySelector('button')!
|
|
32
|
+
|
|
33
|
+
hover(target)
|
|
34
|
+
await wait(200)
|
|
35
|
+
expect(balloons()).toBe(1)
|
|
36
|
+
|
|
37
|
+
first.unmount()
|
|
38
|
+
act(() => void target.dispatchEvent(new Event('pointerdown', { bubbles: true })))
|
|
39
|
+
hover(target)
|
|
40
|
+
await wait(200)
|
|
41
|
+
expect(balloons()).toBe(1)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function Thread() {
|
|
46
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
47
|
+
useSuppressTouchTooltips(rootRef)
|
|
48
|
+
return (
|
|
49
|
+
<div ref={rootRef}>
|
|
50
|
+
<Target />
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('touch on the participant thread', () => {
|
|
56
|
+
it('does not light the balloon when a tap focuses the button', async () => {
|
|
57
|
+
harness.mount(<TooltipLayer />)
|
|
58
|
+
const button = harness.mount(<Thread />).container.querySelector('button')!
|
|
59
|
+
|
|
60
|
+
act(() => void button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' })))
|
|
61
|
+
act(() => void button.dispatchEvent(new Event('focusin', { bubbles: true })))
|
|
62
|
+
await wait(200)
|
|
63
|
+
|
|
64
|
+
expect(balloons()).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('still shows it for keyboard focus after a touch', async () => {
|
|
68
|
+
harness.mount(<TooltipLayer />)
|
|
69
|
+
const button = harness.mount(<Thread />).container.querySelector('button')!
|
|
70
|
+
|
|
71
|
+
act(() => void button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' })))
|
|
72
|
+
act(() => void button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })))
|
|
73
|
+
act(() => void button.dispatchEvent(new Event('focusin', { bubbles: true })))
|
|
74
|
+
await wait(200)
|
|
75
|
+
|
|
76
|
+
expect(balloons()).toBe(1)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator'
|
|
2
|
+
import { act, type ReactElement } from 'react'
|
|
3
|
+
import type { Root } from 'react-dom/client'
|
|
4
|
+
|
|
5
|
+
export type MountedTree = {
|
|
6
|
+
readonly container: HTMLElement
|
|
7
|
+
readonly unmount: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type DomHarness = {
|
|
11
|
+
readonly mount: (element: ReactElement) => MountedTree
|
|
12
|
+
readonly teardown: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers happy-dom for one test file only and mounts React trees into it. react-dom/client is
|
|
17
|
+
* imported after the registration because it reads the DOM globals when it loads.
|
|
18
|
+
*/
|
|
19
|
+
export async function setupDom(): Promise<DomHarness> {
|
|
20
|
+
GlobalRegistrator.register()
|
|
21
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
22
|
+
const { createRoot } = await import('react-dom/client')
|
|
23
|
+
const roots: Root[] = []
|
|
24
|
+
|
|
25
|
+
function mount(element: ReactElement): MountedTree {
|
|
26
|
+
const container = document.createElement('div')
|
|
27
|
+
document.body.appendChild(container)
|
|
28
|
+
const root = createRoot(container)
|
|
29
|
+
roots.push(root)
|
|
30
|
+
act(() => root.render(element))
|
|
31
|
+
return { container, unmount: () => act(() => root.unmount()) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function teardown(): void {
|
|
35
|
+
for (const root of roots) act(() => root.unmount())
|
|
36
|
+
document.body.innerHTML = ''
|
|
37
|
+
GlobalRegistrator.unregister()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { mount, teardown }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pressKey(target: Element, key: string): void {
|
|
44
|
+
act(() => {
|
|
45
|
+
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function wait(milliseconds: number): Promise<void> {
|
|
50
|
+
return act(async () => {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -7,7 +7,7 @@ export type ParticipantBackButtonProps = {
|
|
|
7
7
|
|
|
8
8
|
export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
|
|
9
9
|
return (
|
|
10
|
-
<button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
|
|
10
|
+
<button type="button" className="cv-p-thread__back" aria-label={label} data-cv-tooltip={label} onClick={onBack}>
|
|
11
11
|
<ArrowLeft size={24} aria-hidden="true" focusable="false" />
|
|
12
12
|
</button>
|
|
13
13
|
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatTimestamp } from '../lib/format'
|
|
2
|
+
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
3
|
+
import { OwnStatus } from './ParticipantBubbleStatus'
|
|
4
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
5
|
+
import type { BubbleContent } from './ParticipantMessageBubble'
|
|
6
|
+
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
7
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
8
|
+
import type { ParticipantOwnMessageStatus } from './participantMessages'
|
|
9
|
+
|
|
10
|
+
type BubbleBodyProps = {
|
|
11
|
+
readonly content: BubbleContent
|
|
12
|
+
readonly labels: ParticipantConversationsLabels
|
|
13
|
+
readonly ownStatus?: ParticipantOwnMessageStatus
|
|
14
|
+
readonly textId?: string
|
|
15
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
16
|
+
readonly onRetry?: () => void
|
|
17
|
+
readonly onDiscard?: () => void
|
|
18
|
+
readonly onEdit?: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
|
|
22
|
+
if (content.isMine) return <span className="cv-p-sr-only">{labels.me}</span>
|
|
23
|
+
if (!content.authorName) return null
|
|
24
|
+
return <span className="cv-p-bubble__author">{content.authorName}</span>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<BubbleHeading content={content} labels={labels} />
|
|
31
|
+
{content.text ? <ParticipantMessageText text={content.text} labels={labels} id={textId} /> : null}
|
|
32
|
+
{content.attachments.map((attachment) => (
|
|
33
|
+
<ParticipantAttachmentItem
|
|
34
|
+
key={attachment.id}
|
|
35
|
+
attachment={attachment}
|
|
36
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
37
|
+
labels={labels}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
{content.pendingFilenames.map((filename, index) => (
|
|
41
|
+
<span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
|
|
42
|
+
{filename}
|
|
43
|
+
</span>
|
|
44
|
+
))}
|
|
45
|
+
<span className="cv-p-bubble__meta">
|
|
46
|
+
<time className="cv-p-bubble__time" dateTime={content.createdAt}>
|
|
47
|
+
{formatTimestamp(content.createdAt)}
|
|
48
|
+
</time>
|
|
49
|
+
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
50
|
+
{ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
|
|
51
|
+
</span>
|
|
52
|
+
</>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -24,44 +24,38 @@ export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
|
24
24
|
const ticksStatus = status === 'sending' ? 'queued' : status
|
|
25
25
|
const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
|
|
26
26
|
|
|
27
|
-
if (status === 'failed' && onRetry) {
|
|
28
|
-
return (
|
|
29
|
-
<button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
|
|
30
|
-
<StatusTicks status="failed" appearance="stylesheet" />
|
|
31
|
-
<span>{labels.statusFailed}</span>
|
|
32
|
-
</button>
|
|
33
|
-
)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
|
|
28
|
+
const isTextVisible = isVisibleText && !(status === 'failed' && onRetry)
|
|
37
29
|
return (
|
|
38
30
|
<span className="cv-p-bubble__status">
|
|
39
|
-
<StatusTicks status={ticksStatus} appearance="stylesheet" />
|
|
40
|
-
<span className={
|
|
31
|
+
<StatusTicks status={ticksStatus} appearance="stylesheet" title={isTextVisible ? undefined : text} />
|
|
32
|
+
<span className={isTextVisible ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
|
|
41
33
|
</span>
|
|
42
34
|
)
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
type
|
|
37
|
+
type RetryButtonProps = {
|
|
46
38
|
readonly labels: ParticipantConversationsLabels
|
|
47
|
-
readonly
|
|
48
|
-
|
|
39
|
+
readonly onRetry: () => void
|
|
40
|
+
/** Id of the message text, read after the name so the button says which message it resends. */
|
|
41
|
+
readonly describedBy?: string
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
export function
|
|
52
|
-
if (!onDiscard && !onEdit) return null
|
|
44
|
+
export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
|
|
53
45
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="cv-p-failed-retry"
|
|
49
|
+
aria-label={labels.statusFailed}
|
|
50
|
+
aria-describedby={describedBy}
|
|
51
|
+
data-cv-tooltip={labels.statusFailed}
|
|
52
|
+
onClick={onRetry}
|
|
53
|
+
>
|
|
54
|
+
<span className="cv-p-failed-retry__icon">
|
|
55
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
|
56
|
+
<path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
57
|
+
</svg>
|
|
58
|
+
</span>
|
|
59
|
+
</button>
|
|
66
60
|
)
|
|
67
61
|
}
|
|
@@ -59,7 +59,11 @@ export function ParticipantChannelBadges({
|
|
|
59
59
|
<span className="cv-p-channels">
|
|
60
60
|
<span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
|
|
61
61
|
{descriptors.map((descriptor, index) => (
|
|
62
|
-
<span
|
|
62
|
+
<span
|
|
63
|
+
key={descriptor.channel}
|
|
64
|
+
className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
|
|
65
|
+
data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
|
|
66
|
+
>
|
|
63
67
|
<BadgeContent
|
|
64
68
|
descriptor={descriptor}
|
|
65
69
|
labels={labels}
|
|
@@ -75,7 +79,11 @@ export function ParticipantChannelBadges({
|
|
|
75
79
|
return (
|
|
76
80
|
<ul className="cv-p-channels" aria-label={labels.channelsGroup}>
|
|
77
81
|
{descriptors.map((descriptor) => (
|
|
78
|
-
<li
|
|
82
|
+
<li
|
|
83
|
+
key={descriptor.channel}
|
|
84
|
+
className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
|
|
85
|
+
data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
|
|
86
|
+
>
|
|
79
87
|
<BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
|
|
80
88
|
</li>
|
|
81
89
|
))}
|
|
@@ -60,7 +60,7 @@ function FileList({ files, labels, onRemove }: FileListProps) {
|
|
|
60
60
|
<li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
|
|
61
61
|
<span className="cv-p-files__name">{file.name}</span>
|
|
62
62
|
<span className="cv-p-files__size">{formatFileSize(file.size)}</span>
|
|
63
|
-
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} onClick={() => onRemove(file)}>
|
|
63
|
+
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} data-cv-tooltip={labels.removeAttachment} onClick={() => onRemove(file)}>
|
|
64
64
|
<span aria-hidden="true">×</span>
|
|
65
65
|
<span className="cv-p-sr-only">{labels.removeAttachment}</span>
|
|
66
66
|
</button>
|
|
@@ -29,6 +29,7 @@ export function AttachControl({ labels, disabled, acceptedTypes, onChosen }: Att
|
|
|
29
29
|
type="button"
|
|
30
30
|
className="cv-p-composer__attach"
|
|
31
31
|
aria-label={labels.attach}
|
|
32
|
+
data-cv-tooltip={labels.attach}
|
|
32
33
|
disabled={disabled}
|
|
33
34
|
onClick={() => fileInputRef.current?.click()}
|
|
34
35
|
>
|
|
@@ -44,6 +45,7 @@ export function SendButton({ label, disabled }: { readonly label: string; readon
|
|
|
44
45
|
type="submit"
|
|
45
46
|
className="cv-p-button cv-p-button--primary cv-p-composer__send"
|
|
46
47
|
aria-label={label}
|
|
48
|
+
data-cv-tooltip={label}
|
|
47
49
|
disabled={disabled}
|
|
48
50
|
>
|
|
49
51
|
<Send size={20} aria-hidden="true" focusable="false" />
|
|
@@ -43,6 +43,7 @@ export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopy
|
|
|
43
43
|
type="button"
|
|
44
44
|
className="cv-p-text__link-copy"
|
|
45
45
|
aria-label={`${labels.copyValue} ${value}`}
|
|
46
|
+
data-cv-tooltip={labels.copyValue}
|
|
46
47
|
onClick={() => copy(value, labels.valueCopied)}
|
|
47
48
|
>
|
|
48
49
|
{isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantFailedMenuList } from './ParticipantFailedMenuList'
|
|
4
|
+
import { DEFAULT_MESSAGE_OPTIONS_LABEL, type ParticipantConversationsLabels } from './participantLabels'
|
|
5
|
+
import { useParticipantFailedMenu } from './useParticipantFailedMenu'
|
|
6
|
+
|
|
7
|
+
type ParticipantFailedMenuProps = {
|
|
8
|
+
readonly labels: ParticipantConversationsLabels
|
|
9
|
+
readonly onEdit?: () => void
|
|
10
|
+
readonly onDiscard?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ParticipantFailedMenu({ labels, onEdit, onDiscard }: ParticipantFailedMenuProps) {
|
|
14
|
+
const menu = useParticipantFailedMenu()
|
|
15
|
+
const triggerId = useId()
|
|
16
|
+
if (!onEdit && !onDiscard) return null
|
|
17
|
+
const label = labels.messageOptions ?? DEFAULT_MESSAGE_OPTIONS_LABEL
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<span className="cv-p-failed-menu" ref={menu.containerRef} onBlur={menu.handleBlur}>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
id={triggerId}
|
|
24
|
+
ref={menu.triggerRef}
|
|
25
|
+
className="cv-p-failed-menu__trigger"
|
|
26
|
+
aria-haspopup="menu"
|
|
27
|
+
aria-expanded={menu.state.isOpen}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
data-cv-tooltip={menu.state.isOpen ? undefined : label}
|
|
30
|
+
onClick={menu.handleTriggerClick}
|
|
31
|
+
onKeyDown={menu.handleTriggerKeyDown}
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden="true">⋯</span>
|
|
34
|
+
</button>
|
|
35
|
+
{menu.state.isOpen ? (
|
|
36
|
+
<ParticipantFailedMenuList menu={menu} labelledBy={triggerId} labels={labels} onEdit={onEdit} onDiscard={onDiscard} />
|
|
37
|
+
) : null}
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ParticipantFailedMenuController } from './useParticipantFailedMenu'
|
|
2
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
3
|
+
|
|
4
|
+
type ParticipantFailedMenuListProps = {
|
|
5
|
+
readonly menu: ParticipantFailedMenuController
|
|
6
|
+
readonly labelledBy: string
|
|
7
|
+
readonly labels: ParticipantConversationsLabels
|
|
8
|
+
readonly onEdit?: () => void
|
|
9
|
+
readonly onDiscard?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ParticipantFailedMenuList({ menu, labelledBy, labels, onEdit, onDiscard }: ParticipantFailedMenuListProps) {
|
|
13
|
+
function runAndClose(action: () => void): () => void {
|
|
14
|
+
return () => {
|
|
15
|
+
menu.close()
|
|
16
|
+
action()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const className = `cv-p-failed-menu__list${menu.placement === 'below' ? ' cv-p-failed-menu__list--below' : ''}`
|
|
21
|
+
return (
|
|
22
|
+
<div role="menu" aria-labelledby={labelledBy} ref={menu.menuRef} className={className} onKeyDown={menu.handleMenuKeyDown}>
|
|
23
|
+
{onEdit ? (
|
|
24
|
+
<button type="button" role="menuitem" tabIndex={-1} className="cv-p-failed-menu__item" onClick={runAndClose(onEdit)}>
|
|
25
|
+
{labels.edit}
|
|
26
|
+
</button>
|
|
27
|
+
) : null}
|
|
28
|
+
{onDiscard ? (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
role="menuitem"
|
|
32
|
+
tabIndex={-1}
|
|
33
|
+
className="cv-p-failed-menu__item cv-p-failed-menu__item--danger"
|
|
34
|
+
onClick={runAndClose(onDiscard)}
|
|
35
|
+
>
|
|
36
|
+
{labels.discard}
|
|
37
|
+
</button>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -112,22 +112,22 @@ describe('ParticipantMessageBubble', () => {
|
|
|
112
112
|
const withoutRetry = renderPending('failed')
|
|
113
113
|
|
|
114
114
|
expect(withRetry).toContain('<button')
|
|
115
|
-
expect(withRetry).toContain('Failed — tap to retry')
|
|
115
|
+
expect(withRetry).toContain('aria-label="Failed — tap to retry"')
|
|
116
|
+
expect(withRetry).toContain('cv-p-failed-retry')
|
|
116
117
|
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
117
118
|
expect(findUtilityClassTokens(withRetry)).toEqual([])
|
|
118
119
|
expect(withoutRetry).not.toContain('<button')
|
|
119
120
|
expect(withoutRetry).toContain('cv-status-ticks--failed')
|
|
120
121
|
})
|
|
121
122
|
|
|
122
|
-
it('
|
|
123
|
+
it('offers edit and discard through the menu trigger only when a handler exists', () => {
|
|
123
124
|
const none = renderPending('failed')
|
|
124
125
|
const both = renderPending('failed', () => undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
125
126
|
const notFailed = renderPending('queued', undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
126
127
|
|
|
127
|
-
expect(none).not.toContain('cv-p-
|
|
128
|
-
expect(both).toContain('
|
|
129
|
-
expect(
|
|
130
|
-
expect(notFailed).not.toContain('cv-p-bubble__action')
|
|
128
|
+
expect(none).not.toContain('cv-p-failed-menu')
|
|
129
|
+
expect(both).toContain('aria-haspopup="menu"')
|
|
130
|
+
expect(notFailed).not.toContain('cv-p-failed-menu')
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('a failed server message shows only "Failed", with no button', () => {
|