change bulk selection panel to allow more versatile input (#4394)

# Description of Changes

- Add features to BulkSelectionPanel to allow more versatility when
selecting pages
- Make changes to Tooltip to: Remove non-existent props delayAppearance,
fixed defaults no hardcoded maxWidth, and documented new props
(closeOnOutside, containerStyle, minWidth). Clarify pinned vs. unpinned
outside-click logic, hover/focus interactions, and event/ref
preservation.
- Made top controls show full text always rather than dynamically
display the text only for the selected items
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
EthanHealy01
2025-09-18 11:19:52 +01:00
committed by GitHub
parent 06e5205302
commit d2de8e54aa
22 changed files with 2323 additions and 441 deletions

View File

@@ -11,6 +11,7 @@ import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
export default function RightRail() {
const { t } = useTranslation();
@@ -111,50 +112,13 @@ export default function RightRail() {
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
// CSV parsing functions for page selection
const parseCSVInput = useCallback((csv: string) => {
const pageNumbers: number[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end; i++) {
if (i > 0) {
pageNumbers.push(i);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0) {
pageNumbers.push(pageNum);
}
}
});
return pageNumbers;
}, []);
const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput);
// Use PageEditor's total pages for validation
const updatePagesFromCSV = useCallback((override?: string) => {
const maxPages = pageEditorFunctions?.totalPages || 0;
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
// Use PageEditor's function to set selected pages
const normalized = parseSelection(override ?? csvInput, maxPages);
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]);
}, [csvInput, pageEditorFunctions]);
// Sync csvInput with PageEditor's selected pages
useEffect(() => {
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
? pageEditorFunctions.selectedPageIds.map(id => {
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0).sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [pageEditorFunctions?.selectedPageIds]);
// Do not overwrite user's expression input when selection changes.
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
@@ -260,7 +224,7 @@ export default function RightRail() {
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: 280 }}>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}

View File

