@djangocfg/widget-diagram 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/package.json +77 -0
  4. package/src/FloatingToolbar/FloatingToolbar.css +5 -0
  5. package/src/FloatingToolbar/actions/CopyAction.tsx +31 -0
  6. package/src/FloatingToolbar/actions/DownloadAction.tsx +51 -0
  7. package/src/FloatingToolbar/actions/ExpandAction.tsx +33 -0
  8. package/src/FloatingToolbar/actions/FullscreenAction.tsx +38 -0
  9. package/src/FloatingToolbar/actions/index.ts +4 -0
  10. package/src/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
  11. package/src/FloatingToolbar/index.tsx +184 -0
  12. package/src/Mermaid.client.tsx +97 -0
  13. package/src/builders/FlowDiagram/FlowDiagram.ts +96 -0
  14. package/src/builders/FlowDiagram/functions/getEdges.ts +50 -0
  15. package/src/builders/FlowDiagram/functions/getNodes.ts +43 -0
  16. package/src/builders/FlowDiagram/functions/getStyles.ts +90 -0
  17. package/src/builders/FlowDiagram/functions/index.ts +8 -0
  18. package/src/builders/FlowDiagram/index.ts +16 -0
  19. package/src/builders/FlowDiagram/types.ts +130 -0
  20. package/src/builders/JourneyDiagram/JourneyDiagram.ts +88 -0
  21. package/src/builders/JourneyDiagram/index.ts +12 -0
  22. package/src/builders/JourneyDiagram/types.ts +48 -0
  23. package/src/builders/SequenceDiagram/SequenceDiagram.ts +158 -0
  24. package/src/builders/SequenceDiagram/functions/getActivations.ts +30 -0
  25. package/src/builders/SequenceDiagram/functions/getBlocks.ts +112 -0
  26. package/src/builders/SequenceDiagram/functions/getMessages.ts +85 -0
  27. package/src/builders/SequenceDiagram/functions/getNotes.ts +94 -0
  28. package/src/builders/SequenceDiagram/functions/index.ts +16 -0
  29. package/src/builders/SequenceDiagram/index.ts +18 -0
  30. package/src/builders/SequenceDiagram/types.ts +192 -0
  31. package/src/builders/core/DiagramStore.ts +138 -0
  32. package/src/builders/core/index.ts +8 -0
  33. package/src/builders/core/sanitize.ts +83 -0
  34. package/src/builders/core/theme.ts +42 -0
  35. package/src/builders/core/types.ts +183 -0
  36. package/src/builders/index.ts +96 -0
  37. package/src/components/MermaidCodeViewer.tsx +95 -0
  38. package/src/components/MermaidErrorPanel.tsx +31 -0
  39. package/src/components/MermaidFullscreenModal.tsx +201 -0
  40. package/src/hooks/index.ts +4 -0
  41. package/src/hooks/useMermaidCleanup.ts +70 -0
  42. package/src/hooks/useMermaidFullscreen.ts +46 -0
  43. package/src/hooks/useMermaidRenderer.ts +329 -0
  44. package/src/hooks/useMermaidValidation.ts +97 -0
  45. package/src/index.tsx +79 -0
  46. package/src/lazy.tsx +40 -0
  47. package/src/mermaid.stories.tsx +217 -0
  48. package/src/types.ts +28 -0
  49. package/src/utils/mermaid-helpers.ts +157 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @djangocfg/widget-diagram
