@tldraw/commenting 5.3.0-next.d7d8ced023d5 → 5.3.0-next.dec36c5c2930

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 (239) hide show
  1. package/commenting.css +85 -87
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +12 -7
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +81 -871
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +135 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +326 -203
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +13 -1
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +138 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +12 -7
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +58 -879
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +127 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +326 -203
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +13 -1
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +118 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +7 -6
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.test.ts +109 -4
  189. package/src/canvas/comment-tool.tsx +31 -19
  190. package/src/canvas/comments-filter-menu.tsx +10 -17
  191. package/src/canvas/comments-overflow-menu.tsx +8 -3
  192. package/src/canvas/comments-overlay.tsx +160 -1345
  193. package/src/canvas/comments-sidebar.tsx +40 -19
  194. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  195. package/src/canvas/context.ts +4 -3
  196. package/src/canvas/hooks.test.ts +94 -0
  197. package/src/canvas/hooks.ts +17 -3
  198. package/src/canvas/license.ts +1 -1
  199. package/src/canvas/mobile-placement.ts +115 -0
  200. package/src/canvas/options.test.ts +124 -2
  201. package/src/canvas/options.ts +192 -58
  202. package/src/canvas/pending-composer.tsx +161 -0
  203. package/src/canvas/pin-stacking.test.ts +111 -1
  204. package/src/canvas/pin-stacking.ts +46 -0
  205. package/src/canvas/region-box.tsx +124 -0
  206. package/src/canvas/state.ts +20 -28
  207. package/src/canvas/thread-pin.tsx +419 -0
  208. package/src/canvas/thread-preview.tsx +77 -87
  209. package/src/canvas/thread-stack.tsx +19 -29
  210. package/src/canvas/thread-state.test.ts +51 -0
  211. package/src/canvas/thread-state.ts +56 -23
  212. package/src/canvas/thread-view.test.ts +72 -0
  213. package/src/canvas/thread-view.tsx +231 -133
  214. package/src/clustering/computeClusterTable.ts +12 -3
  215. package/src/clustering/replay.test.ts +0 -7
  216. package/src/clustering/replay.ts +131 -32
  217. package/src/clustering/runtime.test.ts +50 -6
  218. package/src/clustering/runtime.ts +42 -39
  219. package/src/clustering/schedule.test.ts +0 -6
  220. package/src/clustering/screen-offsets.test.ts +171 -0
  221. package/src/clustering/types.ts +10 -0
  222. package/src/index.ts +15 -2
  223. package/src/ui/byline.tsx +16 -6
  224. package/src/ui/comment-composer.tsx +25 -69
  225. package/src/ui/comment-pin.tsx +3 -16
  226. package/src/ui/comments-list.tsx +40 -29
  227. package/src/ui/comments.css +80 -76
  228. package/src/ui/format-time.test.ts +69 -0
  229. package/src/ui/format-time.ts +22 -2
  230. package/src/ui/icons.tsx +138 -0
  231. package/src/ui/reaction-picker.tsx +9 -16
  232. package/src/ui/reaction.tsx +10 -6
  233. package/src/ui/send-button.tsx +3 -8
  234. package/src/ui/visual-viewport.test.ts +36 -0
  235. package/src/ui/visual-viewport.ts +27 -0
  236. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  237. package/dist-esm/ui/tooltip-button.mjs +0 -12
  238. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  239. package/src/ui/tooltip-button.tsx +0 -20
@@ -84,6 +84,15 @@
84
84
  overflow-wrap: anywhere;
85
85
  }
86
86
 
87
+ /* A comment body is read-only but its text must stay selectable so it can be copied. tldraw's
88
+ container sets `user-select: none` on every descendant (`.tl-container *`) to keep canvas gestures
89
+ from selecting UI text, so the body and its rendered children have to opt back in. */
90
+ .tlui-cmt-text,
91
+ .tlui-cmt-text * {
92
+ -webkit-user-select: text;
93
+ user-select: text;
94
+ }
95
+
87
96
  .tlui-cmt-edited {
88
97
  font-style: italic;
89
98
  }
@@ -197,8 +206,6 @@
197
206
  pointer-events: none;
198
207
  white-space: nowrap;
199
208
  width: max-content;
200
- max-height: none;
201
- overflow: visible;
202
209
  }
203
210
 
204
211
  .tlui-cmt-input {
@@ -238,6 +245,17 @@
238
245
  pointer-events: none;
239
246
  }
240
247
 
