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:
EthanHealy01
2025-08-08 12:09:41 +01:00
committed by GitHub
parent f4e4831c0d
commit 9861332040
19 changed files with 1256 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import React, { useState, useRef, forwardRef } from "react";
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
import AppsIcon from "@mui/icons-material/AppsRounded";
@@ -8,32 +8,12 @@ import FolderIcon from "@mui/icons-material/FolderRounded";
import PersonIcon from "@mui/icons-material/PersonRounded";
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar';
import './QuickAccessBar.css';
interface QuickAccessBarProps {
onToolsClick: () => void;
onReaderToggle: () => void;
selectedToolKey?: string;
toolRegistry: any;
leftPanelView: 'toolPicker' | 'toolContent';
readerMode: boolean;
}
interface ButtonConfig {
id: string;
name: string;
icon: React.ReactNode;
tooltip: string;
isRound?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
onClick: () => void;
type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions
}
function NavHeader({
activeButton,
setActiveButton,
@@ -104,14 +84,10 @@ function NavHeader({
);
}
const QuickAccessBar = ({
const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
onToolsClick,
onReaderToggle,
selectedToolKey,
toolRegistry,
leftPanelView,
readerMode,
}: QuickAccessBarProps) => {
}, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const [configModalOpen, setConfigModalOpen] = useState(false);
@@ -234,6 +210,8 @@ const QuickAccessBar = ({
return (
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
>
{/* Fixed header outside scrollable area */}
@@ -335,6 +313,6 @@ const QuickAccessBar = ({
/>
</div>
);
};
});
export default QuickAccessBar;

View File

@@ -0,0 +1,243 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipContent, TooltipTip } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css'
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: 'right' | 'left' | 'top' | 'bottom';
content?: React.ReactNode;
tips?: TooltipTip[];
children: React.ReactElement;
offset?: number;
maxWidth?: number | string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: {
title: string;
logo?: React.ReactNode;
};
}
export const Tooltip: React.FC<TooltipProps> = ({
sidebarTooltip = false,
position = 'right',
content,
tips,
children,
offset: gap = 8,
maxWidth = 280,
open: controlledOpen,
onOpenChange,
arrow = false,
portalTarget,
header,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(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 handleOpenChange = (newOpen: boolean) => {
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setInternalOpen(newOpen);
}
// 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,
position,
gap,
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState
});
// Add document click listener for unpinning
useEffect(() => {
if (isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [isPinned]);
const getArrowClass = () => {
// No arrow for sidebar tooltips
if (sidebarTooltip) return null;
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-top";
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
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 = (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] ||
'';
};
// Only show tooltip when position is ready and correct
const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true);
const tooltipElement = shouldShowTooltip ? (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
maxWidth,
zIndex: 9999,
visibility: 'visible',
opacity: 1,
color: 'var(--text-primary)',
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={handleTooltipClick}
>
{isPinned && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
handleOpenChange(false);
}}
title="Close tooltip"
>
<span className="material-symbols-rounded">
close
</span>
</button>
)}
{arrow && getArrowClass() && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
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' }} />}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent
content={content}
tips={tips}
/>
</div>
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
// Clear any existing timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Only show on hover if not pinned
if (!isPinned) {
handleOpenChange(true);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
// Only hide on mouse leave if not pinned
if (!isPinned) {
// Add a small delay to prevent flickering
hoverTimeoutRef.current = setTimeout(() => {
handleOpenChange(false);
}, 100);
}
(children.props as any)?.onMouseLeave?.(e);
};
const handleClick = (e: React.MouseEvent) => {
// Toggle pin state on click
if (open) {
setIsPinned(!isPinned);
} else {
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}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);
};