@plannotator/ui 0.52.1 → 0.53.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 (67) hide show
  1. package/HANDOFF.md +231 -27
  2. package/README.md +29 -4
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +189 -48
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +183 -96
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The comment composer's textarea with its token highlight layer (skill
3
+ * references, host `@` mentions), moved out of CommentPopover so a host can
4
+ * put the same field in its own composer (the Plannotator Inbox's reply box
5
+ * and New message). CommentPopover renders it with every default, so its
6
+ * markup is unchanged.
7
+ */
8
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import type { SkillReferenceToken } from '../utils/skillReferences';
10
+ import {
11
+ mentionTokenRanges,
12
+ mergeTokenRanges,
13
+ skillTokenRanges,
14
+ type ComposerTokenRange,
15
+ } from '../utils/composerTokens';
16
+ import type { MentionPerson } from '../utils/mentions';
17
+
18
+ /** Classes shared by the textarea and its highlight mirror — font, size,
19
+ * and box metrics MUST stay identical or the overlay drifts out of alignment. */
20
+ export const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
21
+
22
+ /** Per-composer memo for the overlay gutter sync below. */
23
+ interface OverlayGutterState {
24
+ /** The overlay the applied value belongs to (a remount invalidates it). */
25
+ overlay: HTMLElement | null;
26
+ /** Last `offsetWidth - clientWidth` seen on the textarea. */
27
+ raw: number;
28
+ /** Extra right padding currently written inline on the overlay, in px. */
29
+ applied: number;
30
+ }
31
+
32
+ const cssPx = (value: string): number => {
33
+ const n = parseFloat(value);
34
+ return Number.isFinite(n) ? n : 0;
35
+ };
36
+
37
+ /**
38
+ * Keep the mirror's text column exactly as wide as the textarea's (#1525).
39
+ *
40
+ * A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
41
+ * app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
42
+ * content box once the composer hits its max height — but the overlay is
43
+ * `overflow: hidden` and keeps its full width, so from the first line that
44
+ * wraps differently the painted glyphs drift away from the native caret, which
45
+ * is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
46
+ * cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
47
+ * measured scrollbar width onto the overlay's right padding makes both boxes
48
+ * wrap identically again (and stops the overlay painting under the scrollbar).
49
+ *
50
+ * Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
51
+ * measured width is 0 — and then this writes NOTHING at all, leaving the
52
+ * class-driven padding untouched exactly as before the fix.
53
+ */
54
+ function syncOverlayGutter(
55
+ el: HTMLTextAreaElement,
56
+ overlay: HTMLElement,
57
+ state: OverlayGutterState,
58
+ raw: number,
59
+ ): void {
60
+ // Nothing measurable changed since the last sync — the common case.
61
+ if (overlay === state.overlay && raw === state.raw) return;
62
+ state.overlay = overlay;
63
+ state.raw = raw;
64
+
65
+ const clear = () => {
66
+ if (state.applied === 0) return; // never wrote anything; leave the DOM alone
67
+ overlay.style.removeProperty('padding-right');
68
+ state.applied = 0;
69
+ };
70
+
71
+ const style = getComputedStyle(el);
72
+ // `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
73
+ // are shared with the overlay (both boxes are border-box), so only the
74
+ // scrollbar part is the extra gutter the mirror is missing.
75
+ const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
76
+ const scrollbar = Math.max(0, raw - borders);
77
+ if (scrollbar === 0) {
78
+ clear();
79
+ return;
80
+ }
81
+ // Base padding is read from the textarea (never the overlay, whose computed
82
+ // value may already include a gutter we wrote), so the two can never drift
83
+ // apart if COMPOSER_TEXT_CLASSES changes.
84
+ const base = parseFloat(style.paddingRight);
85
+ if (!Number.isFinite(base)) {
86
+ clear(); // unmeasurable: fall back to the pre-fix behavior
87
+ return;
88
+ }
89
+ overlay.style.paddingRight = `${base + scrollbar}px`;
90
+ state.applied = scrollbar;
91
+ }
92
+
93
+ /**
94
+ * The default chip look: the theme's primary at a wash, one shade stronger
95
+ * than a skill reference's so the two token kinds in one overlay read as
96
+ * siblings rather than the same thing. Deliberately no ring: every class here
97
+ * is one the package already emitted, so a host build's CSS (and the portable
98
+ * guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
99
+ * `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
100
+ * layout, per the metric rule above.
101
+ */
102
+ const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
103
+
104
+ /**
105
+ * One token's span in the highlight overlay.
106
+ *
107
+ * METRIC RULE, load-bearing for every token kind: a span may change COLOR,
108
+ * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
109
+ * that moves a glyph — padding, margin, border width, font-weight,
110
+ * letter-spacing, font-size — would shift the overlay's text off the
111
+ * textarea's own layout and drift the caret away from the painted glyphs. A
112
+ * pill's breathing room is faked with a paint-only `box-shadow` ring in the
113
+ * chip's own background color.
114
+ */
115
+ function renderTokenSpan(
116
+ range: ComposerTokenRange,
117
+ text: string,
118
+ mentionTokenClassName?: string,
119
+ ): React.ReactNode {
120
+ if (range.kind === 'mention') {
121
+ // `data-mention-token` / `data-mention-kind` are the host's styling hook;
122
+ // `tokenClassName` is appended verbatim and is the host's to keep
123
+ // metric-safe (see the rule above).
124
+ return (
125
+ <span
126
+ key={`mention-${range.start}`}
127
+ data-mention-token={range.person.id}
128
+ data-mention-kind={range.person.kind}
129
+ className={`${MENTION_CHIP_CLASSES}${
130
+ mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
131
+ }`}
132
+ >
133
+ {text}
134
+ </span>
135
+ );
136
+ }
137
+ // Human-only tokens carry a quiet dotted underline as their inline marker
138
+ // (text-decoration never affects glyph layout, so overlay alignment is
139
+ // safe). The accessible explanation lives in HumanOnlySkillNotice below
140
+ // the textarea — this overlay is aria-hidden.
141
+ return (
142
+ <span
143
+ key={`skill-${range.start}`}
144
+ data-skill-ref-token={range.skill.entry.name}
145
+ data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
146
+ className={`text-primary bg-primary/10 rounded-[3px] ${
147
+ range.skill.entry.humanOnly
148
+ ? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
149
+ : ''
150
+ }`}
151
+ >
152
+ {text}
153
+ </span>
154
+ );
155
+ }
156
+
157
+ /** The mention half of the highlight layer. Null → no mention source at all. */
158
+ export interface ComposerMentionChips {
159
+ /** The people whose `@Label` token still survives in the text. */
160
+ readonly people: readonly MentionPerson[];
161
+ /** Host class appended to each chip (see `MentionSource.tokenClassName`). */
162
+ readonly tokenClassName?: string;
163
+ }
164
+
165
+ /** A host's extra classes, appended after a space; nothing at all when absent (the default markup is unchanged). */
166
+ const extra = (className: string | undefined): string => (className ? ` ${className}` : '');
167
+
168
+ export interface ComposerTokenTextareaProps {
169
+ value: string;
170
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
171
+ onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
172
+ /** Caret observer (skill autocomplete). */
173
+ onSelectCaret: () => void;
174
+ placeholder: string;
175
+ /** Mode-specific min/max height classes. */
176
+ sizeClassName: string;
177
+ textareaRef: (el: HTMLTextAreaElement | null) => void;
178
+ /** Positioned skill-reference occurrences to highlight. */
179
+ tokens: SkillReferenceToken[];
180
+ /** Off → no skill-reference contribution to the highlight layer. */
181
+ skillReferences: boolean;
182
+ /**
183
+ * The mention contribution, or null with no `mentionSource`. Present (even
184
+ * with nobody tagged yet) it turns the overlay on, so the first pick paints
185
+ * a chip without swapping the textarea for a different element.
186
+ */
187
+ mentionChips: ComposerMentionChips | null;
188
+ /** ARIA relationship to the skill-reference listbox. */
189
+ listboxId: string;
190
+ listboxOpen: boolean;
191
+ activeOptionId?: string;
192
+ /**
193
+ * Font, size and box classes shared by the textarea and its mirror; they
194
+ * MUST be the same on both or the overlay drifts. Default:
195
+ * `COMPOSER_TEXT_CLASSES` (the comment composer's).
196
+ */
197
+ textClassName?: string;
198
+ /** Extra classes on the textarea only (a host's own hook). */
199
+ inputClassName?: string;
200
+ /** Extra classes on the mirror only (a host that keeps its metrics in its own class). */
201
+ mirrorClassName?: string;
202
+ /**
203
+ * Further textarea attributes (`aria-label`, `aria-describedby`, `rows`, …).
204
+ * An `aria-label` here replaces the default, the placeholder.
205
+ */
206
+ inputProps?: Omit<
207
+ React.TextareaHTMLAttributes<HTMLTextAreaElement>,
208
+ 'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder'
209
+ >;
210
+ }
211
+
212
+ /**
213
+ * The composer's textarea. With NEITHER token source active this is exactly
214
+ * the pre-feature `<textarea>`; with either one on, its tokens are painted by
215
+ * a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
216
+ * (a textarea cannot style substrings). ONE overlay serves both sources: two
217
+ * mirrored layers could never stay pixel-aligned with each other, and only
218
+ * one of them could own the scroll sync. The overlay shares the exact
219
+ * font/padding/wrapping metrics and mirrors scroll position, and token spans
220
+ * obey the metric rule on `renderTokenSpan` (paint only, never layout), so
221
+ * the glyphs the browser lays out in the textarea and the glyphs the overlay
222
+ * paints coincide. During IME composition the overlay hides and the textarea
223
+ * text becomes visible again (`.pn-ref-composing`), keeping native
224
+ * composition rendering (underlines, candidate highlights) intact.
225
+ */
226
+ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
227
+ value,
228
+ onChange,
229
+ onKeyDown,
230
+ onSelectCaret,
231
+ placeholder,
232
+ sizeClassName,
233
+ textareaRef,
234
+ tokens,
235
+ skillReferences,
236
+ mentionChips,
237
+ listboxId,
238
+ listboxOpen,
239
+ activeOptionId,
240
+ textClassName = COMPOSER_TEXT_CLASSES,
241
+ inputClassName,
242
+ mirrorClassName,
243
+ inputProps,
244
+ }) => {
245
+ const overlayRef = useRef<HTMLDivElement>(null);
246
+ const [composing, setComposing] = useState(false);
247
+ const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
248
+
249
+ const syncScroll = useCallback((el: HTMLTextAreaElement) => {
250
+ const overlay = overlayRef.current;
251
+ if (!overlay) return;
252
+ // Reads before writes so a sync costs at most one forced layout.
253
+ const { scrollTop, scrollLeft } = el;
254
+ const raw = el.offsetWidth - el.clientWidth;
255
+ overlay.scrollTop = scrollTop;
256
+ overlay.scrollLeft = scrollLeft;
257
+ syncOverlayGutter(el, overlay, gutterRef.current, raw);
258
+ }, []);
259
+
260
+ const innerRef = useRef<HTMLTextAreaElement | null>(null);
261
+ const attachRef = useCallback(
262
+ (el: HTMLTextAreaElement | null) => {
263
+ innerRef.current = el;
264
+ textareaRef(el);
265
+ },
266
+ [textareaRef],
267
+ );
268
+
269
+ // The overlay exists while EITHER source is active. `mentionChips` turns it
270
+ // on for the whole life of a mention composer, not only once somebody is
271
+ // tagged, so a pick never swaps the textarea element under the caret.
272
+ const mentionPeople = mentionChips?.people;
273
+ const highlight = skillReferences || mentionChips !== null;
274
+
275
+ // The one list of spans the overlay paints, from every active source.
276
+ // Skill references are the earlier group, so they win a byte both claim.
277
+ const ranges = useMemo(
278
+ () =>
279
+ mergeTokenRanges(value, [
280
+ skillReferences ? skillTokenRanges(tokens) : [],
281
+ mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
282
+ ]),
283
+ [value, tokens, skillReferences, mentionPeople],
284
+ );
285
+
286
+ // Keep the mirror aligned when the value changes without a scroll event
287
+ // (e.g. programmatic insertion moving the caret into a scrolled region).
288
+ useEffect(() => {
289
+ if (innerRef.current) syncScroll(innerRef.current);
290
+ }, [value, syncScroll]);
291
+
292
+ // `syncScroll` otherwise only runs on scroll and on value change, so a
293
+ // window/panel resize that grows or drops the textarea's scrollbar without a
294
+ // keystroke would leave the gutter stale until the next one.
295
+ useEffect(() => {
296
+ const el = innerRef.current;
297
+ if (!el || typeof ResizeObserver === 'undefined') return;
298
+ const observer = new ResizeObserver(() => {
299
+ if (innerRef.current) syncScroll(innerRef.current);
300
+ });
301
+ observer.observe(el);
302
+ return () => observer.disconnect();
303
+ }, [highlight, syncScroll]);
304
+
305
+ if (!highlight) {
306
+ return (
307
+ <textarea
308
+ data-pn-mobile-editable="true"
309
+ // Absent any open menu these are all undefined, so the plain textarea
310
+ // renders exactly the attributes it always did.
311
+ aria-autocomplete={listboxOpen ? 'list' : undefined}
312
+ aria-controls={listboxOpen ? listboxId : undefined}
313
+ aria-activedescendant={activeOptionId}
314
+ {...inputProps}
315
+ ref={attachRef}
316
+ value={value}
317
+ onChange={onChange}
318
+ onKeyDown={onKeyDown}
319
+ onSelect={onSelectCaret}
320
+ placeholder={placeholder}
321
+ className={`${textClassName} placeholder:text-muted-foreground resize-none focus:outline-none ${sizeClassName}${extra(inputClassName)}`}
322
+ style={{ fieldSizing: 'content' } as React.CSSProperties}
323
+ />
324
+ );
325
+ }
326
+
327
+ const segments: React.ReactNode[] = [];
328
+ let pos = 0;
329
+ for (const range of ranges) {
330
+ if (range.start > pos) segments.push(value.slice(pos, range.start));
331
+ segments.push(
332
+ renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
333
+ );
334
+ pos = range.end;
335
+ }
336
+ segments.push(value.slice(pos));
337
+
338
+ return (
339
+ <div className="relative">
340
+ <div
341
+ ref={overlayRef}
342
+ aria-hidden="true"
343
+ // Names the SKILL-reference layer, so a skill composer's overlay is
344
+ // byte-identical to the one it rendered before mentions existed; a
345
+ // mentions-only overlay is found by its mirror attribute below.
346
+ data-skill-ref-overlay={skillReferences ? 'true' : undefined}
347
+ data-pn-mobile-editable-mirror="true"
348
+ className={`${textClassName} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words${extra(mirrorClassName)}`}
349
+ style={composing ? { visibility: 'hidden' } : undefined}
350
+ >
351
+ {segments}
352
+ {'\n'}
353
+ </div>
354
+ <textarea
355
+ data-pn-mobile-editable="true"
356
+ aria-label={placeholder}
357
+ {...inputProps}
358
+ aria-autocomplete="list"
359
+ aria-haspopup="listbox"
360
+ aria-controls={listboxOpen ? listboxId : undefined}
361
+ aria-owns={listboxOpen ? listboxId : undefined}
362
+ aria-activedescendant={activeOptionId}
363
+ ref={attachRef}
364
+ value={value}
365
+ onChange={onChange}
366
+ onKeyDown={onKeyDown}
367
+ onSelect={onSelectCaret}
368
+ onScroll={(e) => syncScroll(e.currentTarget)}
369
+ onCompositionStart={() => setComposing(true)}
370
+ onCompositionEnd={() => setComposing(false)}
371
+ placeholder={placeholder}
372
+ className={`${textClassName} placeholder:text-muted-foreground resize-none focus:outline-none relative pn-ref-input ${
373
+ composing ? 'pn-ref-composing' : ''
374
+ } ${sizeClassName}${extra(inputClassName)}`}
375
+ style={{ fieldSizing: 'content' } as React.CSSProperties}
376
+ />
377
+ </div>
378
+ );
379
+ };
@@ -12,6 +12,7 @@ import { getBearSettings } from '../utils/bear';
12
12
  import { getOctarineSettings } from '../utils/octarine';
