@plannotator/ui 0.22.0 → 0.24.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.
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types';
2
+ import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
3
3
  import { isCurrentUser } from '../utils/identity';
4
4
  import { ImageThumbnail } from './ImageThumbnail';
5
5
  import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -73,12 +73,18 @@ interface PanelProps {
73
73
  /** Committed direct edits to one or more documents. Rendered as pinned cards
74
74
  * above the annotation timeline with expandable unified diffs. */
75
75
  directEdits?: DirectEditsPanelItem[] | null;
76
+ /** Host slot rendered at the foot of each plan-annotation card (e.g. reply/
77
+ * resolve UI). The panel stays presentation-only; clicks inside the slot
78
+ * do not select the card. Default: nothing rendered. */
79
+ renderCardFooter?: (annotation: Annotation) => React.ReactNode;
80
+ /** Hide every mutation affordance (delete/edit buttons on all card kinds).
81
+ * Selection and scrolling still work. Default false — today's behavior. */
82
+ readOnly?: boolean;
76
83
  }
77
84
 
78
85
  export const AnnotationPanel: React.FC<PanelProps> = ({
79
86
  isOpen,
80
87
  annotations,
81
- blocks,
82
88
  onSelect,
83
89
  onDelete,
84
90
  onEdit,
@@ -97,6 +103,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
97
103
  otherFileAnnotations,
98
104
  onOtherFileAnnotationsClick,
99
105
  directEdits = null,
106
+ renderCardFooter,
107
+ readOnly = false,
100
108
  }) => {
101
109
  const isMobile = useIsMobile();
102
110
  const [copiedText, setCopiedText] = useState(false);
@@ -195,6 +203,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
195
203
  onSelect={() => onSelect(entry.annotation.id)}
196
204
  onDelete={() => onDelete(entry.annotation.id)}
197
205
  onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
206
+ readOnly={readOnly}
207
+ footer={renderCardFooter?.(entry.annotation)}
198
208
  />
199
209
  ) : (
200
210
  <CodeAnnotationCard
@@ -205,6 +215,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
205
215
  onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)}
206
216
  onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)}
207
217
  onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined}
218
+ readOnly={readOnly}
208
219
  />
209
220
  )
210
221
  ))}
@@ -221,7 +232,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
221
232
  <EditorAnnotationCard
222
233
  key={ann.id}
223
234
  annotation={ann}
224
- onDelete={() => onDeleteEditorAnnotation?.(ann.id)}
235
+ onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)}
225
236
  />
226
237
  ))}
227
238
  </>
@@ -418,7 +429,9 @@ const AnnotationCard: React.FC<{
418
429
  onSelect: () => void;
419
430
  onDelete: () => void;
420
431
  onEdit?: (updates: Partial<Annotation>) => void;
421
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
432
+ readOnly?: boolean;
433
+ footer?: React.ReactNode;
434
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => {
422
435
  const [isEditing, setIsEditing] = useState(false);
423
436
  const [editText, setEditText] = useState(annotation.text || '');
424
437
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -517,26 +530,28 @@ const AnnotationCard: React.FC<{
517
530
  <span className="text-[10px] text-muted-foreground/50 truncate">
518
531
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
519
532
  </span>
520
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
521
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
533
+ {!readOnly && (
534
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
535
+ {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
536
+ <button
537
+ type="button"
538
+ onClick={handleStartEdit}
539
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
540
+ title="Edit annotation"
541
+ >
542
+ <PencilIcon />
543
+ </button>
544
+ )}
522
545
  <button
523
546
  type="button"
524
- onClick={handleStartEdit}
525
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
526
- title="Edit annotation"
547
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
548
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
549
+ title="Delete annotation"
527
550
  >
528
- <PencilIcon />
551
+ <TrashCardIcon />
529
552
  </button>
530
- )}
531
- <button
532
- type="button"
533
- onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
534
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
535
- title="Delete annotation"
536
- >
537
- <TrashCardIcon />
538
- </button>
539
- </div>
553
+ </div>
554
+ )}
540
555
  </div>