248
+ /* iOS Safari zooms the whole page in when a text input with a sub-16px font takes focus, and doesn't
249
+ zoom back out. Raising the font to 16px on touch devices suppresses that without disabling
250
+ pinch-zoom of the canvas (which a `maximum-scale` viewport lock would). Must come after both base
251
+ rules above: a media query adds no specificity, so it only wins the placeholder on source order. */
252
+ @media (pointer: coarse) {
253
+ .tlui-cmt-input,
254
+ .tlui-cmt-input__placeholder {
255
+ font-size: 16px;
256
+ }
257
+ }
258
+
241
259
  /* A bare up-arrow icon button (matching the placement composer): no fill by default, a grey square
242
260
  on hover, grey while there's nothing to send and dark once there is. */
243
261
  .tlui-cmt-send {
@@ -414,12 +432,17 @@
414
432
  font-size: 10px;
415
433
  font-weight: 400;
416
434
  color: var(--tl-color-text-1);
435
+ }
436
+
437
+ /* An inert pill renders as a span (e.g. in a notification row); only the button variant is
438
+ clickable, so only it gets the pointer cursor and hover shade. */
439
+ button.tlui-cmt-reaction {
417
440
  cursor: pointer;
418
441
  }
419
442
 
420
443
  /* Hover deepens the grey — no border to tint now. Mixing toward the text colour is theme-aware:
421
444
  it darkens the fill in light mode and lightens it in dark, tracking the pill's own contrast. */
422
- .tlui-cmt-reaction:hover {
445
+ button.tlui-cmt-reaction:hover {
423
446
  background: var(--tl-color-muted-1);
424
447
  }
425
448
 
@@ -429,14 +452,15 @@
429
452
  background: color-mix(in srgb, var(--tl-color-selected) 28%, var(--tl-color-panel));
430
453
  }
431
454
 