13
13
  import { wrapFeedbackForAgent } from '../utils/parser';
14
14
  import { copyTextToClipboard } from '../utils/clipboard';
15
+ import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession';
15
16
  import { OverlayScrollArea } from './OverlayScrollArea';
16
17
 
17
18
  /** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */
@@ -31,8 +32,12 @@ async function defaultSaveToNotes(body: SaveToNotesPayload): Promise<SaveToNotes
31
32
  const res = await fetch('/api/save-notes', {
32
33
  method: 'POST',
33
34
  headers: { 'Content-Type': 'application/json' },
34
- body: JSON.stringify(body),
35
+ // The stale-tab guard (a no-op for a host that never adopted a session).
36
+ body: JSON.stringify(withServerSession(body)),
35
37
  });
38
+ if (await noteServerSessionMismatch(res)) {
39
+ return { results: Object.fromEntries(Object.keys(body).map((k) => [k, { success: false, error: 'This review was replaced. Reload the page.' }])) };
40
+ }
36
41
  return res.json();
37
42
  }
38
43
 
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { Tooltip } from './Tooltip';
3
+ import { isTextCut, noHover } from '../utils/textCut';
4
+
5
+ /**
6
+ * Hover text for a ui control or label, drawn the way the host chooses.
7
+ *
8
+ * Default (every host that does not opt in, Plannotator's plan review,
9
+ * annotate and code review included): the child gets a native `title`,
10
+ * exactly as before.
11
+ *
12
+ * With `configurePlannotatorUI({ componentTooltips: true })` (the Plannotator
13
+ * Inbox): the child gets ui's `Tooltip` instead, and no native title. `name`
14
+ * marks a title that is also the control's accessible name (an icon button
15
+ * with no `aria-label`): the child then gets it as its `aria-label`, so the
16
+ * name never depends on the tooltip. `cut` opens the tooltip only while the
17
+ * child's text is cut. Escape closes it and still reaches the page, and
18
+ * nothing opens on a touch screen.
19
+ */
20
+
21
+ let componentTooltips = false;
22
+
23
+ /** Draw ui's hover text as ui's Tooltip (true) or a native title (false, the default). */
24
+ export function setComponentTooltips(on: boolean): void {
25
+ componentTooltips = on;
26
+ }
27
+
28
+ export function resetComponentTooltips(): void {
29
+ componentTooltips = false;
30
+ }
31
+
32
+ export function getComponentTooltips(): boolean {
33
+ return componentTooltips;
34
+ }
35
+
36
+ /** How long the pointer rests before a component tooltip opens. */
37
+ const HOST_TITLE_DELAY_MS = 150;
38
+
39
+ /**
40
+ * Where a tooltip for `trigger` is portaled: the open native `<dialog>` it
41
+ * sits in (a popup in the body is drawn under the dialog's top layer), else
42
+ * the nearest host root marked `data-pn-tooltip-root` (its styles and theme
43
+ * tokens then reach the popup), else the body.
44
+ */
45
+ export function tooltipContainerOf(trigger: Element): HTMLElement | null {
46
+ return trigger.closest<HTMLElement>('dialog[open], [data-pn-tooltip-root]');
47
+ }
48
+
49
+ export function HostTitle({
50
+ title,
51
+ children,
52
+ name = false,
53
+ cut = false,
54
+ side = 'top',
55
+ }: {
56
+ title: string | undefined | null;
57
+ children: React.ReactElement;
58
+ name?: boolean;
59
+ cut?: boolean;
60
+ side?: 'top' | 'right' | 'bottom' | 'left';
61
+ }) {
62
+ const descriptionId = React.useId();
63
+ const [container, setContainer] = React.useState<HTMLElement | null>(null);
64
+ if (title === undefined || title === null) return children;
65
+ // Off: the native title, byte for byte as before (an empty title included).
66
+ if (!componentTooltips) return React.cloneElement(children as React.ReactElement<{ title?: string }>, { title });
67
+ if (!title) return children;
68
+ const props = children.props as { 'aria-label'?: string; 'aria-describedby'?: string };
69
+ const labelled = props['aria-label'] != null;
70
+ // A title that was the control's only name becomes its aria-label. Any other
71
+ // title was its description (a native title is read as one), and a tooltip
72
+ // tags nothing for a screen reader: the text stays in a hidden element the
73
+ // child names with aria-describedby. Not for a cut label (the text is on
74
+ // screen) or a title equal to the control's own label.
75
+ const asName = name && !labelled;
76
+ const describe = !asName && !cut && props['aria-label'] !== title;
77
+ const extra: Record<string, string> = {};
78
+ if (asName) extra['aria-label'] = title;
79
+ if (describe) extra['aria-describedby'] = [props['aria-describedby'], descriptionId].filter(Boolean).join(' ');
80
+ const child = Object.keys(extra).length > 0 ? React.cloneElement(children as React.ReactElement<Record<string, string>>, extra) : children;
81
+ const tooltip = (
82
+ <Tooltip
83
+ content={title}
84
+ side={side}
85
+ wide
86
+ delayDuration={HOST_TITLE_DELAY_MS}
87
+ escapePassesThrough
88
+ shouldOpen={(trigger) => {
89
+ if (noHover() || (cut && !isTextCut(trigger))) return false;
90
+ setContainer(tooltipContainerOf(trigger));
91
+ return true;
92
+ }}
93
+ container={container}
94
+ >
95
+ {child}
96
+ </Tooltip>
97
+ );
98
+ if (!describe) return tooltip;
99
+ return (
100
+ <>
101
+ {tooltip}
102
+ {/* `hidden`: never drawn or read in browse mode; aria-describedby still reads it. */}
103
+ <span id={descriptionId} hidden data-host-title-description="">
104
+ {title}
105
+ </span>
106
+ </>
107
+ );
108
+ }