541
556
 
542
557
  {/* Global Comment - show text directly */}
@@ -585,6 +600,19 @@ const AnnotationCard: React.FC<{
585
600
  ))}
586
601
  </div>
587
602
  )}
603
+
604
+ {/* Host footer slot (reply/resolve UI etc.) — interactions inside it
605
+ must not toggle card selection. */}
606
+ {footer != null && footer !== false && (
607
+ <div
608
+ data-annotation-card-footer="true"
609
+ className="mt-2"
610
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
611
+ onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()}
612
+ >
613
+ {footer}
614
+ </div>
615
+ )}
588
616
  </div>
589
617
  );
590
618
  };
@@ -596,7 +624,8 @@ const CodeAnnotationCard: React.FC<{
596
624
  onSelect: () => void;
597
625
  onDelete: () => void;
598
626
  onEdit?: (updates: Partial<CodeAnnotation>) => void;
599
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
627
+ readOnly?: boolean;
628
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
600
629
  const [isEditing, setIsEditing] = useState(false);
601
630
  const [editText, setEditText] = useState(annotation.text || '');
602
631
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -650,26 +679,28 @@ const CodeAnnotationCard: React.FC<{
650
679
  <span className="text-[10px] text-muted-foreground/50 truncate">
651
680
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
652
681
  </span>
653
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
654
- {onEdit && !isEditing && (
682
+ {!readOnly && (
683
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
684
+ {onEdit && !isEditing && (
685
+ <button
686
+ type="button"
687
+ onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
688
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
689
+ title="Edit annotation"
690
+ >
691
+ <PencilIcon />
692
+ </button>
693
+ )}
655
694
  <button
656
695
  type="button"
657
- onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
658
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
659
- title="Edit annotation"
696
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
697
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
698
+ title="Delete annotation"
660
699
  >
661
- <PencilIcon />
700
+ <TrashCardIcon />
662
701
  </button>
663
- )}
664
- <button
665
- type="button"
666
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
667
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
668
- title="Delete annotation"
669
- >
670
- <TrashCardIcon />
671
- </button>
672
- </div>
702
+ </div>
703
+ )}
673
704
  </div>
674
705
 
675
706
  {/* File / line meta */}
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
1
+ import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationType } from "../types";
3
3
  import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Block } from "../types";
2
+ import type { Block } from "../types";
3
3
  import { InlineMarkdown } from "./InlineMarkdown";
4
4
  import { ListItemBody } from "./ListItemBody";
5
5
  import { CodeBlock } from "./blocks/CodeBlock";
@@ -14,7 +14,8 @@ type EditorAnnotationVariant = 'plan' | 'code-review';
14
14
 
15
15
  interface EditorAnnotationCardProps {
16
16
  annotation: EditorAnnotation;
17
- onDelete: () => void;
17
+ /** Omit to render the card read-only (no delete affordance). */
18
+ onDelete?: () => void;
18
19
  variant?: EditorAnnotationVariant;
19
20
  }
20
21
 
@@ -38,15 +39,17 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
38
39
  <span className="text-[10px] font-mono text-muted-foreground/50 truncate" title={annotation.filePath}>
39
40
  {annotation.filePath}:{lineRange}
40
41
  </span>
41
- <button
42
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
43
- className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
44
- title="Delete annotation"
45
- >
46
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
47
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
48
- </svg>
49
- </button>
42
+ {onDelete && (
43
+ <button
44
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
45
+ className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
46
+ title="Delete annotation"
47
+ >
48
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
49
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
50
+ </svg>
51
+ </button>
52
+ )}
50
53
  </div>
51
54
 
52
55
  {/* Selected text */}
@@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { Canvas } from './Canvas';
3
3
  import { Toolbar } from './Toolbar';
4
4
  import { renderStroke } from './utils';
