@plannotator/ui 0.55.0 → 0.56.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 (42) hide show
  1. package/HANDOFF.md +43 -1
  2. package/README.md +81 -4
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/CodeFilePopout.tsx +6 -4
  6. package/components/ComposerTokenTextarea.tsx +43 -2
  7. package/components/DocBadges.tsx +47 -35
  8. package/components/ImageZoomStage.tsx +32 -4
  9. package/components/Settings.tsx +2 -1
  10. package/components/TableOfContents.tsx +2 -1
  11. package/components/Viewer.tsx +43 -17
  12. package/components/chat/ChatComposer.tsx +192 -0
  13. package/components/chat/ChatFrame.tsx +524 -0
  14. package/components/chat/ChatTranscript.tsx +422 -0
  15. package/components/chat/chatMarkdown.ts +436 -0
  16. package/components/chat/chatRecipes.tsx +39 -0
  17. package/components/chat/chatWindowStore.ts +196 -0
  18. package/components/chat/index.ts +72 -0
  19. package/components/diagram/DiagramCanvas.tsx +7 -0
  20. package/components/diagram/DiagramComposer.tsx +15 -3
  21. package/components/diagram/DiagramViewer.tsx +1 -0
  22. package/components/docBadgesWidth.ts +29 -0
  23. package/components/imageZoom.ts +10 -0
  24. package/components/inbox-thread/FileName.tsx +118 -0
  25. package/components/inbox-thread/GuideCard.tsx +39 -0
  26. package/components/inbox-thread/ReaskNote.tsx +25 -0
  27. package/components/inbox-thread/SkillsLine.tsx +40 -0
  28. package/components/inbox-thread/Tip.tsx +105 -0
  29. package/components/inbox-thread/icons.tsx +262 -0
  30. package/components/inbox-thread/reask.ts +222 -0
  31. package/hooks/useActiveSection.ts +46 -63
  32. package/hooks/useAnnotationHighlighter.ts +40 -1
  33. package/hooks/useFileBrowser.ts +4 -3
  34. package/inbox-thread.css +4542 -0
  35. package/package.json +10 -3
  36. package/styles.css +1 -1
  37. package/theme.css +1 -0
  38. package/themes/tokyo-night-moon.css +34 -0
  39. package/utils/aiChatFormat.ts +32 -11
  40. package/utils/headingScrollSpy.ts +19 -6
  41. package/utils/syntaxTheme.ts +1 -0
  42. package/utils/themeRegistry.ts +11 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A shared floating chat window: screen code only. The host owns the