2
+
3
+ Mermaid diagrams, both halves: typed builders that WRITE the source, and a
4
+ full-parser renderer that DRAWS it. Supports the diagram types covered by the builders (`FlowDiagram`,
5
+ `SequenceDiagram`, `JourneyDiagram`) plus anything else Mermaid parses (gantt,
6
+ class, state, ER, pie, mindmap, …) via the `chart` prop. Click-to-fullscreen
7
+ via the floating toolbar.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @djangocfg/widget-diagram @djangocfg/ui-core
13
+ ```
14
+
15
+ ## Which renderer
16
+
17
+ `@djangocfg/widget-media/diagram` is the OTHER one, and it is not a duplicate.
18
+
19
+ | | `widget-diagram` | `widget-media/diagram` |
20
+ |---|---|---|
21
+ | Parser | `mermaid`, ~84 MB unpacked | `beautiful-mermaid`, ~2 MB |
22
+ | Covers | everything mermaid parses | flowchart, sequence, state, class, ER — source otherwise |
23
+ | For | a surface whose job IS the diagram | a chat transcript, where most messages have none |
24
+
25
+ Do not import both into one app.
26
+
27
+ ## Builders without a renderer
28
+
29
+ ```ts
30
+ import { FlowDiagram } from '@djangocfg/widget-diagram/builders';
31
+ ```
32
+
33
+ The `./builders` subpath holds no static edge to the parser, so a server pass,
34
+ a test or a CLI can write diagram source without resolving a renderer.
35
+ `src/entries.test.ts` walks the graph and holds that.
36
+
37
+ ## Verify
38
+
39
+ ```bash
40
+ pnpm --filter @djangocfg/widget-diagram check-types test
41
+ ```
42
+
43
+ ```tsx
44
+ import Mermaid from '@djangocfg/widget-diagram';
45
+
46
+ <Mermaid chart={`
47
+ flowchart LR
48
+ A[Client] --> B[API] --> C[(DB)]
49
+ `} />
50
+ ```
51
+
52
+ Builder API:
53
+
54
+ ```tsx
55
+ import Mermaid, { FlowDiagram } from '@djangocfg/widget-diagram';
56
+
57
+ const chart = new FlowDiagram({ direction: 'LR' })
58
+ .node('a', 'Client')
59
+ .node('b', 'API')
60
+ .edge('a', 'b')
61
+ .build();
62
+
63
+ <Mermaid chart={chart} />
64
+ ```
65
+
66
+ ## Props
67
+
68
+ | Prop | Type | Default | Description |
69
+ |---|---|---|---|
70
+ | `chart` | `string` | — | Mermaid source. |
71
+ | `isCompact` | `boolean` | `false` | Tighter padding for embedded contexts. |
72
+ | `fullscreen` | `boolean` | `true` | Click-to-fullscreen via the floating toolbar. |
73
+ | `scrollIsolation` | `boolean` | `false` | Lock page wheel while hovering. Off by default — Mermaid SVGs don't scroll, the overlay just steals wheel events. |
74
+ | `debounceMs` | `number` | `300` | Re-render debounce after `chart` changes. Lower for static diagrams, higher while streaming. |
75
+ | `className` | `string` | — | — |
76
+
77
+ ## Notes
78
+
79
+ - **~800KB bundle** — entry point is already `lazy()`-wrapped with a `Suspense` spinner, so the cost is only paid when a diagram is actually mounted.
80
+ - Theming pulls from `useThemePalette` / `useStylePresets` / `useBoxColors` (exported alongside the builders) — Mermaid colors follow the active app theme; do not hard-code hex.
81
+
82
+ ---
83
+
84
+ Adapted from jalcoui (MIT).
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@djangocfg/widget-diagram",
3
+ "version": "0.1.1",
4
+ "description": "Mermaid diagrams: typed builders that write the source, and a full-parser renderer that draws it",
5
+ "keywords": [
6
+ "mermaid",
7
+ "diagram",
8
+ "flowchart",
9
+ "sequence-diagram",
10
+ "builder",
11
+ "typescript"
12
+ ],
13
+ "author": {
14
+ "name": "DjangoCFG",
15
+ "url": "https://djangocfg.com"
16
+ },
17
+ "homepage": "https://djangocfg.com",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/markolofsen/django-cfg.git",
21
+ "directory": "widgets/diagram"
22
+ },
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "main": "./src/index.tsx",
26
+ "types": "./src/index.tsx",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./src/index.tsx",
30
+ "default": "./src/index.tsx"
31
+ },
32
+ "./builders": {
33
+ "types": "./src/builders/index.ts",
34
+ "default": "./src/builders/index.ts"
35
+ }
36
+ },
37
+ "files": [
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "scripts": {
44
+ "check": "tsc --noEmit",
45
+ "check-types": "tsc --noEmit",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest"
48
+ },
49
+ "peerDependencies": {
50
+ "@djangocfg/ui-core": "^2.1.542",
51
+ "lucide-react": "^0.545.0",
52
+ "react": "^19.0.0",
53
+ "react-dom": "^19.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "@djangocfg/typescript-config": "^2.1.542",
57
+ "@djangocfg/ui-core": "^2.1.542",
58
+ "@storybook/react-vite": "^10.5.0",
59
+ "@types/node": "^25.9.5",
60
+ "@types/react": "19.2.15",
61
+ "@types/react-dom": "19.2.3",
62
+ "lucide-react": "0.545.0",
63
+ "react": "19.2.7",
64
+ "react-dom": "19.2.7",
65
+ "storybook": "^10.5.0",
66
+ "typescript": "^5.9.3",
67
+ "vitest": "^4.1.10"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "dependencies": {
73
+ "@djangocfg/widget-kit": "^0.1.1",
74
+ "mermaid": "^11.16.0",
75
+ "react-zoom-pan-pinch": "^3.7.0"
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ /* Focus ring when scroll isolation is unlocked */
2
+ .scroll-unlocked {
3
+ box-shadow: 0 0 0 2px var(--ring);
4
+ transition: box-shadow 150ms;
5
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import {
5
+ CopyButton,
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from '@djangocfg/ui-core/components';
10
+
11
+ interface CopyActionProps {
12
+ value: string;
13
+ title?: string;
14
+ }
15
+
16
+ const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
17
+
18
+ export const CopyAction: React.FC<CopyActionProps> = ({ value, title = 'Copy' }) => (
19
+ <Tooltip>
20
+ <TooltipTrigger asChild>
21
+ <CopyButton
22
+ value={value}
23
+ variant="ghost"
24
+ size="icon"
25
+ className={BUTTON_CLASS}
26
+ iconClassName="h-3 w-3"
27
+ />
28
+ </TooltipTrigger>
29
+ <TooltipContent side="top">{title}</TooltipContent>
30
+ </Tooltip>
31
+ );
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import { Download } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
7
+
8
+ interface DownloadActionProps {
9
+ value: string;
10
+ filename?: string;
11
+ mimeType?: string;
12
+ title?: string;
13
+ }
14
+
15
+ const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
16
+
17
+ export const DownloadAction: React.FC<DownloadActionProps> = ({
18
+ value,
19
+ filename = 'download.txt',
20
+ mimeType = 'text/plain',
21
+ title = 'Download',
22
+ }) => {
23
+ const handleDownload = () => {
24
+ const blob = new Blob([value], { type: mimeType });
25
+ const url = URL.createObjectURL(blob);
26
+ const a = document.createElement('a');
27
+ a.href = url;
28
+ a.download = filename;
29
+ document.body.appendChild(a);
30
+ a.click();
31
+ document.body.removeChild(a);
32
+ URL.revokeObjectURL(url);
33
+ };
34
+
35
+ return (
36
+ <Tooltip>
37
+ <TooltipTrigger asChild>
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ onClick={handleDownload}
42
+ aria-label={title}
43
+ className={BUTTON_CLASS}
44
+ >
45
+ <Download className="h-3 w-3" />
46
+ </Button>
47
+ </TooltipTrigger>
48
+ <TooltipContent side="top">{title}</TooltipContent>
49
+ </Tooltip>
50
+ );
51
+ };
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, ChevronUp } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
7
+
8
+ interface ExpandActionProps {
9
+ isExpanded: boolean;
10
+ onToggle: () => void;
11
+ }
12
+
13
+ const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
14
+
15
+ export const ExpandAction: React.FC<ExpandActionProps> = ({ isExpanded, onToggle }) => {
16
+ const label = isExpanded ? 'Collapse All' : 'Expand All';
17
+ return (
18
+ <Tooltip>
19
+ <TooltipTrigger asChild>
20
+ <Button
21
+ variant="ghost"
22
+ size="icon"
23
+ onClick={onToggle}
24
+ aria-label={label}
25
+ className={BUTTON_CLASS}
26
+ >
27
+ {isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
28
+ </Button>
29
+ </TooltipTrigger>
30
+ <TooltipContent side="top">{label}</TooltipContent>
31
+ </Tooltip>
32
+ );
33
+ };
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import { Maximize2, Minimize2 } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
7
+
8
+ interface FullscreenActionProps {
9
+ isFullscreen?: boolean;
10
+ onToggle: () => void;
11
+ title?: string;
12
+ }
13
+
14
+ const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
15
+
16
+ export const FullscreenAction: React.FC<FullscreenActionProps> = ({
17
+ isFullscreen = false,
18
+ onToggle,
19
+ title,
20
+ }) => {
21
+ const label = title ?? (isFullscreen ? 'Exit fullscreen' : 'Fullscreen');
22
+ return (
23
+ <Tooltip>
24
+ <TooltipTrigger asChild>
25
+ <Button
26
+ variant="ghost"
27
+ size="icon"
28
+ onClick={onToggle}
29
+ aria-label={label}
30
+ className={BUTTON_CLASS}
31
+ >
32
+ {isFullscreen ? <Minimize2 className="h-3 w-3" /> : <Maximize2 className="h-3 w-3" />}
33
+ </Button>
34
+ </TooltipTrigger>
35
+ <TooltipContent side="top">{label}</TooltipContent>
36
+ </Tooltip>
37
+ );
38
+ };
@@ -0,0 +1,4 @@
1
+ export { CopyAction } from './CopyAction';
2
+ export { DownloadAction } from './DownloadAction';
3
+ export { ExpandAction } from './ExpandAction';
4
+ export { FullscreenAction } from './FullscreenAction';
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+
5
+ const UNLOCKED_CLASS = 'scroll-unlocked';
6
+
7
+ /**
8
+ * Scroll isolation — prevents the container from capturing wheel scroll
9
+ * until the user explicitly clicks inside it (like Google Maps).
10
+ *
11
+ * Locked (overlay visible): wheel events on container scroll the PAGE.
12
+ * Unlocked: normal scroll inside the container.
13
+ *
14
+ * Unlock: click anywhere inside the container.
15
+ * Re-lock: click outside the container.
16
+ *
17
+ * When unlocked, adds `scroll-unlocked` class to the container element
18
+ * so it can be styled (e.g. focus ring) via CSS.
19
+ */
20
+ export function useScrollIsolation(
21
+ ref: React.RefObject<HTMLElement | null>,
22
+ enabled: boolean,
23
+ ) {
24
+ const [locked, setLocked] = useState(true);
25
+
26
+ const unlock = useCallback(() => setLocked(false), []);
27
+
28
+ // Re-lock when clicking outside the container
29
+ useEffect(() => {
30
+ if (!enabled) return;
31
+
32
+ const handleDocClick = (e: MouseEvent) => {
33
+ const el = ref.current;
34
+ if (!el) return;
35
+ if (!el.contains(e.target as Node)) {
36
+ setLocked(true);
37
+ }
38
+ };
39
+
40
+ document.addEventListener('click', handleDocClick, true);
41
+ return () => document.removeEventListener('click', handleDocClick, true);
42
+ }, [enabled, ref]);
43
+
44
+ // Toggle class on container to allow CSS-driven focus ring
45
+ useEffect(() => {
46
+ const el = ref.current;
47
+ if (!el || !enabled) return;
48
+ if (locked) {
49
+ el.classList.remove(UNLOCKED_CLASS);
50
+ } else {
51
+ el.classList.add(UNLOCKED_CLASS);
52
+ }
53
+ return () => el.classList.remove(UNLOCKED_CLASS);
54
+ }, [locked, enabled, ref]);
55
+
56
+ // Reset to locked when feature toggled on
57
+ useEffect(() => {
58
+ if (enabled) setLocked(true);
59
+ }, [enabled]);
60
+
61
+ return { locked, unlock };
62
+ }
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+
5
+ import './FloatingToolbar.css';
6
+ import { useScrollIsolation } from './hooks/useScrollIsolation';
7
+
8
+ /**
9
+ * Track whether the container actually overflows. Scroll isolation
10
+ * (and its "Click to scroll" overlay) only makes sense when there is
11
+ * something to scroll — a fully-visible block must stay interactive.
12
+ */
13
+ function useIsScrollable(ref: React.RefObject<HTMLElement | null>): boolean {
14
+ const [scrollable, setScrollable] = useState(false);
15
+
16
+ useEffect(() => {
17
+ const el = ref.current;
18
+ if (!el) return;
19
+
20
+ const measure = () => {
21
+ // 1px tolerance for sub-pixel rounding.
22
+ setScrollable(
23
+ el.scrollHeight - el.clientHeight > 1 ||
24
+ el.scrollWidth - el.clientWidth > 1,
25
+ );
26
+ };
27
+
28
+ measure();
29
+ const observer = new ResizeObserver(measure);
30
+ observer.observe(el);
31
+ return () => observer.disconnect();
32
+ }, [ref]);
33
+
34
+ return scrollable;
35
+ }
36
+
37
+ export interface FloatingToolbarProps {
38
+ /** Ref to the container element the toolbar anchors to */
39
+ containerRef: React.RefObject<HTMLElement | null>;
40
+ /** Action buttons to render (right side) */
41
+ children: React.ReactNode;
42
+ /** Optional label shown left of the buttons (e.g. language badge) */
43
+ label?: React.ReactNode;
44
+ /** Where to anchor relative to the container (default: bottom-right) */
45
+ position?: 'top-right' | 'bottom-right';
46
+ /** Offset from the edge in px (default: 8) */
47
+ offset?: number;
48
+ /** z-index (default: 30) */
49
+ zIndex?: number;
50
+ /**
51
+ * Block wheel scroll inside the container until user clicks into it.
52
+ * Re-locks when mouse leaves. Like Google Maps scroll isolation.
53
+ * @default true
54
+ */
55
+ scrollIsolation?: boolean;
56
+ /**
57
+ * Hide the toolbar until the user hovers the container. ChatGPT /
58
+ * GitHub-style: chrome stays out of the way, appears on demand.
59
+ * Keyboard focus inside the container also reveals it (a11y).
60
+ * @default false (always visible — back-compat)
61
+ */
62
+ autoHide?: boolean;
63
+ }
64
+
65
+ /**
66
+ * Toolbar is anchored with `position: absolute` inside the container (PrettyCode, etc.).
67
+ * `position: fixed` + viewport coordinates breaks embedded layouts (e.g. chat composer overlap).
68
+ */
69
+ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
70
+ containerRef,
71
+ children,
72
+ label,
73
+ position = 'bottom-right',
74
+ offset = 8,
75
+ zIndex = 30,
76
+ scrollIsolation = true,
77
+ autoHide = false,
78
+ }) => {
79
+ // Isolation only engages when the container can actually scroll —
80
+ // a block that fully fits never shows the "Click to scroll" overlay.
81
+ const isScrollable = useIsScrollable(containerRef);
82
+ const isolationActive = scrollIsolation && isScrollable;
83
+
84
+ const { locked, unlock } = useScrollIsolation(containerRef, isolationActive);
85
+ const [overlayHovered, setOverlayHovered] = useState(false);
86
+
87
+ // Track container hover + focus for ChatGPT-style auto-hide. We watch
88
+ // `mouseenter/leave` and `focusin/out` on the container so keyboard
89
+ // users still see the toolbar — `:hover` alone would hide it from
90
+ // them entirely.
91
+ const [containerActive, setContainerActive] = useState(false);
92
+ useEffect(() => {
93
+ if (!autoHide) return;
94
+ const el = containerRef.current;
95
+ if (!el) return;
96
+ const enter = () => setContainerActive(true);
97
+ const leave = () => setContainerActive(false);
98
+ el.addEventListener('mouseenter', enter);
99
+ el.addEventListener('mouseleave', leave);
100
+ el.addEventListener('focusin', enter);
101
+ el.addEventListener('focusout', leave);
102
+ return () => {
103
+ el.removeEventListener('mouseenter', enter);
104
+ el.removeEventListener('mouseleave', leave);
105
+ el.removeEventListener('focusin', enter);
106
+ el.removeEventListener('focusout', leave);
107
+ };
108
+ }, [autoHide, containerRef]);
109
+
110
+ const overlay =
111
+ isolationActive && locked ? (
112
+ <div
113
+ onClick={unlock}
114
+ onMouseEnter={() => setOverlayHovered(true)}
115
+ onMouseLeave={() => setOverlayHovered(false)}
116
+ style={{
117
+ position: 'absolute',
118
+ inset: 0,
119
+ zIndex: zIndex - 1,
120
+ cursor: 'pointer',
121
+ background: overlayHovered ? 'rgba(0,0,0,0.04)' : 'transparent',
122
+ display: 'flex',
123
+ alignItems: 'center',
124
+ justifyContent: 'center',
125
+ transition: 'background 150ms',
126
+ }}
127
+ >
128
+ {overlayHovered && (
129
+ <span
130
+ style={{
131
+ fontSize: '0.75rem',
132
+ padding: '0.25rem 0.625rem',
133
+ borderRadius: '9999px',
134
+ background: 'rgba(0,0,0,0.55)',
135
+ color: '#fff',
136
+ pointerEvents: 'none',
137
+ userSelect: 'none',
138
+ }}
139
+ >
140
+ Click to scroll
141
+ </span>
142
+ )}
143
+ </div>
144
+ ) : null;
145
+
146
+ const positionStyle: React.CSSProperties =
147
+ position === 'bottom-right'
148
+ ? { bottom: offset, right: offset }
149
+ : { top: offset, right: offset };
150
+
151
+ // Auto-hide: invisible until the container is hovered or has keyboard
152
+ // focus inside. Opacity transition keeps the reveal smooth instead of
153
+ // popping. `pointer-events: none` while hidden so the toolbar doesn't
154
+ // intercept clicks on whatever sits behind it.
155
+ const hidden = autoHide && !containerActive;
156
+ const toolbar = (
157
+ <div
158
+ className="flex items-center gap-1"
159
+ style={{
160
+ position: 'absolute',
161
+ ...positionStyle,
162
+ zIndex,
163
+ opacity: hidden ? 0 : 1,
164
+ pointerEvents: hidden ? 'none' : undefined,
165
+ transition: 'opacity 120ms ease-out',
166
+ }}
167
+ >
168
+ {label && (
169
+ <>
170
+ {label}
171
+ <div className="w-px h-4 bg-border/50 mx-0.5" />
172
+ </>
173
+ )}
174
+ {children}
175
+ </div>
176
+ );
177
+
178
+ return (
179
+ <>
180
+ {overlay}
181
+ {toolbar}
182
+ </>
183
+ );
184
+ };