432
- .tlui-cmt-reaction--active:hover {
455
+ button.tlui-cmt-reaction--active:hover {
433
456
  background: color-mix(in srgb, var(--tl-color-selected) 38%, var(--tl-color-panel));
434
457
  }
435
458
 
436
- /* the emoji sits at the same 10px as the count beside it */
459
+ /* The emoji renders larger than the 10px count, but its pinned line-height keeps the pill the
460
+ count's height — the glyph overflows the line box rather than growing it. */
437
461
  .tlui-cmt-reaction__emoji {
438
- font-size: 10px;
439
- line-height: 1;
462
+ font-size: 13px;
463
+ line-height: 10px;
440
464
  }
441
465
 
442
466
  .tlui-cmt-reaction__count {
@@ -539,7 +563,8 @@
539
563
  --tlui-cmt-marker-transition: 0.08s ease;
540
564
  --tlui-cmt-thread-padding: 12px;
541
565
  --tlui-cmt-thread-gap: 0px;
542
- --tlui-cmt-thread-action-size: 26px;
566
+ /* Hit-area width of one .tlui-cmt-thread__action button (drawn as a 32px square inset 4px). */
567
+ --tlui-cmt-thread-action-size: 40px;
543
568
  /* A floating pill's edge — its hairline ring is the only thing separating it from the panel it
544
569
  overlaps, so the ring is a theme token rather than a literal black: a near-black hairline
545
570
  vanishes on a dark panel, where the divider token lightens instead. The drop below it stays
@@ -602,47 +627,28 @@
602
627
  space-between alone would leave them at the left). */
603
628
  margin-left: auto;
604
629
  }
605
- /* Follows tldraw's toolbar buttons (.tlui-button), used by both the header and card actions: the
606
- button is the 40px hit target that tiles with its neighbours; its 32px ::after is the visual
607
- square carrying the hover wash, so the hit area (not the ::after) is what's clickable and it
608
- never reaches past its row. */
609
- .tlui-cmt-thread__action {
610
- position: relative;
611
- display: flex;
612
- align-items: center;
613
- justify-content: center;
630
+ /* These are TldrawUiButtons (type="icon"), so the 40px box, the 32px ::after square carrying the
631
+ hover wash, and the hover z-index flip all come from .tlui-button — the hit area (not the
632
+ ::after) is what's clickable, and it never reaches past its row. Only the bits that differ from
633
+ a toolbar button are set here, at .tlui-button's specificity + 1 so stylesheet order can't
634
+ decide the winner. */
635
+ .tlui-button.tlui-cmt-thread__action {
614
636
  width: 40px;
615
- height: 40px;
616
637
  padding: 0;
617
- border: none;
618
638
  border-radius: 6px;
619
- background: transparent;
620
639
  color: var(--tl-color-text-3);
621
640
  font-size: 14px;
622
641
  line-height: 1;
623
- cursor: pointer;
624
642
  }
625
643
  /* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px.
626
644
  Left-only, so the last button's hit area still stops at its row's right edge. */
627
645
  .tlui-cmt-thread__action + .tlui-cmt-thread__action {
628
646
  margin-left: -4px;
629
647
  }
630
- .tlui-cmt-thread__action::after {
631
- content: '';
632
- position: absolute;
633
- inset: 4px;
634
- border-radius: var(--tl-radius-2);
635
- }
636
- .tlui-cmt-thread__action:hover {
648
+ .tlui-button.tlui-cmt-thread__action:hover {
637
649
  color: var(--tl-color-text-1);
638
- /* Raise the hovered button above its overlapping neighbours so its 40px hit area wins the
639
- overlap zone — the same z-index flip tldraw's own toolbar buttons use. */
640
- z-index: 1;
641
- }
642
- .tlui-cmt-thread__action:hover::after {
643
- background: var(--tl-color-muted-2);
644
650
  }
645
- .tlui-cmt-thread__action--danger:hover {
651
+ .tlui-button.tlui-cmt-thread__action--danger:hover {
646
652
  color: var(--tl-color-danger);
647
653
  }
648
654
  .tlui-cmt-thread__resolved {
@@ -725,7 +731,7 @@
725
731
  .tlui-cmt-canvas-preview--thread .tlui-cmt-card {
726
732
  width: 100%;
727
733
  gap: 0;
728
- padding: 0 12px;
734
+ padding: 0 var(--tlui-cmt-thread-padding);
729
735
  background: transparent;
730
736
  border: none;
731
737
  }
@@ -754,14 +760,17 @@
754
760
  .tlui-cmt-stack-list .tlui-cmt-card .tlui-cmt-head {
755
761
  min-height: 40px;
756
762
  }
757
- /* Only the opened thread's comments actually carry the pills, so only they reserve room for them.
758
- Derived from the action size so it can't drift: n buttons + (n−1) 2px gaps + the pill's 2px
759
- padding either side + its 2px right inset. One button (react) on others' comments. */
760
- .tlui-cmt-thread .tlui-cmt-card .tlui-cmt-head {
761
- padding-right: calc(var(--tlui-cmt-thread-action-size) + 6px);
763
+ /* Reserve head room only for pills that actually rendered (one child / two children) — pill count
764
+ is a permissions question (canModifyComment), not ownership, and empty slots (read-only viewers)
765
+ keep the full row. Sized from the 40px hit areas (4px overlap), minus the card padding the
766
+ right:0 pills sit over; text stops 4px shy of the visible square. */
767
+ .tlui-cmt-thread .tlui-cmt-card:has(.tlui-cmt-card__actions > *) .tlui-cmt-head {
768
+ padding-right: calc(var(--tlui-cmt-thread-action-size) - var(--tlui-cmt-thread-padding));
762
769
  }
763
- .tlui-cmt-thread .tlui-cmt-card--you .tlui-cmt-head {
764
- padding-right: calc(3 * var(--tlui-cmt-thread-action-size) + 10px);
770
+ .tlui-cmt-thread .tlui-cmt-card:has(.tlui-cmt-card__actions > * + *) .tlui-cmt-head {
771
+ padding-right: calc(
772
+ 2 * var(--tlui-cmt-thread-action-size) - 4px - var(--tlui-cmt-thread-padding)
773
+ );
765
774
  }
766
775
 
767
776
  /* comments list — thread summaries, one per row (used by the canvas sidebar) */
@@ -777,7 +786,7 @@
777
786
  align-items: center;
778
787
  justify-content: space-between;
779
788
  gap: 8px;
780
- padding: 6px 6px 6px 14px;
789
+ padding: 2px 2px 2px 14px;
781
790
  border-bottom: 1px solid var(--tl-color-divider);
782
791
  }
783
792
  .tlui-cmt-list__header-title {
@@ -786,43 +795,28 @@
786
795
  color: var(--tl-color-text-1);
787
796
  }
788
797
 
789
- /* a small square icon button in the sidebar header (filter funnel, overflow …) */
790
- .tlui-cmt-header-btn {
791
- position: relative;
792
- display: flex;
793
- align-items: center;
794
- justify-content: center;
795
- width: 26px;
796
- height: 26px;
798
+ /* An icon button in the sidebar header (filter funnel, overflow …). Sized like the thread actions:
799
+ a TldrawUiButton (type="icon") left at its native 40px box, with .tlui-button's 32px ::after
800
+ square carrying the hover wash. Only the bits that differ are set here, at .tlui-button's
801
+ specificity + 1 so stylesheet order can't decide the winner. */
802
+ .tlui-button.tlui-cmt-header-btn {
803
+ width: 40px;
797
804
  padding: 0;
798
- border: none;
799
805
  border-radius: 6px;
800
- background: transparent;
801
806
  color: var(--tl-color-text-2);
802
- cursor: pointer;
803
- }
804
- /* Same hit-target expansion as the thread actions, tiled the same way — this row's gap is 2px too
805
- (see `.tlui-cmt-list__header-actions`). */
806
- .tlui-cmt-header-btn::after {
807
- content: '';
808
- position: absolute;
809
- inset: -6px -1px;
810
807
  }
811
- .tlui-cmt-header-btn:first-child::after {
812
- left: -6px;
813
- }
814
- .tlui-cmt-header-btn:last-child::after {
815
- right: -6px;
808
+ /* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px —
809
+ the same tiling the thread actions use. */
810
+ .tlui-cmt-header-btn + .tlui-cmt-header-btn {
811
+ margin-left: -4px;
816
812
  }
817
- .tlui-cmt-header-btn:hover,
818
- .tlui-cmt-header-btn[data-state='open'] {
819
- background: var(--tl-color-muted-2);
813
+ .tlui-button.tlui-cmt-header-btn:hover,
814
+ .tlui-button.tlui-cmt-header-btn[data-state='open'] {
820
815
  color: var(--tl-color-text-1);
821
816
  }
822
817
  .tlui-cmt-list__header-actions {
823
818
  display: flex;
824
819
  align-items: center;
825
- gap: 2px;
826
820
  }
827
821
 
828
822
  /* The commenting dropdowns' surface — a little air between the items and the menu edge. */
@@ -878,6 +872,7 @@
878
872
  font-size: 12px;
879
873
  }
880
874
  .tlui-cmt-list__item {
875
+ position: relative;
881
876
  display: flex;
882
877
  gap: 10px;
883
878
  width: 100%;
@@ -892,18 +887,27 @@
892
887
  color: inherit;
893
888
  text-decoration: none;
894
889
  }
895
- .tlui-cmt-list__item:hover {
890
+ /* The highlight is an inset, rounded wash like tldraw's buttons and menu items — not an
891
+ edge-to-edge band — so a comment row lights up the same way every other interactive row does. */
892
+ .tlui-cmt-list__item::after {
893
+ content: '';
894
+ position: absolute;
895
+ inset: 4px;
896
+ border-radius: var(--tl-radius-2);
897
+ pointer-events: none;
898
+ }
899
+ .tlui-cmt-list__item:hover::after {
896
900
  background: var(--tl-color-muted-2);
897
901
  }
898
902
  /* Selected sits a step above hover so the open thread's row stays distinguishable while
899
903
  pointing at other rows — but only a step: the muted-1 token (10%) reads too heavy here.
900
904
  These rows sit on the opaque sidebar panel, so a translucent tint composes safely. */
901
- .tlui-cmt-list__item--selected,
902
- .tlui-cmt-list__item--selected:hover {
905
+ .tlui-cmt-list__item--selected::after,
906
+ .tlui-cmt-list__item--selected:hover::after {
903
907
  background: hsl(0, 0%, 0%, 5%);
904
908
  }
905
- .tl-theme__dark .tlui-cmt-list__item--selected,
906
- .tl-theme__dark .tlui-cmt-list__item--selected:hover {
909
+ .tl-theme__dark .tlui-cmt-list__item--selected::after,
910
+ .tl-theme__dark .tlui-cmt-list__item--selected:hover::after {
907
911
  background: hsl(0, 0%, 100%, 5%);
908
912
  }
909
913
  /* resolved rows mute their content but keep the "Resolved" marker legible */
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatFullDateTime, formatRelativeTime } from './format-time'
3
+
4
+ /** An ISO timestamp `seconds` in the past, relative to now. */
5
+ function agoIso(seconds: number): string {
6
+ return new Date(Date.now() - seconds * 1000).toISOString()
7
+ }
8
+
9
+ const TWO_HOURS = 2 * 60 * 60
10
+
11
+ describe('formatRelativeTime', () => {
12
+ it('formats in English by default', () => {
13
+ expect(formatRelativeTime(agoIso(TWO_HOURS))).toBe(
14
+ new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
15
+ )
16
+ })
17
+
18
+ // `Byline` passes the UI's current translation locale; without this a localized app showed
19
+ // English timestamps under localized everything else.
20
+ it('formats in the given locale', () => {
21
+ const french = formatRelativeTime(agoIso(TWO_HOURS), 'fr')
22
+ expect(french).toBe(
23
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
24
+ )
25
+ expect(french).not.toBe(formatRelativeTime(agoIso(TWO_HOURS), 'en'))
26
+ })
27
+
28
+ // The parameter is optional, so undefined has to mean "the default", not "no locale".
29
+ it('treats an undefined locale as the default', () => {
30
+ expect(formatRelativeTime(agoIso(TWO_HOURS), undefined)).toBe(
31
+ formatRelativeTime(agoIso(TWO_HOURS))
32
+ )
33
+ })
34
+
35
+ it('reads under a minute as "now" in the given locale', () => {
36
+ expect(formatRelativeTime(agoIso(5), 'fr')).toBe(
37
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(0, 'second')
38
+ )
39
+ })
40
+
41
+ it('returns nothing for an unparseable date', () => {
42
+ expect(formatRelativeTime('not a date', 'fr')).toBe('')
43
+ })
44
+ })
45
+
46
+ describe('formatFullDateTime', () => {
47
+ it('formats an ISO datetime as a full date and time', () => {
48
+ const iso = '2025-07-22T16:44:00.000Z'
49
+ expect(formatFullDateTime(iso)).toBe(
50
+ new Intl.DateTimeFormat('en', { dateStyle: 'full', timeStyle: 'short' }).format(new Date(iso))
51
+ )
52
+ })
53
+
54
+ it('formats in the given locale', () => {
55
+ const french = formatFullDateTime('2025-07-22T16:44:00.000Z', 'fr')
56
+ expect(french).toContain('juillet')
57
+ expect(french).not.toBe(formatFullDateTime('2025-07-22T16:44:00.000Z', 'en'))
58
+ })
59
+
60
+ // The parameter is optional, so undefined has to mean "the default", not "no locale".
61
+ it('treats an undefined locale as the default', () => {
62
+ const iso = '2025-07-22T16:44:00.000Z'
63
+ expect(formatFullDateTime(iso, undefined)).toBe(formatFullDateTime(iso))
64
+ })
65
+
66
+ it('returns an empty string for invalid dates', () => {
67
+ expect(formatFullDateTime('not a date')).toBe('')
68
+ })
69
+ })
@@ -9,7 +9,7 @@ const DIVISIONS = [
9
9
  ] as const
10
10
 
11
11
  /**
12
- * Format an ISO datetime as short relative time ("2 hr. ago", "yesterday", "last wk.").
12
+ * Format an ISO datetime as compact relative time ("2h ago", "yesterday", "last wk.").
13
13
  * Locale-aware via Intl.RelativeTimeFormat.
14
14
  * @public
15
15
  */
@@ -17,7 +17,7 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
17
17
  const then = new Date(iso).getTime()
18
18
  if (Number.isNaN(then)) return ''
19
19
 
20
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'short' })
20
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
21
21
  let duration = (then - Date.now()) / 1000
22
22
  // Under a minute is just "now" — with second granularity the label visibly ticks while
23
23
  // typing a reply (every keystroke re-renders the card).
@@ -32,3 +32,23 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
32
32
  }
33
33
  return ''
34
34
  }
35
+
36
+ // Cached per locale — comment cards re-render on every reply keystroke, and constructing a
37
+ // DateTimeFormat is far more expensive than formatting with one.
38
+ const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
39
+
40
+ /**
41
+ * Format an ISO datetime as a full date and time ("Tuesday, July 22, 2025 at 4:44 PM").
42
+ * Locale-aware via Intl.DateTimeFormat.
43
+ * @public
44
+ */
45
+ export function formatFullDateTime(iso: string, locale = 'en'): string {
46
+ const date = new Date(iso)
47
+ if (Number.isNaN(date.getTime())) return ''
48
+ let formatter = fullDateTimeFormatters.get(locale)
49
+ if (!formatter) {
50
+ formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' })
51
+ fullDateTimeFormatters.set(locale, formatter)
52
+ }
53
+ return formatter.format(date)
54
+ }
@@ -0,0 +1,138 @@
1
+ /* The commenting UI's inline icons, kept together so the set is visible in one place.
2
+ *
3
+ * Anything with an equivalent in tldraw's icon assets goes through `TldrawUiIcon` instead (the ⋯
4
+ * menu, the dismiss cross, the resolve button's check). What's left lives here because the asset
5
+ * set has no counterpart — a smiley, a send arrow, a filter — or because the asset reads wrong at
6
+ * the size it's needed, which is the case for both resolved checks below. The eye pair mirrors
7
+ * tldraw's presence glyphs: the open state is the `follow` icon — the almond eye whose pupil
8
+ * tracks presence — and the closed state the `closed` icon, its shut-lid companion. Both are in
9
+ * the asset set, but they're inlined because the package renders without an `AssetUrlsProvider`,
10
+ * so `TldrawUiIcon` can't reach them. Inline also means they render without the consumer serving
11
+ * files.
12
+ *
13
+ * All internal — none are exported from the package. */
14
+
15
+ /** The check inside a resolved comment pin. A drawn check, not the '✓' glyph — that character sits
16
+ * off-baseline and varies by font. Heavier and rounder than `CheckIcon`: it has to hold its weight
17
+ * against the pin's 28px filled face, where the asset check reads too light. */
18
+ export function PinCheckIcon() {
19
+ return (
20
+ <svg
21
+ viewBox="0 0 24 24"
22
+ width="15"
23
+ height="15"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth="2.5"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ aria-hidden="true"
30
+ >
31
+ <path d="M4 12.5l5 5L20 6.5" />
32
+ </svg>
33
+ )
34
+ }
35
+
36
+ /** The check beside the "Resolved" label on a comments list row. Thinner than `PinCheckIcon` — it
37
+ * sits next to text at 12px, so it's drawn to match the label's weight rather than shout. */
38
+ export function CheckIcon() {
39
+ return (
40
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
41
+ <path
42
+ d="M2.5 6.2 4.7 8.4 9.5 3.6"
43
+ stroke="currentColor"
44
+ strokeWidth="1.5"
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ />
48
+ </svg>
49
+ )
50
+ }
51
+
52
+ /** The reaction picker's trigger. */
53
+ export function SmileyIcon() {
54
+ return (
55
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
56
+ <path
57
+ d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82708 7.49972C1.82708 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82708 10.6327 1.82708 7.49972ZM5.03747 9.21395C4.87949 8.98746 4.56782 8.93193 4.34133 9.08991C4.11484 9.24789 4.05931 9.55956 4.21729 9.78605C4.93926 10.8211 6.14033 11.5 7.50004 11.5C8.85974 11.5 10.0608 10.8211 10.7828 9.78605C10.9408 9.55956 10.8852 9.24789 10.6587 9.08991C10.4323 8.93193 10.1206 8.98746 9.9626 9.21395C9.41963 9.99238 8.51907 10.5 7.50004 10.5C6.481 10.5 5.58044 9.99238 5.03747 9.21395ZM5.37503 6.84998C5.85828 6.84998 6.25003 6.45815 6.25003 5.97498C6.25003 5.4918 5.85828 5.09998 5.37503 5.09998C4.89179 5.09998 4.50003 5.4918 4.50003 5.97498C4.50003 6.45815 4.89179 6.84998 5.37503 6.84998ZM10.5 5.97498C10.5 6.45815 10.1083 6.84998 9.62503 6.84998C9.14179 6.84998 8.75003 6.45815 8.75003 5.97498C8.75003 5.4918 9.14179 5.09998 9.62503 5.09998C10.1083 5.09998 10.5 5.4918 10.5 5.97498Z"
58
+ fill="currentColor"
59
+ fillRule="evenodd"
60
+ clipRule="evenodd"
61
+ />
62
+ </svg>
63
+ )
64
+ }
65
+
66
+ /** The composer's post button — an up arrow. */
67
+ export function SendIcon() {
68
+ return (
69
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
70
+ <path
71
+ d="M7.14645 2.14645C7.34171 1.95118 7.65829 1.95118 7.85355 2.14645L11.8536 6.14645C12.0488 6.34171 12.0488 6.65829 11.8536 6.85355C11.6583 7.04882 11.3417 7.04882 11.1464 6.85355L8 3.70711L8 12.5C8 12.7761 7.77614 13 7.5 13C7.22386 13 7 12.7761 7 12.5L7 3.70711L3.85355 6.85355C3.65829 7.04882 3.34171 7.04882 3.14645 6.85355C2.95118 6.65829 2.95118 6.34171 3.14645 6.14645L7.14645 2.14645Z"
72
+ fill="currentColor"
73
+ fillRule="evenodd"
74
+ clipRule="evenodd"
75
+ />
76
+ </svg>
77
+ )
78
+ }
79
+
80
+ /** Comment pins are showing. tldraw's `follow` glyph (icons/icon/follow.svg) inlined at its native
81
+ * weight — the almond eye with a centred pupil. Pairs with `EyeClosedIcon`; the two must stay the
82
+ * same family. */
83
+ export function EyeOpenIcon() {
84
+ return (
85
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
86
+ <path fill="currentColor" d="M19 15a4 4 0 1 1-8 0 4 4 0 0 1 8 0" />
87
+ <path
88
+ stroke="currentColor"
89
+ strokeWidth="2"
90
+ d="M26 15c0 1.77-1.077 3.496-3.07 4.825C20.946 21.149 18.145 22 15 22s-5.945-.851-7.93-2.175C5.076 18.496 4 16.77 4 15c0-1.77 1.077-3.496 3.07-4.825C9.054 8.851 11.855 8 15 8s5.945.851 7.93 2.175C24.924 11.504 26 13.23 26 15Z"
91
+ />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ /** Comment pins are hidden. tldraw's `closed` eye glyph (icons/icon/closed.svg) — a shut lower lid
97
+ * with lashes, the design pair to `follow`. Inlined like the open eye: the package renders without
98
+ * an `AssetUrlsProvider`. Lash dots are `r=1.5` (vs the asset's `r=1`) because at the 15px header
99
+ * size the asset's finer lashes fall below a pixel; the lid keeps `follow`'s native stroke. */
100
+ export function EyeClosedIcon() {
101
+ return (
102
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
103
+ <path
104
+ stroke="currentColor"
105
+ strokeWidth="2"
106
+ strokeLinecap="round"
107
+ d="M27 15C27 19.4183 21.6274 23 15 23C8.37258 23 3 19.4183 3 15"
108
+ />
109
+ <circle cx="4" cy="20" r="1.5" fill="currentColor" />
110
+ <circle cx="9" cy="23" r="1.5" fill="currentColor" />
111
+ <circle cx="15" cy="24" r="1.5" fill="currentColor" />
112
+ <circle cx="21" cy="23" r="1.5" fill="currentColor" />
113
+ <circle cx="26" cy="20" r="1.5" fill="currentColor" />
114
+ </svg>
115
+ )
116
+ }
117
+
118
+ /** The sidebar's comment-menu trigger — tldraw's `dots-vertical` glyph (icons/icon/dots-vertical.svg)
119
+ * inlined, a vertical kebab. Inlined like the eye pair because the package renders without an
120
+ * `AssetUrlsProvider`. */
121
+ export function MoreMenuIcon() {
122
+ return (
123
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
124
+ <path
125
+ fill="currentColor"
126
+ d="M15 9.48975C14.3167 9.48975 13.7292 9.24747 13.2375 8.76293C12.7458 8.27003 12.5 7.67688 12.5 6.98348C12.5 6.29843 12.7458 5.71364 13.2375 5.22909C13.7292 4.73619 14.3167 4.48975 15 4.48975C15.6833 4.48975 16.2708 4.73619 16.7625 5.22909C17.2542 5.71364 17.5 6.29843 17.5 6.98348C17.5 7.44296 17.3833 7.86485 17.15 8.24914C16.925 8.62508 16.625 8.92583 16.25 9.1514C15.875 9.37696 15.4583 9.48975 15 9.48975Z"
127
+ />
128
+ <path
129
+ fill="currentColor"
130
+ d="M15 17.5C14.3167 17.5 13.7292 17.2577 13.2375 16.7732C12.7458 16.2803 12.5 15.6871 12.5 14.9937C12.5 14.3087 12.7458 13.7239 13.2375 13.2393C13.7292 12.7464 14.3167 12.5 15 12.5C15.6833 12.5 16.2708 12.7464 16.7625 13.2393C17.2542 13.7239 17.5 14.3087 17.5 14.9937C17.5 15.4532 17.3833 15.8751 17.15 16.2594C16.925 16.6353 16.625 16.9361 16.25 17.1617C15.875 17.3872 15.4583 17.5 15 17.5Z"
131
+ />
132
+ <path
133
+ fill="currentColor"
134
+ d="M15 25.5103C14.3167 25.5103 13.7292 25.268 13.2375 24.7834C12.7458 24.2905 12.5 23.6974 12.5 23.004C12.5 22.319 12.7458 21.7342 13.2375 21.2496C13.7292 20.7567 14.3167 20.5103 15 20.5103C15.6833 20.5103 16.2708 20.7567 16.7625 21.2496C17.2542 21.7342 17.5 22.319 17.5 23.004C17.5 23.4635 17.3833 23.8854 17.15 24.2697C16.925 24.6456 16.625 24.9463 16.25 25.1719C15.875 25.3975 15.4583 25.5103 15 25.5103Z"
135
+ />
136
+ </svg>
137
+ )
138
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useId, type ComponentType } from 'react'
2
2
  import {
3
+ TldrawUiButton,
3
4
  TldrawUiDropdownMenuContent,
4
5
  TldrawUiDropdownMenuRoot,
5
6
  TldrawUiDropdownMenuTrigger,
@@ -8,8 +9,8 @@ import {
8
9
  useTranslation,
9
10
  } from 'tldraw'
10
11
  import { EmojiPicker, type EmojiPickerProps } from './emoji-picker'
12
+ import { SmileyIcon } from './icons'
11
13
  import { RenderReaction } from './reaction'
12
- import { TooltipButton } from './tooltip-button'
13
14
 
14
15
  /** @public */
15
16
  export interface ReactionPickerProps {
@@ -78,9 +79,14 @@ export function ReactionPicker({
78
79
  return (
79
80
  <TldrawUiDropdownMenuRoot id={id}>
80
81
  <TldrawUiDropdownMenuTrigger>
81
- <TooltipButton tooltip={msg('comments.add-reaction')} className={className}>
82
+ <TldrawUiButton
83
+ type="icon"
84
+ tooltip={msg('comments.add-reaction')}
85
+ title={msg('comments.add-reaction')}
86
+ className={className}
87
+ >
82
88
  <SmileyIcon />
83
- </TooltipButton>
89
+ </TldrawUiButton>
84
90
  </TldrawUiDropdownMenuTrigger>
85
91
  {/* left-aligned under the trigger, so the grid opens rightward off the card's edge */}
86
92
  <TldrawUiDropdownMenuContent side="bottom" align="start" alignOffset={0} sideOffset={4}>
@@ -94,16 +100,3 @@ export function ReactionPicker({
94
100
  </TldrawUiDropdownMenuRoot>
95
101
  )
96
102
  }
97
-
98
- function SmileyIcon() {
99
- return (
100
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
101
- <path
102
- d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82708 7.49972C1.82708 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82708 10.6327 1.82708 7.49972ZM5.03747 9.21395C4.87949 8.98746 4.56782 8.93193 4.34133 9.08991C4.11484 9.24789 4.05931 9.55956 4.21729 9.78605C4.93926 10.8211 6.14033 11.5 7.50004 11.5C8.85974 11.5 10.0608 10.8211 10.7828 9.78605C10.9408 9.55956 10.8852 9.24789 10.6587 9.08991C10.4323 8.93193 10.1206 8.98746 9.9626 9.21395C9.41963 9.99238 8.51907 10.5 7.50004 10.5C6.481 10.5 5.58044 9.99238 5.03747 9.21395ZM5.37503 6.84998C5.85828 6.84998 6.25003 6.45815 6.25003 5.97498C6.25003 5.4918 5.85828 5.09998 5.37503 5.09998C4.89179 5.09998 4.50003 5.4918 4.50003 5.97498C4.50003 6.45815 4.89179 6.84998 5.37503 6.84998ZM10.5 5.97498C10.5 6.45815 10.1083 6.84998 9.62503 6.84998C9.14179 6.84998 8.75003 6.45815 8.75003 5.97498C8.75003 5.4918 9.14179 5.09998 9.62503 5.09998C10.1083 5.09998 10.5 5.4918 10.5 5.97498Z"
103
- fill="currentColor"
104
- fillRule="evenodd"
105
- clipRule="evenodd"
106
- />
107
- </svg>
108
- )
109
- }
@@ -46,19 +46,23 @@ export function Reaction({
46
46
  ReactionTooltip = DefaultReactionTooltip,
47
47
  onClick,
48
48
  }: ReactionProps) {
49
+ // An inert pill (no handler) is plain content, not a control — rendering it as a span keeps it
50
+ // valid inside an anchor row (e.g. a notification list item) and out of the tab order.
51
+ const Tag = onClick ? 'button' : 'span'
49
52
  const pill = (
50
- <button
53
+ <Tag
51
54
  className={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}
52
- type="button"
55
+ {...(onClick
56
+ ? { type: 'button' as const, 'aria-pressed': active, onClick }
57
+ : { role: 'img' as const })}
53
58
  // The emoji alone announces as its unicode name ("thumbs up"); pairing it with the count
54
- // is what makes the pill's state legible to a screen reader.
59
+ // is what makes the pill's state legible to a screen reader. The inert span needs the
60
+ // img role for the label to be announced at all.
55
61
  aria-label={`${emoji} ${count}`}
56
- aria-pressed={active}
57
- onClick={onClick}
58
62
  >
59
63
  <span className="tlui-cmt-reaction__emoji">{renderReaction(emoji)}</span>
60
64
  <span className="tlui-cmt-reaction__count">{count}</span>
61
- </button>
65
+ </Tag>
62
66
  )
63
67
 
64
68
  // A bare pill when hovering is suppressed, or when there's no one to name.
@@ -1,3 +1,5 @@
1
+ import { SendIcon } from './icons'
2
+
1
3
  /** @public */
2
4
  export interface SendButtonProps {
3
5
  /** The button's accessible name (e.g. "Send"). Shown as an up-arrow icon, so this is its label. */
@@ -17,14 +19,7 @@ export function SendButton({ label, disabled, onClick }: SendButtonProps) {
17
19
  aria-label={label}
18
20
  title={label}
19
21
  >
20
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
21
- <path
22
- d="M7.14645 2.14645C7.34171 1.95118 7.65829 1.95118 7.85355 2.14645L11.8536 6.14645C12.0488 6.34171 12.0488 6.65829 11.8536 6.85355C11.6583 7.04882 11.3417 7.04882 11.1464 6.85355L8 3.70711L8 12.5C8 12.7761 7.77614 13 7.5 13C7.22386 13 7 12.7761 7 12.5L7 3.70711L3.85355 6.85355C3.65829 7.04882 3.34171 7.04882 3.14645 6.85355C2.95118 6.65829 2.95118 6.34171 3.14645 6.14645L7.14645 2.14645Z"
23
- fill="currentColor"
24
- fillRule="evenodd"
25
- clipRule="evenodd"
26
- />
27
- </svg>
22
+ <SendIcon />
28
23
  </button>
29
24
  )
30
25
  }