3
+ * conversation, the transport and anything saved.
4
+ *
5
+ * const store = new ChatWindowStore({ initialGeometry, onGeometryChange });
6
+ * <div className="relative">
7
+ * <ChatFrame store={store} label="Chat" header={...} onClose={...}>
8
+ * {() => (
9
+ * <>
10
+ * <ChatTranscript messages={messages} status={status} agentName="Agent" />
11
+ * <ChatComposer value={value} onChange={setValue} onSend={send} />
12
+ * </>
13
+ * )}
14
+ * </ChatFrame>
15
+ * </div>
16
+ *
17
+ * THEME TOKENS. The classes below are not in `theme.css`, so a host defines
18
+ * them in its own `@theme`. With none defined a class has no rule and the
19
+ * property falls through to its default.
20
+ *
21
+ * --color-tertiary text-tertiary, the ink a step behind muted
22
+ * --color-surface-hover bg-surface-hover, the quiet hover wash
23
+ * --color-ring-soft ring-ring-soft, the focus halo
24
+ * --color-border-subtle border-border-subtle, a blockquote's rule
25
+ * --color-soft-quiet-fill bg-soft-quiet-fill, Stop's resting fill
26
+ * --color-soft-quiet-fill-hover bg-soft-quiet-fill-hover, Stop's hover
27
+ * --radius-dialog rounded-dialog, the window's corner
28
+ * --shadow-dialog shadow-dialog, the window's shadow
29
+ * --text-2xs text-2xs, the Stopped badge
30
+ *
31
+ * Everything else comes from `theme.css`: bg-card, bg-popover,
32
+ * text-popover-foreground, text-foreground, text-muted-foreground, bg-muted,
33
+ * text-primary, border-border, border-input, ring-ring.
34
+ */
35
+
36
+ export {
37
+ CHAT_WINDOW_DEFAULT,
38
+ CHAT_WINDOW_FRAME_MARGIN,
39
+ CHAT_WINDOW_MIN,
40
+ ChatWindowStore,
41
+ type ChatWindowGeometry,
42
+ type ChatWindowStoreOptions,
43
+ } from './chatWindowStore';
44
+ export {
45
+ CHAT_LAUNCHER_SELECTOR,
46
+ ChatFrame,
47
+ ChatFrameLauncher,
48
+ useChatFrameFocusReturn,
49
+ useChatFrameKeys,
50
+ type ChatFrameLabels,
51
+ type ChatFrameProps,
52
+ type ChatFrameRootProps,
53
+ type ChatFrameState,
54
+ } from './ChatFrame';
55
+ export { CHAT_COMPOSER_FIELD_BOX, ChatComposer, type ChatComposerProps } from './ChatComposer';
56
+ export {
57
+ ChatMarkdown,
58
+ ChatMessageView,
59
+ ChatPendingTurn,
60
+ ChatTranscript,
61
+ chatTurnHasOutput,
62
+ clipAtSentenceBoundary,
63
+ type ChatMessage,
64
+ type ChatMessagePart,
65
+ type ChatMessageRole,
66
+ type ChatPartContext,
67
+ type ChatStatus,
68
+ type ChatTextPart,
69
+ type ChatTranscriptProps,
70
+ } from './ChatTranscript';
71
+ export { chatMarkdownHtml, personMarkdownHtml } from './chatMarkdown';
72
+ export { FOCUS_RING, floatingDialogChrome } from './chatRecipes';
@@ -247,6 +247,13 @@ export function DiagramCanvas({
247
247
  pressRef.current = null;
248
248
  return;
249
249
  }
250
+ // A second finger makes it a pinch: neither finger is a tap on a part or
251
+ // a pan any more, so the press ends here.
252
+ if (event.pointerType === 'touch' && !event.isPrimary) {
253
+ if (pressRef.current?.panning) setPanning(false);
254
+ pressRef.current = null;
255
+ return;
256
+ }
250
257
  pressRef.current = {
251
258
  id: event.pointerId,
252
259
  x: event.clientX,
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
1
+ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
2
2
  import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
3
3
  import { cn } from '../../lib/utils';
4
4
  import type { ScreenRect } from '../../utils/diagram-projection';
@@ -30,6 +30,7 @@ export function DiagramComposer({
30
30
  draft,
31
31
  anchorRect,
32
32
  hostWidth,
33
+ hostHeight = 0,
33
34
  sourceDirty,
34
35
  submitting,
35
36
  error,
@@ -41,6 +42,8 @@ export function DiagramComposer({
41
42
  draft: DiagramComposerDraft;
42
43
  anchorRect: ScreenRect;
43
44
  hostWidth: number;
45
+ /** The canvas's height, to keep the composer inside it. */
46
+ hostHeight?: number;
44
47
  sourceDirty: boolean;
45
48
  submitting: boolean;
46
49
  /** The last submit's failure, shown under the textarea. */
@@ -73,6 +76,14 @@ export function DiagramComposer({
73
76
  const canWrite = disabledReason === undefined && !unsavedPart;
74
77
  const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
75
78
  const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
79
+ // The canvas clips what overflows it: a part near the bottom of a short
80
+ // canvas (an inline diagram on a phone) lifts the composer until it all shows.
81
+ const rootRef = useRef<HTMLDivElement | null>(null);
82
+ const [height, setHeight] = useState(0);
83
+ useLayoutEffect(() => {
84
+ setHeight(rootRef.current?.offsetHeight ?? 0);
85
+ });
86
+ const top = Math.max(0, hostHeight > 0 && height > 0 ? Math.min(anchorRect.top, hostHeight - height) : anchorRect.top);
76
87
 
77
88
  const canAskAI = onAskAI !== undefined && !asking && !submitting && text.trim() !== '';
78
89
  const askAI = async () => {
@@ -113,11 +124,12 @@ export function DiagramComposer({
113
124
 
114
125
  return (
115
126
  <div
127
+ ref={rootRef}
116
128
  data-diagram-composer=""
117
129
  role="dialog"
118
130
  aria-label={`Comment on ${diagramTargetText(target)}`}
119
- className="pointer-events-auto absolute z-10 flex w-72 flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
120
- style={{ left, top: Math.max(0, anchorRect.top) }}
131
+ className="pointer-events-auto absolute z-10 flex w-72 max-w-full flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
132
+ style={{ left, top }}
121
133
  onPointerDown={(event) => event.stopPropagation()}
122
134
  onPointerUp={(event) => event.stopPropagation()}
123
135
  onKeyDown={(event) => {
@@ -265,6 +265,7 @@ export function DiagramViewer({
265
265
  draft={commentsState.composer}
266
266
  anchorRect={anchorRect}
267
267
  hostWidth={handle.hostRef.current?.getBoundingClientRect().width ?? 0}
268
+ hostHeight={handle.hostRef.current?.getBoundingClientRect().height ?? 0}
268
269
  sourceDirty={sourceDirty}
269
270
  submitting={commentsState.submitting}
270
271
  error={commentsState.submitError}
@@ -0,0 +1,29 @@
1
+ /** A box's viewport edges, as `getBoundingClientRect()` reports them. */
2
+ export interface BoxEdges {
3
+ readonly left: number;
4
+ readonly right: number;
5
+ readonly top: number;
6
+ readonly bottom: number;
7
+ }
8
+
9
+ /** Space kept between the badge cluster and the action cluster. */
10
+ const ACTIONS_GAP = 8;
11
+
12
+ /**
13
+ * The widest the legacy (absolutely positioned) badge cluster may grow before
14
+ * it runs under the document's action cluster, so a long file name truncates
15
+ * where the buttons start rather than at a fixed width.
16
+ *
17
+ * When no actions share the row (there are none, they have not been measured
18
+ * yet, or the phone layout drops them below the badges), the cluster stops
19
+ * inside the card instead, keeping the same inset on the right as on the left,
20
+ * so the name truncates and the open-in button stays on screen. Null leaves
21
+ * it unbounded while the card has no measured width (hidden, not laid out).
22
+ */
23
+ export function legacyBadgesMaxWidth(badges: BoxEdges, actions: BoxEdges | null, card: BoxEdges): number | null {
24
+ const inCard = card.right > card.left ? Math.max(0, card.right - badges.left - (badges.left - card.left)) : null;
25
+ if (!actions || actions.right <= actions.left) return inCard;
26
+ const sharesRow = actions.top < badges.bottom && actions.bottom > badges.top;
27
+ if (!sharesRow) return inCard;
28
+ return Math.max(0, actions.left - badges.left - ACTIONS_GAP);
29
+ }
@@ -32,6 +32,16 @@ export interface ZoomInset {
32
32
  readonly y: number;
33
33
  }
34
34
 
35
+ /**
36
+ * Rows of a stage that things float over: Fit and the pan's limits work in the
37
+ * stage between them, while a zoomed image still slides under them and every
38
+ * row of the stage still takes the fingers.
39
+ */
40
+ export interface ZoomClear {
41
+ readonly top: number;
42
+ readonly bottom: number;
43
+ }
44
+
35
45
  /** 800%: an image pixel is then 8 screen pixels, enough to read any screenshot. */
36
46
  export const LIGHTBOX_ZOOM_MAX = 8;
37
47
  /** Wheel zoom: factor = exp(-deltaY * sensitivity), with deltaY capped so a
@@ -0,0 +1,118 @@
1
+ import { useEffect } from 'react';
2
+ import { sliceText } from '@plannotator/core/inbox-questions';
3
+ import { Tip } from './Tip';
4
+
5
+ /**
6
+ * A file name that is cut in the middle when it runs short, never at its
7
+ * end: the end is what tells two files apart (the date, the version, the
8
+ * extension), so "SPIKE-claude-code-mobile-annotations-20261012.md" reads
9
+ * "SPIKE-claude-…-20261012.md" in a tile, not "SPIKE-claude-code-mob…".
10
+ * The same two-part pattern code review's file rows use: the start shrinks
11
+ * with an ellipsis, the end keeps its width. The full name is the hover
12
+ * text, and the direction follows the name (`dir="auto"`), so an Arabic name
13
+ * reads right to left and is cut at its own end.
14
+ *
15
+ * `wrap` draws the whole name, broken where it must, for places with room
16
+ * (the download card). A cut name gets its whole name in a tooltip (Tip)
17
+ * only while it is cut.
18
+ */
19
+ export function FileName({ name, className, wrap = false }: { name: string; className?: string; wrap?: boolean }) {
20
+ useEffect(keepNamesWholeOnCopy, []);
21
+ const [head, tail] = splitFileName(name);
22
+ const cls = `ib-fname${wrap ? ' ib-fname-wrap' : ''}${className ? ` ${className}` : ''}`;
23
+ if (wrap) {
24
+ return (
25
+ <span className={cls} dir="auto">
26
+ <span className="ib-fname-h">{name}</span>
27
+ </span>
28
+ );
29
+ }
30
+ if (!tail) {
31
+ return (
32
+ <Tip content={name} cut>
33
+ <span className={cls} dir="auto">
34
+ <span className="ib-fname-h">{name}</span>
35
+ </span>
36
+ </Tip>
37
+ );
38
+ }
39
+ // The two parts are flex items, which a screen reader would read apart
40
+ // ("refund -policy.pdf"): they are hidden from it, and it hears the whole
41
+ // name from `data-name` (inbox.css draws it as a visually hidden ::after).
42
+ // The text content stays the name.
43
+ return (
44
+ <Tip content={name} cut>
45
+ <span className={cls} dir="auto" data-name={name}>
46
+ <span className="ib-fname-h" aria-hidden="true">
47
+ {head}
48
+ </span>
49
+ <span className="ib-fname-t" aria-hidden="true">
50
+ {tail}
51
+ </span>
52
+ </span>
53
+ </Tip>
54
+ );
55
+ }
56
+
57
+ let copyListeners = 0;
58
+
59
+ /**
60
+ * The two parts of a cut name are flex items, so a selection's text puts a
61
+ * line break between them: a copy that takes in a cut name gets the name
62
+ * whole instead. Installed once while any FileName is on the page.
63
+ */
64
+ function keepNamesWholeOnCopy(): () => void {
65
+ if (copyListeners++ === 0) document.addEventListener('copy', onCopy);
66
+ return () => {
67
+ if (--copyListeners === 0) document.removeEventListener('copy', onCopy);
68
+ };
69
+ }
70
+
71
+ function onCopy(event: ClipboardEvent): void {
72
+ const selection = document.getSelection();
73
+ if (!selection || selection.isCollapsed || !event.clipboardData) return;
74
+ const text = selection.toString();
75
+ let whole = text;
76
+ for (const el of document.querySelectorAll<HTMLElement>('.ib-fname[data-name]')) {
77
+ if (!selection.containsNode(el, true)) continue;
78
+ const head = el.querySelector('.ib-fname-h')?.textContent ?? '';
79
+ const tail = el.querySelector('.ib-fname-t')?.textContent ?? '';
80
+ if (!head || !tail) continue;
81
+ // A selection across the cut holds the head's last character, the break, then the tail's first, however
82
+ // much of either part it took.
83
+ whole = whole.split(`${head.slice(-1)}\n${tail.slice(0, 1)}`).join(`${head.slice(-1)}${tail.slice(0, 1)}`);
84
+ }
85
+ if (whole === text) return;
86
+ event.clipboardData.setData('text/plain', whole);
87
+ event.preventDefault();
88
+ }
89
+
90
+ /** Names this short are never cut in the middle: there is nothing to keep apart. */
91
+ const SHORT = 16;
92
+ /** The most of the stem kept with the extension. */
93
+ const TAIL_STEM = 12;
94
+
95
+ /**
96
+ * The name split where a middle cut goes: `[start, end]`, the end being the
97
+ * extension and the last part of the stem (after its last `-`, `_`, `.` or
98
+ * space when that part is short, else its last few characters), never
99
+ * splitting a character. `end` is empty for a short name.
100
+ */
101
+ export function splitFileName(name: string): [string, string] {
102
+ if (name.length <= SHORT) return [name, ''];
103
+ const dot = name.lastIndexOf('.');
104
+ // An extension: a dot that is not the first character, 1 to 8 letters or digits after it.
105
+ const ext = dot > 0 && /^\.[\p{L}\p{N}]{1,8}$/u.test(name.slice(dot)) ? name.slice(dot) : '';
106
+ const stem = ext ? name.slice(0, -ext.length) : name;
107
+ const sep = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_'), stem.lastIndexOf(' '), stem.lastIndexOf('.'));
108
+ let keep: string;
109
+ if (sep > 0 && stem.length - sep <= TAIL_STEM + 1) keep = stem.slice(sep);
110
+ else {
111
+ // The last few characters, never half of one: cut the stem's start whole, keep what is left.
112
+ const start = sliceText(stem, Math.max(0, stem.length - 6));
113
+ keep = stem.slice(start.length);
114
+ }
115
+ const tail = `${keep}${ext}`;
116
+ const head = name.slice(0, name.length - tail.length);
117
+ return head ? [head, tail] : [name, ''];
118
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A guided review at the foot of the message that carries it (the record's
3
+ * 4.1): like any attachment, with its sections, files and line counts, and
4
+ * Open, which takes the window with Plannotator's guide viewer.
5
+ */
6
+
7
+ import type { InboxGuideRef } from '@plannotator/core/inbox-types';
8
+ import { formatCount, plural } from '@plannotator/core/inbox-format';
9
+ import { Icon } from './icons';
10
+ import { Tip } from './Tip';
11
+
12
+ export function GuideCard({ guide, onOpen }: { guide: InboxGuideRef; onOpen: () => void }) {
13
+ return (
14
+ <>
15
+ <div className="ib-attach-h">
16
+ <Icon name="clip" size={13} />1 attachment
17
+ </div>
18
+ <div className="ib-atts">
19
+ <button type="button" className="ib-att-t ib-guide" onClick={onOpen} data-guide-card="">
20
+ <span className="ib-fi">
21
+ <Icon name="book" size={18} />
22
+ </span>
23
+ <span className="ib-att-text">
24
+ <Tip content={`Guided review: ${guide.title}`} cut>
25
+ <span className="ib-nm">
26
+ Guided review: <bdi>{guide.title}</bdi>
27
+ </span>
28
+ </Tip>
29
+ <span className="ib-kd">
30
+ {plural(guide.sections, 'section')}, {plural(guide.files, 'file')}, <span className="ib-add">+{formatCount(guide.additions)}</span>{' '}
31
+ <span className="ib-del">-{formatCount(guide.deletions)}</span>
32
+ </span>
33
+ </span>
34
+ <span className="ib-open">Open</span>
35
+ </button>
36
+ </div>
37
+ </>
38
+ );
39
+ }
@@ -0,0 +1,25 @@
1
+ import type { InboxMessageWire } from '@plannotator/core/inbox-types';
2
+ import { Icon } from './icons';
3
+ import { reaskNoteSentence } from './reask';
4
+
5
+ /**
6
+ * The person's Re-ask note as it reads in the thread (approval "inbox-reask"):
7
+ * one line, and what the agent was sent behind a disclosure.
8
+ */
9
+ export function ReaskNote({ message, agent, picks }: { message: InboxMessageWire; agent: string; picks: number }) {
10
+ const asked = message.reask?.length ?? 0;
11
+ // Action items it asked about (ids `<message id>/a-…`): the line names them.
12
+ const actions = (message.reask ?? []).filter((id) => /\/a-[0-9a-f]/.test(id)).length;
13
+ return (
14
+ <div className="ib-reask" data-reask-note="">
15
+ <p className="ib-reask-l">
16
+ <Icon name="refresh" size={15} />
17
+ <span>{reaskNoteSentence(agent, asked, picks, actions)}</span>
18
+ </p>
19
+ <details className="ib-reask-d">
20
+ <summary>What {agent} was sent</summary>
21
+ <pre>{message.body}</pre>
22
+ </details>
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The quiet skills line under a person's message (approval
3
+ * "inbox-skills-line", option B, 2026-10-29): the box icon, then the names of
4
+ * the skills the reply used as a comma list. Nothing to open: no label, no
5
+ * size, no Show. The block the window pasted at the end of the message is
6
+ * split off by Thread.tsx's MessageBody; the agent's text is shown whole.
7
+ * The line sits in the message body, above the Delivered line, so it is in
8
+ * the same place whatever the state (sent, queued, answered). A long list
9
+ * wraps between names, never inside one.
10
+ */
11
+ import type { InboxSkillsSplit } from '@plannotator/core/skill-reference-block';
12
+
13
+ export function SkillsLine({ split }: { split: InboxSkillsSplit }) {
14
+ const names = split.skills.map((s) => s.name).join(', ');
15
+ return (
16
+ <div className="ib-skills" data-skills-line="" title={`Skills you referenced: ${names}`}>
17
+ <SkillCube />
18
+ <span className="ib-sr">Skills: </span>
19
+ <span className="ib-skills-n">
20
+ {split.skills.map((s, i) => (
21
+ <span key={s.name}>
22
+ <span className="ib-skill-nm" data-skill-name="">{s.name}</span>
23
+ {i < split.skills.length - 1 ? ', ' : ''}
24
+ </span>
25
+ ))}
26
+ </span>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ /** The cube Plannotator's skill picker shows beside each skill. */
32
+ function SkillCube() {
33
+ return (
34
+ <svg className="ib-skills-i" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
35
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
36
+ <path d="M3.29 7 12 12l8.71-5" />
37
+ <path d="M12 22V12" />
38
+ </svg>
39
+ );
40
+ }
@@ -0,0 +1,105 @@
1
+ import { useCallback, useState, type ReactElement, type ReactNode } from 'react';
2
+ import { Tooltip, TOOLTIP_DELAY_MS } from '../Tooltip';
3
+ import { isTextCut as isTextCutIn, noHover } from '../../utils/textCut';
4
+ import { tooltipContainerOf } from '../HostTitle';
5
+
6
+ /**
7
+ * ui's cut check (a whole pixel, or the text's Range wider than its box). A
8
+ * FileName (`.ib-fname`) counts as cut when its first part (`.ib-fname-h`) is.
9
+ */
10
+ export function isTextCut(el: Element | null | undefined): boolean {
11
+ return isTextCutIn(el, el?.classList.contains('ib-fname') ? '.ib-fname-h' : undefined);
12
+ }
13
+
14
+ /** How long the pointer rests before a tooltip opens (the list chips' timing). */
15
+ export const TIP_DELAY_MS = TOOLTIP_DELAY_MS;
16
+
17
+ /**
18
+ * The Inbox's hover text: ui's Tooltip (the component the list's chips use),
19
+ * never the browser's native `title`, which waits about a second and cannot
20
+ * be styled. The host's TooltipProvider sets the timing (the Inbox has one,
21
+ * in its App.tsx): 150 ms to open, the next one at once within 400 ms.
22
+ *
23
+ * `cut`: open only while the trigger's text is actually cut (an ellipsis, a
24
+ * clamp), so a name that fits gets no tooltip repeating it. Without `cut` it
25
+ * always opens. `when` decides each time instead, for anything else (a mark
26
+ * whose label is hidden).
27
+ *
28
+ * The trigger keeps its own accessible name (an `aria-label`, or its text):
29
+ * the tooltip adds nothing a screen reader needs, and a trigger that is not
30
+ * focusable stays out of the tab order. Nothing opens on a touch screen
31
+ * (`hover: none`): a hover there is a first tap. The popup is portaled into
32
+ * the open native `<dialog>` the trigger sits in (so the top layer does not
33
+ * cover it), else into the window's root (`data-pn-tooltip-root`), so the
34
+ * Soft switches' tokens reach it. Escape closes an open tooltip and still reaches the window
35
+ * (`escapePassesThrough`), so Escape closes a thread whatever holds focus.
36
+ */
37
+ export function Tip({
38
+ content,
39
+ children,
40
+ cut = false,
41
+ when,
42
+ side = 'top',
43
+ align,
44
+ wide = true,
45
+ open,
46
+ }: {
47
+ content: ReactNode;
48
+ children: ReactElement;
49
+ cut?: boolean;
50
+ when?: (trigger: Element) => boolean;
51
+ side?: 'top' | 'right' | 'bottom' | 'left';
52
+ align?: 'start' | 'center' | 'end';
53
+ wide?: boolean;
54
+ /**
55
+ * Shown whatever the pointer does while true (the list rows' badges, while
56
+ * their row has keyboard focus). Unset: hover decides, as for every tip.
57
+ */
58
+ open?: boolean;
59
+ }) {
60
+ const [container, setContainer] = useState<HTMLElement | null>(null);
61
+ const shouldOpen = useCallback(
62
+ (trigger: Element) => {
63
+ if (content === null || content === undefined || content === '') return false;
64
+ if (noHover()) return false;
65
+ if (cut && !isTextCut(trigger)) return false;
66
+ if (when && !when(trigger)) return false;
67
+ setContainer(tooltipContainerOf(trigger));
68
+ return true;
69
+ },
70
+ [content, cut, when],
71
+ );
72
+ // A disabled control gets no pointer events (and React drops mouse events on
73
+ // it), so its tooltip hangs on a wrapper the pointer reaches instead
74
+ // (inbox.css: `.ib-tipwrap > :disabled` lets the pointer through). Any
75
+ // control that can be disabled gets the wrapper, disabled or not, so the
76
+ // control is never remounted (and never loses focus) when it changes.
77
+ const canDisable = 'disabled' in (children.props as object);
78
+ const trigger = canDisable ? (
79
+ <span className="ib-tipwrap" data-tip-wrap="">
80
+ {children}
81
+ </span>
82
+ ) : (
83
+ children
84
+ );
85
+ return (
86
+ <Tooltip
87
+ content={content}
88
+ side={side}
89
+ align={align}
90
+ wide={wide}
91
+ delayDuration={TIP_DELAY_MS}
92
+ shouldOpen={shouldOpen}
93
+ // Opened without a hover, shouldOpen never named the trigger's root: the window's.
94
+ container={container ?? (open ? windowTooltipRoot() : null)}
95
+ escapePassesThrough
96
+ forceOpen={open}
97
+ >
98
+ {trigger}
99
+ </Tooltip>
100
+ );
101
+ }
102
+
103
+ function windowTooltipRoot(): HTMLElement | null {
104
+ return typeof document === 'undefined' ? null : document.querySelector<HTMLElement>('[data-pn-tooltip-root]');
105
+ }