@@ -1,12 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css'
import styles from './tooltip/Tooltip.module.css';
export interface TooltipProps {
sidebarTooltip?: boolean;
@@ -21,12 +21,12 @@ export interface TooltipProps {
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: {
title: string;
logo?: React.ReactNode;
};
header?: { title: string; logo?: React.ReactNode };
delay?: number;
containerStyle?: React.CSSProperties;
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@@ -44,57 +44,41 @@ export const Tooltip: React.FC<TooltipProps> = ({
portalTarget,
header,
delay = 0,
containerStyle={},
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = () => {
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
// Get sidebar context for tooltip positioning
}, []);
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
// Always use controlled mode - if no controlled props provided, use internal state
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const open = isControlled ? !!controlledOpen : internalOpen;
const handleOpenChange = (newOpen: boolean) => {
clearTimers();
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setInternalOpen(newOpen);
}
const setOpen = useCallback(
(newOpen: boolean) => {
if (newOpen === open) return; // avoid churn
if (isControlled) onOpenChange?.(newOpen);
else setInternalOpen(newOpen);
if (!newOpen) setIsPinned(false);
},
[isControlled, onOpenChange, open]
);
// Reset pin state when closing
if (!newOpen) {
setIsPinned(false);
}
};
const handleTooltipClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPinned(true);
};
const handleDocumentClick = (e: MouseEvent) => {
// If tooltip is pinned and we click outside of it, unpin it
if (isPinned && isClickOutside(e, tooltipRef.current)) {
setIsPinned(false);
handleOpenChange(false);
}
};
// Use the positioning hook
const { coords, positionReady } = useTooltipPosition({
open,
sidebarTooltip,
@@ -103,56 +87,209 @@ export const Tooltip: React.FC<TooltipProps> = ({
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState
sidebarState: sidebarContext?.sidebarState,
});
// Add document click listener for unpinning
// Close on outside click: pinned → close; not pinned → optionally close
const handleDocumentClick = useCallback(
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
if (!insideTooltip && !insideTrigger) {
setIsPinned(false);
setOpen(false);
}
return;
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
);
useEffect(() => {
if (isPinned) {
// Attach global click when open (so hover tooltips can also close on outside if desired)
if (open || isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [isPinned]);
}, [open, isPinned, handleDocumentClick]);
useEffect(() => {
return () => {
clearTimers();
};
}, []);
useEffect(() => () => clearTimers(), [clearTimers]);
const getArrowClass = () => {
// No arrow for sidebar tooltips
const arrowClass = useMemo(() => {
if (sidebarTooltip) return null;
const map: Record<NonNullable<TooltipProps['position']>, string> = {
top: 'tooltip-arrow-bottom',
bottom: 'tooltip-arrow-top',
left: 'tooltip-arrow-left',
right: 'tooltip-arrow-right',
};
return map[position] || map.right;
}, [position, sidebarTooltip]);
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
}
};
const getArrowStyleClass = useCallback(
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
'',
[]
);
const getArrowStyleClass = (arrowClass: string) => {
const styleKey = arrowClass.split(' ')[1];
// Handle both kebab-case and camelCase CSS module exports
return styles[styleKey as keyof typeof styles] ||
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
'';
};
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props]
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
},
[children.props]
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
},
[children.props]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
clearTimers();
if (pinOnClick) {
e.preventDefault?.();
e.stopPropagation?.();
if (!open) setOpen(true);
setIsPinned(true);
clickPendingRef.current = false;
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
);
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props]
);
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
clearTimers();
}, [clearTimers]);
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') originalRef(node);
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
},
'aria-describedby': open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
// Always mount when open so we can measure; hide until positioned to avoid flash
const shouldShowTooltip = open;
const tooltipElement = shouldShowTooltip ? (
<div
id={tooltipIdRef.current}
ref={tooltipRef}
role="tooltip"
tabIndex={-1}
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
minWidth: minWidth,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
@@ -160,7 +297,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
...containerStyle,
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={handleTooltipClick}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
<button
@@ -168,97 +305,48 @@ export const Tooltip: React.FC<TooltipProps> = ({
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
handleOpenChange(false);
setOpen(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && getArrowClass() && (
{arrow && !sidebarTooltip && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
style={coords.arrowOffset !== null ? {
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
} : undefined}
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
style={
coords.arrowOffset !== null
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
: undefined
}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
{header.logo || (
<img
src="/logo-tooltip.svg"
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
)}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent
content={content}
tips={tips}
/>
<TooltipContent content={content} tips={tips} />
</div>
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
clearTimers();
if (!isPinned) {
const effectiveDelay = Math.max(0, delay || 0);
openTimeoutRef.current = setTimeout(() => {
handleOpenChange(true);
}, effectiveDelay);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
clearTimers();
openTimeoutRef.current = null;
if (!isPinned) {
handleOpenChange(false);
}
(children.props as any)?.onMouseLeave?.(e);
};
const handleClick = (e: React.MouseEvent) => {
// Toggle pin state on click
if (open) {
setIsPinned(!isPinned);
} else {
clearTimers();
handleOpenChange(true);
setIsPinned(true);
}
(children.props as any)?.onClick?.(e);
};
// Take the child element and add tooltip behavior to it
const childWithTooltipHandlers = React.cloneElement(children as any, {
// Keep track of the element for positioning
ref: (node: HTMLElement) => {
triggerRef.current = node;
// Don't break if the child already has a ref
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') {
originalRef(node);
} else if (originalRef && typeof originalRef === 'object') {
originalRef.current = node;
}
},
// Add mouse events to show/hide tooltip
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onClick: handleClick,
});
return (
<>
{childWithTooltipHandlers}
{childWithHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};
};

View File

@@ -6,7 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { Tooltip } from "./Tooltip";
const viewOptionStyle = {
display: 'inline-flex',
@@ -18,7 +18,7 @@ const viewOptionStyle = {
}
// Build view options showing text only for current view; others icon-only with tooltip
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
{
label: (
@@ -35,35 +35,37 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
},
{
label: (
<Tooltip content="Page Editor" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
)}
</div>
</Tooltip>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
)}
</div>
),
value: "pageEditor",
},
{
label: (
<Tooltip content="Active Files" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
)}
</div>
</Tooltip>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
</div>
),
value: "fileEditor",
},

View File

@@ -1,177 +1,155 @@
# Tooltip Component
A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click.
A flexible, accessible tooltip component supporting regular positioning and special sidebar positioning, with optional clicktopin behavior. By default, it opens on hover/focus and can be pinned on click when `pinOnClick` is enabled.
## Features
---
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
- **Accessible**: Works with mouse interactions and click-to-pin functionality
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
- **Performance**: Memoized calculations and efficient event handling
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
## Highlights
* 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
* 📱 **Sidebar Aware**: Purposebuilt logic for sidebar/navigation contexts.
* **Accessible**: Keyboard and screenreader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
* 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
* 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
* **Efficient**: Memoized calculations and stable callbacks to minimize rerenders.
* 📜 **Scrollable Content**: When content exceeds max height.
* 📌 **ClicktoPin**: (Optional) Pin open; close via outside click or close button.
* 🔗 **LinkSafe**: Fully clickable links in descriptions, bullets, and custom content.
* 🖱️ **PointerFriendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
---
## Behavior
### Default Behavior (Controlled)
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
- **Click**: Click the trigger to pin the tooltip open
- **Click tooltip**: Pins the tooltip to keep it open
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
- **Click outside**: Unpins and closes the tooltip
- **Visual indicator**: Pinned tooltips have a blue border and close button
### Default
### Manual Control (Optional)
- Use `open` and `onOpenChange` props for complete external control
- Useful for complex state management or custom interaction patterns
* **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
* **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
* **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
* **Escape**: Press **Esc** to close.
### ClicktoPin (optional)
* Enable with `pinOnClick`.
* **Click trigger** (or tooltip) to pin open.
* **Click outside** **both** trigger and tooltip to close when pinned.
* Use the close button (X) to unpin and close.
> **Note**: Outsideclick closing when **not** pinned is configurable via `closeOnOutside` (default `true`).
---
## Installation
```tsx
import { Tooltip } from '@/components/shared';
```
---
## Basic Usage
```tsx
import { Tooltip } from '@/components/shared';
function MyComponent() {
return (
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
);
}
```
## API Reference
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip |
| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body |
| `children` | `ReactElement` | **required** | Element that triggers the tooltip |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) |
| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip |
| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip |
| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control |
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
### TooltipTip Interface
```typescript
interface TooltipTip {
title?: string; // Optional pill label
description?: string; // Optional description text (supports HTML including <a> tags)
bullets?: string[]; // Optional bullet points (supports HTML including <a> tags)
body?: React.ReactNode; // Optional custom JSX for this tip
}
```
## Usage Examples
### Default Behavior (Recommended)
```tsx
// Simple tooltip with hover and click-to-pin
<Tooltip content="This tooltip appears on hover and pins on click">
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
```
// Structured content with tips
<Tooltip
tips={[
{
title: "OCR Mode",
description: "Choose how to process text in your documents.",
bullets: [
"<strong>Auto</strong> skips pages that already contain text.",
"<strong>Force</strong> re-processes every page.",
"<strong>Strict</strong> stops if text is found.",
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
]
}
]}
header={{
title: "Basic Settings Overview",
logo: <img src="/logo.svg" alt="Logo" />
}}
With structured tips and a header:
```tsx
<Tooltip
tips={[{
title: 'OCR Mode',
description: 'Choose how to process text in your documents.',
bullets: [
'<strong>Auto</strong> skips pages that already contain text.',
'<strong>Force</strong> re-processes every page.',
'<strong>Strict</strong> stops if text is found.',
"<a href='https://docs.example.com' target='_blank' rel='noreferrer'>Learn more</a>",
],
}]}
header={{ title: 'Basic Settings Overview', logo: <img src="/logo.svg" alt="Logo" /> }}
>
<button>Settings</button>
</Tooltip>
```
---
## API
### `<Tooltip />` Props
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactElement` | **required** | The trigger element. Receives ARIA and event handlers. |
| `content` | `ReactNode` | `undefined` | Custom JSX content rendered below any `tips`. |
| `tips` | `TooltipTip[]` | `undefined` | Structured content (title, description, bullets, optional body). |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic (no arrow in sidebar mode). |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Preferred placement (ignored if `sidebarTooltip` is `true`). |
| `offset` | `number` | `8` | Gap (px) between trigger and tooltip. |
| `maxWidth` | `number \| string` | `undefined` | Max width. If omitted and `sidebarTooltip` is true, defaults visually to \~`25rem`. |
| `minWidth` | `number \| string` | `undefined` | Min width. |
| `open` | `boolean` | `undefined` | Controlled open state. If provided, the component is controlled. |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state would change. |
| `arrow` | `boolean` | `false` | Shows a directional arrow (suppressed in sidebar mode). |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into. |
| `header` | `{ title: string; logo?: ReactNode }` | `undefined` | Optional header with title and logo. |
| `delay` | `number` | `0` | Hover/focus open delay in ms. |
| `containerStyle` | `React.CSSProperties` | `{}` | Inline style overrides for the tooltip container. |
| `pinOnClick` | `boolean` | `false` | Clicking the trigger pins the tooltip open. |
| `closeOnOutside` | `boolean` | `true` | When not pinned, clicking outside closes the tooltip. Always closes when pinned and clicking outside both trigger & tooltip. |
### `TooltipTip`
```ts
export interface TooltipTip {
title?: string; // Optional pill label
description?: string; // HTML allowed (e.g., <a>)
bullets?: string[]; // HTML allowed in each string
body?: React.ReactNode; // Optional custom JSX
}
```
---
## Accessibility
* The tooltip container uses `role="tooltip"` and gets a stable `id`.
* The trigger receives `aria-describedby` when the tooltip is open.
* Opens on **focus** and closes on **blur** (unless pinned), supporting keyboard navigation.
* **Escape** closes the tooltip.
* Pointer events are mirrored with keyboard/focus for parity.
> Ensure custom triggers remain focusable (e.g., `button`, `a`, or add `tabIndex=0`).
---
## Interaction Details
* **Hover Timing**: Opening can be delayed via `delay`. Closing is immediate on pointer leave from both trigger and tooltip (unless pinned). Timers are cleared on state changes and unmounts.
* **Outside Clicks**: When pinned, clicking outside **both** the trigger and tooltip closes it. When not pinned, outside clicks close it if `closeOnOutside` is `true`.
* **Event Preservation**: Original child event handlers (`onClick`, `onPointerEnter`, etc.) are called after the tooltip augments them.
* **Refs**: The triggers existing `ref` (function or object) is preserved.
---
## Examples
### With Arrow
```tsx
<Tooltip content="Arrow tooltip" arrow position="top">
<button>Arrow tooltip</button>
</Tooltip>
```
### Optional Hover Delay
```tsx
// Show after a 1s hover
<Tooltip content="Appears after a long hover" delay={1000} />
// Custom long-hover duration (2 seconds)
<Tooltip content="Appears after 2s" delay={2000} />
```
### Custom JSX Content
```tsx
<Tooltip
content={
<div>
<h3>Custom Content</h3>
<p>Any JSX you want here</p>
<button>Action</button>
<a href="https://example.com">External link</a>
</div>
}
>
<button>Custom tooltip</button>
</Tooltip>
```
### Mixed Content (Tips + Custom JSX)
```tsx
<Tooltip
tips={[
{ title: "Section", description: "Description" }
]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
### Sidebar Tooltips
```tsx
// For items in a sidebar/navigation
<Tooltip
content="This tooltip appears to the right of the sidebar"
sidebarTooltip={true}
>
<div className="sidebar-item">
📁 File Manager
</div>
</Tooltip>
```
### With Arrows
```tsx
<Tooltip
content="Tooltip with arrow pointing to trigger"
arrow={true}
position="top"
>
<button>Arrow tooltip</button>
<Tooltip content="Appears after 1s" delay={1000}>
<button>Delayed</button>
</Tooltip>
```
@@ -180,63 +158,55 @@ interface TooltipTip {
```tsx
function ManualControlTooltip() {
const [open, setOpen] = useState(false);
return (
<Tooltip
content="Fully controlled tooltip"
open={open}
onOpenChange={setOpen}
>
<button onClick={() => setOpen(!open)}>
Toggle tooltip
</button>
<Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
<button onClick={() => setOpen(!open)}>Toggle tooltip</button>
</Tooltip>
);
}
```
## Click-to-Pin Interaction
### Sidebar Tooltip
### How to Use (Default Behavior)
1. **Hover** over the trigger element to show the tooltip
2. **Click** the trigger element to pin the tooltip open
3. **Click** the red X button in the top-right corner to close
4. **Click** anywhere outside the tooltip to close
5. **Click** the trigger again to toggle pin state
```tsx
<Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
<div className="sidebar-item">📁 File Manager</div>
</Tooltip>
```
### Visual States
- **Unpinned**: Normal tooltip appearance
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
### Mixed Content
## Link Support
```tsx
<Tooltip
tips={[{ title: 'Section', description: 'Description' }]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
The tooltip fully supports clickable links in all content areas:
---
- **Descriptions**: Use `<a href="...">` in description strings
- **Bullets**: Use `<a href="...">` in bullet point strings
- **Body**: Use JSX `<a>` elements in the body ReactNode
- **Content**: Use JSX `<a>` elements in custom content
## Positioning Notes
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
* Initial placement is derived from `position` (or sidebar rules when `sidebarTooltip` is true).
* Tooltip is clamped within the viewport; the arrow is offset to remain visually aligned with the trigger.
* Sidebar mode positions to the sidebars edge and clamps vertically. Arrows are disabled in sidebar mode.
## Positioning Logic
---
### Regular Tooltips
- Uses the `position` prop to determine initial placement
- Automatically clamps to viewport boundaries
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
## Caveats & Tips
## Timing Details
* Ensure your container doesnt block pointer events between trigger and tooltip.
* When using `portalTarget`, confirm its attached to `document.body` before rendering.
* For very dynamic layouts, call positioning after layout changes (the hook already listens to open/refs/viewport).
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
---
### Sidebar Tooltips
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
- **No arrows** - sidebar tooltips don't show arrows
## Changelog (since previous README)
* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
* Clarified outsideclick behavior for pinned vs unpinned.
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
* Removed references to nonexistent props (e.g., `delayAppearance`).
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).