@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.
Files changed (49) hide show
  1. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  2. package/dist/chunk-ZSOD5AQT.js +19 -0
  3. package/dist/flows/index.js +4 -2
  4. package/dist/index.js +5 -3
  5. package/dist/participant/index.d.ts +3 -0
  6. package/dist/participant/index.js +536 -276
  7. package/dist/styles.css +99 -23
  8. package/package.json +2 -1
  9. package/src/Tooltip.tsx +5 -12
  10. package/src/TooltipDom.test.tsx +78 -0
  11. package/src/domHarness.test-helper.ts +53 -0
  12. package/src/participant/ParticipantBackButton.tsx +1 -1
  13. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  14. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  15. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  16. package/src/participant/ParticipantComposer.tsx +1 -1
  17. package/src/participant/ParticipantComposerControls.tsx +2 -0
  18. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  19. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  20. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  21. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  22. package/src/participant/ParticipantMessageBubble.tsx +20 -52
  23. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  24. package/src/participant/ParticipantMessageText.tsx +4 -2
  25. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  26. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  27. package/src/participant/ParticipantThread.tsx +23 -12
  28. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  29. package/src/participant/failedMenuKeyboard.ts +29 -0
  30. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  31. package/src/participant/participantFailedMenuState.test.ts +61 -0
  32. package/src/participant/participantFailedMenuState.ts +57 -0
  33. package/src/participant/participantFailedMessage.test.tsx +83 -0
  34. package/src/participant/participantLabels.ts +6 -0
  35. package/src/participant/participantThemeContrast.test.ts +8 -0
  36. package/src/participant/participantTooltips.test.tsx +74 -0
  37. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
  38. package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
  39. package/src/participant/test-fixtures/thread-default.golden.html +1 -1
  40. package/src/participant/test-fixtures/thread-error.golden.html +1 -1
  41. package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
  42. package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
  43. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
  44. package/src/participant/useFailedMenuEffects.ts +59 -0
  45. package/src/participant/useParticipantFailedMenu.ts +60 -0
  46. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  47. package/src/styles.css +19 -5
  48. package/src/tooltipLayerRegistry.test.ts +56 -0
  49. 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-bubble__retry,
1386
- .cv-p-bubble__action {
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
- .cv-p-bubble__retry {
2008
- min-height: var(--cv-p-i-touch);
2009
- display: inline-flex;
2010
- align-items: center;
2011
- gap: 0.25rem;
2012
- padding: 0 0.5rem;
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: 1px solid var(--cv-p-i-danger);
2027
+ border: 0;
2015
2028
  color: var(--cv-p-i-danger);
2016
- border-radius: var(--cv-p-i-radius);
2017
- font-size: 0.7rem;
2029
+ font: inherit;
2030
+ cursor: pointer;
2018
2031
  }
2019
- .cv-p-bubble__actions {
2020
- display: inline-flex;
2021
- gap: 0.5rem;
2022
- margin-top: 0.25rem;
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-bubble__action {
2025
- min-height: var(--cv-p-i-touch);
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
- align-items: center;
2028
- padding: 0 0.75rem;
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
- font-size: 0.7rem;
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.8.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
- let layerOwner: object | undefined
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={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
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 FailedActionsProps = {
37
+ type RetryButtonProps = {
46
38
  readonly labels: ParticipantConversationsLabels
47
- readonly onDiscard?: () => void
48
- readonly onEdit?: () => void
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 FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
52
- if (!onDiscard && !onEdit) return null
44
+ export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
53
45
  return (
54
- <span className="cv-p-bubble__actions">
55
- {onEdit ? (
56
- <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
57
- {labels.edit}
58
- </button>
59
- ) : null}
60
- {onDiscard ? (
61
- <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
62
- {labels.discard}
63
- </button>
64
- ) : null}
65
- </span>
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 key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
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 key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
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('shows discard and edit on a failed pending only when the handlers exist', () => {
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-bubble__action')
128
- expect(both).toContain('>Discard<')
129
- expect(both).toContain('>Edit<')
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', () => {