mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/tooltips (#4112)
# Description of Changes - added tooltips to ocr and compress - added the tooltip component which can be used either directly, or through the toolstep component --- ## 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:
@@ -0,0 +1,223 @@
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
### Manual Control (Optional)
|
||||
- Use `open` and `onOpenChange` props for complete external control
|
||||
- Useful for complex state management or custom interaction patterns
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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">
|
||||
<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" />
|
||||
}}
|
||||
>
|
||||
<button>Settings</button>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### 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>
|
||||
```
|
||||
|
||||
### Manual Control (Advanced)
|
||||
|
||||
```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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Click-to-Pin Interaction
|
||||
|
||||
### 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
|
||||
|
||||
### Visual States
|
||||
- **Unpinned**: Normal tooltip appearance
|
||||
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
|
||||
|
||||
## Link Support
|
||||
|
||||
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
|
||||
|
||||
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,191 @@
|
||||
/* Tooltip Container */
|
||||
.tooltip-container {
|
||||
position: fixed;
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--bg-raised);
|
||||
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
pointer-events: auto;
|
||||
z-index: 9999;
|
||||
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||
min-width: 25rem;
|
||||
max-width: 50vh;
|
||||
max-height: 80vh;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Pinned tooltip indicator */
|
||||
.tooltip-container.pinned {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Pinned tooltip header */
|
||||
.tooltip-container.pinned .tooltip-header {
|
||||
background-color: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.tooltip-pin-button {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.tooltip-pin-button .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tooltip-pin-button:hover {
|
||||
background-color: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Tooltip Header */
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--tooltip-header-bg);
|
||||
color: var(--tooltip-header-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-top-left-radius: 0.75rem;
|
||||
border-top-right-radius: 0.75rem;
|
||||
margin: -0.0625rem -0.0625rem 0 -0.0625rem;
|
||||
border: 0.0625rem solid var(--tooltip-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-logo {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Tooltip Body */
|
||||
.tooltip-body {
|
||||
padding: 1rem !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.6 !important;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tooltip-body * {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Link styling within tooltips */
|
||||
.tooltip-body a {
|
||||
color: var(--link-color, #3b82f6) !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
|
||||
transition: color 0.2s ease, text-decoration-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip-body a:hover {
|
||||
color: var(--link-hover-color, #2563eb) !important;
|
||||
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
|
||||
}
|
||||
|
||||
.tooltip-container .tooltip-body {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.tooltip-container .tooltip-body * {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Ensure links maintain their styling */
|
||||
.tooltip-container .tooltip-body a {
|
||||
color: var(--link-color, #3b82f6) !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
|
||||
.tooltip-container .tooltip-body a:hover {
|
||||
color: var(--link-hover-color, #2563eb) !important;
|
||||
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip Arrows */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: var(--bg-raised);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
.tooltip-arrow-sidebar {
|
||||
top: 50%;
|
||||
left: -0.25rem;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tooltip-arrow-top {
|
||||
top: -0.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tooltip-arrow-bottom {
|
||||
bottom: -0.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tooltip-arrow-left {
|
||||
right: -0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tooltip-arrow-right {
|
||||
left: -0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import styles from './Tooltip.module.css';
|
||||
|
||||
export interface TooltipTip {
|
||||
title?: string;
|
||||
description?: string;
|
||||
bullets?: string[];
|
||||
body?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TooltipContentProps {
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
}
|
||||
|
||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||
content,
|
||||
tips,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles['tooltip-body']}`}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--text-primary)' }}>
|
||||
{tips ? (
|
||||
<>
|
||||
{tips.map((tip, index) => (
|
||||
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
|
||||
{tip.title && (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'var(--tooltip-title-bg)',
|
||||
color: 'var(--tooltip-title-color)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{tip.title}
|
||||
</div>
|
||||
)}
|
||||
{tip.description && (
|
||||
<p style={{ margin: '0 0 12px 0', color: 'var(--text-secondary)', fontSize: '13px' }} dangerouslySetInnerHTML={{ __html: tip.description }} />
|
||||
)}
|
||||
{tip.bullets && tip.bullets.length > 0 && (
|
||||
<ul style={{ margin: '0', paddingLeft: '16px', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
{tip.bullets.map((bullet, bulletIndex) => (
|
||||
<li key={bulletIndex} style={{ marginBottom: '6px' }} dangerouslySetInnerHTML={{ __html: bullet }} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tip.body && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{tip.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{content && (
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user