5
- import type { Point, Stroke, Tool, AnnotatorState } from './types';
5
+ import type { Point, AnnotatorState } from './types';
6
6
  import { DEFAULT_STATE } from './types';
7
7
 
8
8
  interface ImageAnnotatorProps {
@@ -212,22 +212,24 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
212
212
  )}
213
213
  {/* Chevron (with primary) or standalone overflow (copy-only). */}
214
214
  <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
215
- <DropdownMenuTrigger asChild>
216
- <button
217
- type="button"
218
- disabled={isDisabled}
219
- className={`text-xs flex items-center py-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed ${
220
- openable ? 'px-1 border-l border-border/50' : 'px-1.5'
221
- }`}
222
- title={openable ? 'Open in…' : 'File actions'}
223
- aria-label={openable ? 'Choose app to open in' : 'File actions'}
224
- >
225
- {openable ? (
226
- <ChevronDown className="w-3 h-3" />
227
- ) : (
228
- <MoreHorizontal className="w-3.5 h-3.5" />
229
- )}
230
- </button>
215
+ <DropdownMenuTrigger
216
+ render={
217
+ <button
218
+ type="button"
219
+ disabled={isDisabled}
220
+ className={`text-xs flex items-center py-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed ${
221
+ openable ? 'px-1 border-l border-border/50' : 'px-1.5'
222
+ }`}
223
+ title={openable ? 'Open in…' : 'File actions'}
224
+ aria-label={openable ? 'Choose app to open in' : 'File actions'}
225
+ />
226
+ }
227
+ >
228
+ {openable ? (
229
+ <ChevronDown className="w-3 h-3" />
230
+ ) : (
231
+ <MoreHorizontal className="w-3.5 h-3.5" />
232
+ )}
231
233
  </DropdownMenuTrigger>
232
234
  <DropdownMenuContent
233
235
  align="end"
@@ -235,7 +237,7 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
235
237
  className="min-w-[12rem]"
236
238
  // Don't snap focus (and its focus ring) back onto the trigger when
237
239
  // the menu closes — that left-edge bar reads as a stray artifact.
238
- onCloseAutoFocus={(event) => event.preventDefault()}
240
+ finalFocus={false}
239
241
  >
240
242
  {openable &&
241
243
  grouped.map((group, gi) => (
@@ -244,10 +246,8 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
244
246
  {group.map((app) => (
245
247
  <DropdownMenuItem
246
248
  key={app.id}
247
- onSelect={(e) => {
248
- e.preventDefault();
249
- selectApp(app.id);
250
- }}
249
+ closeOnClick={false}
250
+ onClick={() => selectApp(app.id)}
251
251
  className="text-xs"
252
252
  >
253
253
  <AppIcon id={app.icon} className="w-4 h-4" />
@@ -260,10 +260,8 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
260
260
  {openable && (filePath || diffText) && <DropdownMenuSeparator />}
261
261
  {filePath && (
262
262
  <DropdownMenuItem
263
- onSelect={(e) => {
264
- e.preventDefault();
265
- void copyText(filePath);
266
- }}
263
+ closeOnClick={false}
264
+ onClick={() => void copyText(filePath)}
267
265
  className="text-xs"
268
266
  >
269
267
  <Copy className="w-4 h-4" />
@@ -272,10 +270,8 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
272
270
  )}
273
271
  {diffText && (
274
272
  <DropdownMenuItem
275
- onSelect={(e) => {
276
- e.preventDefault();
277
- void copyText(diffText);
278
- }}
273
+ closeOnClick={false}
274
+ onClick={() => void copyText(diffText)}
279
275
  className="text-xs"
280
276
  >
281
277
  <Copy className="w-4 h-4" />
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback } from 'react';
2
- import * as Dialog from '@radix-ui/react-dialog';
2
+ import { Dialog } from '@base-ui/react/dialog';
3
3
 
4
4
  const ANNOTATION_SELECTORS = [
5
5
  '.annotation-toolbar',
@@ -14,7 +14,7 @@ interface PopoutDialogProps {
14
14
  container?: HTMLElement | null;
15
15
  className?: string;
16
16
  children: React.ReactNode;
17
- /** Extra data attributes to spread onto Dialog.Content */
17
+ /** Extra data attributes to spread onto the dialog popup */
18
18
  dataAttributes?: Record<string, string>;
19
19
  }
20
20
 
@@ -36,55 +36,69 @@ export const PopoutDialog: React.FC<PopoutDialogProps> = ({
36
36
  [onClose],
37
37
  );
38
38
 
39
- const handleInteractOutside = useCallback(
40
- (e: Event) => {
41
- const target = e.target as Element | null;
42
- if (!target) return;
43
- if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) {
44
- e.preventDefault();
39
+ const handleOpenChange = useCallback(
40
+ (next: boolean, eventDetails: Dialog.Root.ChangeEventDetails) => {
41
+ if (next) return;
42
+ // Interactions with annotation UI (toolbars/popovers portal outside the
43
+ // dialog) must not dismiss the popout — the Radix version guarded this
44
+ // via onInteractOutside preventDefault; Base UI expresses it as
45
+ // cancelling the outside-press/focus-out close reason.
46
+ if (eventDetails.reason === 'outside-press' || eventDetails.reason === 'focus-out') {
47
+ // For focus events the annotation UI may sit on either side of the
48
+ // event (target = element losing focus, relatedTarget = gaining), so
49
+ // guard both.
50
+ const event = eventDetails.event as Event | undefined;
51
+ const candidates = [
52
+ event?.target as Element | null,
53
+ (event as FocusEvent | undefined)?.relatedTarget as Element | null,
54
+ ];
55
+ if (
56
+ candidates.some(
57
+ (el) => el instanceof Element && ANNOTATION_SELECTORS.some((sel) => el.closest(sel)),
58
+ )
59
+ ) {
60
+ eventDetails.cancel();
61
+ return;
62
+ }
45
63
  }
64
+ onClose();
46
65
  },
47
- [],
66
+ [onClose],
48
67
  );
49
68
 
50
69
  return (
51
- <Dialog.Root
52
- open={open}
53
- onOpenChange={(next) => { if (!next) onClose(); }}
54
- modal={false}
55
- >
70
+ <Dialog.Root open={open} onOpenChange={handleOpenChange} modal={false}>
56
71
  <Dialog.Portal container={container ?? undefined}>
57
- {/* Radix ignores Dialog.Overlay when modal={false}, so we use a plain
58
- div for the backdrop. This gives us the dark scrim + blur while
59
- keeping annotation toolbars (which portal outside the dialog)
60
- interactive. */}
72
+ {/* Non-modal dialogs render no library backdrop, so we use a plain
73
+ div for the dark scrim + blur while keeping annotation toolbars
74
+ (which portal outside the dialog) interactive. */}
61
75
  <div
62
76
  className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px]"
63
77
  onClick={handleBackdropClick}
64
78
  aria-hidden="true"
65
79
  />
66
- <Dialog.Content
80
+ <Dialog.Popup
67
81
  className={`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden ${className ?? 'w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)]'}`}
68
82
  data-popout="true"
69
- aria-describedby={undefined}
70
- onOpenAutoFocus={(e) => e.preventDefault()}
71
- onInteractOutside={handleInteractOutside}
83
+ initialFocus={false}
72
84
  {...dataAttributes}
73
85
  >
74
86
  <Dialog.Title className="sr-only">{title}</Dialog.Title>
75
- <Dialog.Close asChild>
76
- <button
77
- className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
78
- aria-label="Close"
79
- >
80
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
81
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
82
- </svg>
83
- </button>
87
+ <Dialog.Close
88
+ render={
89
+ <button
90
+ className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
91
+ aria-label="Close"
92
+ />
93
+ }
94
+ >
95
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
96
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
97
+ </svg>
84
98
  </Dialog.Close>
85
99
 
86
100
  {children}
87
- </Dialog.Content>
101
+ </Dialog.Popup>
88
102
  </Dialog.Portal>
89
103
  </Dialog.Root>
90
104
  );
@@ -1,27 +1,33 @@
1
- import * as PopoverPrimitive from '@radix-ui/react-popover';
1
+ import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
2
2
  import React from 'react';
3
3
 
4
4
  const Popover = PopoverPrimitive.Root;
5
5
  const PopoverTrigger = PopoverPrimitive.Trigger;
6
- const PopoverAnchor = PopoverPrimitive.Anchor;
7
6
 
8
7
  const PopoverContent = React.forwardRef<
9
- React.ComponentRef<typeof PopoverPrimitive.Content>,
10
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
11
- >(({ className, align = 'center', sideOffset = 6, children, ...props }, ref) => (
8
+ HTMLDivElement,
9
+ PopoverPrimitive.Popup.Props &
10
+ Pick<PopoverPrimitive.Positioner.Props, 'side' | 'sideOffset' | 'align' | 'alignOffset'>
11
+ >(({ className, side, align = 'center', sideOffset = 6, alignOffset, children, ...props }, ref) => (
12
12
  <PopoverPrimitive.Portal>
13
- <PopoverPrimitive.Content
14
- ref={ref}
13
+ <PopoverPrimitive.Positioner
14
+ side={side}
15
15
  align={align}
16
16
  sideOffset={sideOffset}
17
- className={`bg-popover text-popover-foreground border border-border/50 rounded-lg shadow-xl z-[100] outline-none popover-enter ${className ?? ''}`}
18
- {...props}
17
+ alignOffset={alignOffset}
18
+ className="isolate z-[100]"
19
19
  >
20
- {children}
21
- </PopoverPrimitive.Content>
20
+ <PopoverPrimitive.Popup
21
+ ref={ref}
22
+ className={`bg-popover text-popover-foreground border border-border/50 rounded-lg shadow-xl z-[100] outline-none origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${className ?? ''}`}
23
+ {...props}
24
+ >
25
+ {children}
26
+ </PopoverPrimitive.Popup>
27
+ </PopoverPrimitive.Positioner>
22
28
  </PopoverPrimitive.Portal>
23
29
  ));
24
30
 
25
31
  PopoverContent.displayName = 'PopoverContent';
26
32
 
27
- export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
33
+ export { Popover, PopoverTrigger, PopoverContent };
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useRef, useCallback, type ReactNode } from 'react';
1
+ import React, { useState, useMemo, useRef, useCallback, type ReactElement, type ReactNode } from 'react';
2
2
  import { Popover, PopoverTrigger, PopoverContent } from './Popover';
3
3
 
4
4
  interface SearchableSelectProps<T extends { id: string }> {
@@ -7,7 +7,7 @@ interface SearchableSelectProps<T extends { id: string }> {
7
7
  onSelect: (item: T) => void;
8
8
  filterFn: (item: T, query: string) => boolean;
9
9
  renderItem: (item: T, state: { isSelected: boolean; isFocused: boolean }) => ReactNode;
10
- renderTrigger: (state: { open: boolean }) => ReactNode;
10
+ renderTrigger: (state: { open: boolean }) => ReactElement;
11
11
  headerContent?: ReactNode;
12
12
  placeholder?: string;
13
13
  emptyMessage?: string;
@@ -85,14 +85,11 @@ export function SearchableSelect<T extends { id: string }>({
85
85
 
86
86
  return (
87
87
  <Popover open={open} onOpenChange={handleOpenChange}>
88
- <PopoverTrigger asChild>{renderTrigger({ open })}</PopoverTrigger>
88
+ <PopoverTrigger render={renderTrigger({ open })} />
89
89
  <PopoverContent
90
90
  align={align}
91
91
  className={`${width} p-0`}
92
- onOpenAutoFocus={(e) => {
93
- e.preventDefault();
94
- inputRef.current?.focus();
95
- }}
92
+ initialFocus={inputRef}
96
93
  >
97
94
  {/* Search row */}
98
95
  <div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">
@@ -1,11 +1,39 @@
1
1
  import React from 'react';
2
- import * as RadixTooltip from '@radix-ui/react-tooltip';
2
+ import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
3
3
 
4
- export const TooltipProvider = RadixTooltip.Provider;
4
+ /**
5
+ * TooltipProvider keeps the Radix-era prop names (`delayDuration`,
6
+ * `skipDelayDuration`, `disableHoverableContent`) as its public API and maps
7
+ * them onto Base UI's Provider (`delay`, `timeout`) — call sites in the apps
8
+ * stay unchanged. `disableHoverableContent` has no provider-level equivalent
9
+ * in Base UI (it moved to per-root `disableHoverablePopup`), so it is carried
10
+ * via context and applied to every Tooltip root under the provider.
11
+ */
12
+ const DisableHoverablePopupContext = React.createContext(false);
13
+
14
+ interface TooltipProviderProps {
15
+ delayDuration?: number;
16
+ skipDelayDuration?: number;
17
+ disableHoverableContent?: boolean;
18
+ children?: React.ReactNode;
19
+ }
20
+
21
+ export const TooltipProvider: React.FC<TooltipProviderProps> = ({
22
+ delayDuration,
23
+ skipDelayDuration,
24
+ disableHoverableContent = false,
25
+ children,
26
+ }) => (
27
+ <BaseTooltip.Provider delay={delayDuration} timeout={skipDelayDuration}>
28
+ <DisableHoverablePopupContext.Provider value={disableHoverableContent}>
29
+ {children}
30
+ </DisableHoverablePopupContext.Provider>
31
+ </BaseTooltip.Provider>
32
+ );
5
33
 
6
34
  interface TooltipProps {
7
35
  content: React.ReactNode;
8
- children: React.ReactNode;
36
+ children: React.ReactElement;
9
37
  side?: 'top' | 'right' | 'bottom' | 'left';
10
38
  align?: 'start' | 'center' | 'end';
11
39
  delayDuration?: number;
@@ -26,20 +54,22 @@ export const Tooltip: React.FC<TooltipProps> = ({
26
54
  delayDuration,
27
55
  sideOffset = 8,
28
56
  wide = false,
29
- }) => (
30
- <RadixTooltip.Root delayDuration={delayDuration}>
31
- <RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
32
- <RadixTooltip.Portal>
33
- <RadixTooltip.Content
34
- side={side}
35
- align={align}
36
- sideOffset={sideOffset}
37
- className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--radix-tooltip-content-transform-origin)] transition-[opacity,transform] duration-150 ease-out data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=delayed-open]:opacity-100 data-[state=delayed-open]:scale-100 data-[state=instant-open]:opacity-100 data-[state=instant-open]:scale-100 ${
38
- wide ? 'max-w-[260px] leading-snug whitespace-normal' : 'whitespace-nowrap'
39
- }`}
40
- >
41
- {content}
42
- </RadixTooltip.Content>
43
- </RadixTooltip.Portal>
44
- </RadixTooltip.Root>
45
- );
57
+ }) => {
58
+ const disableHoverablePopup = React.useContext(DisableHoverablePopupContext);
59
+ return (
60
+ <BaseTooltip.Root disableHoverablePopup={disableHoverablePopup}>
61
+ <BaseTooltip.Trigger render={children} delay={delayDuration} />
62
+ <BaseTooltip.Portal>
63
+ <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-50">
64
+ <BaseTooltip.Popup
65
+ className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
66
+ wide ? 'max-w-[260px] leading-snug whitespace-normal' : 'whitespace-nowrap'
67
+ }`}
68
+ >
69
+ {content}
70
+ </BaseTooltip.Popup>
71
+ </BaseTooltip.Positioner>
72
+ </BaseTooltip.Portal>
73
+ </BaseTooltip.Root>
74
+ );
75
+ };