@djangocfg/ui-tools 2.1.210 → 2.1.213

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 (91) hide show
  1. package/dist/JsonTree-EFWAIIUV.mjs +5 -0
  2. package/dist/{JsonTree-5CRPHRP5.mjs.map → JsonTree-EFWAIIUV.mjs.map} +1 -1
  3. package/dist/JsonTree-LULSGFAW.cjs +11 -0
  4. package/dist/{JsonTree-IIVOSSVZ.cjs.map → JsonTree-LULSGFAW.cjs.map} +1 -1
  5. package/dist/JsonTree-MLET23ZA.css +6 -0
  6. package/dist/JsonTree-MLET23ZA.css.map +1 -0
  7. package/dist/{Mermaid.client-HG24D5KB.mjs → Mermaid.client-5EKULAUZ.mjs} +12 -22
  8. package/dist/Mermaid.client-5EKULAUZ.mjs.map +1 -0
  9. package/dist/Mermaid.client-DDXWXZXY.css +6 -0
  10. package/dist/Mermaid.client-DDXWXZXY.css.map +1 -0
  11. package/dist/{Mermaid.client-2TAFAXPW.cjs → Mermaid.client-Z25LMNPA.cjs} +12 -22
  12. package/dist/Mermaid.client-Z25LMNPA.cjs.map +1 -0
  13. package/dist/{PlaygroundLayout-XYMJBNT4.cjs → PlaygroundLayout-F47LFRYB.cjs} +21 -20
  14. package/dist/PlaygroundLayout-F47LFRYB.cjs.map +1 -0
  15. package/dist/{PlaygroundLayout-DWRNA2QM.mjs → PlaygroundLayout-NFNVEPIL.mjs} +5 -4
  16. package/dist/PlaygroundLayout-NFNVEPIL.mjs.map +1 -0
  17. package/dist/PlaygroundLayout-O52C6HK5.css +6 -0
  18. package/dist/PlaygroundLayout-O52C6HK5.css.map +1 -0
  19. package/dist/PrettyCode.client-GWFAIVFN.css +6 -0
  20. package/dist/PrettyCode.client-GWFAIVFN.css.map +1 -0
  21. package/dist/{PrettyCode.client-TCVEDDCJ.cjs → PrettyCode.client-VH6C6OC5.cjs} +15 -18
  22. package/dist/PrettyCode.client-VH6C6OC5.cjs.map +1 -0
  23. package/dist/{PrettyCode.client-RHOS5D6V.mjs → PrettyCode.client-VNTMCHXR.mjs} +16 -19
  24. package/dist/PrettyCode.client-VNTMCHXR.mjs.map +1 -0
  25. package/dist/{chunk-QP6QAK3F.cjs → chunk-3IIZ5ESQ.cjs} +3 -3
  26. package/dist/{chunk-QP6QAK3F.cjs.map → chunk-3IIZ5ESQ.cjs.map} +1 -1
  27. package/dist/chunk-3Q2X3CYQ.mjs +208 -0
  28. package/dist/chunk-3Q2X3CYQ.mjs.map +1 -0
  29. package/dist/chunk-G2A6SX5L.cjs +235 -0
  30. package/dist/chunk-G2A6SX5L.cjs.map +1 -0
  31. package/dist/chunk-LJ2VEBBZ.cjs +210 -0
  32. package/dist/chunk-LJ2VEBBZ.cjs.map +1 -0
  33. package/dist/chunk-MBFBVGUP.mjs +229 -0
  34. package/dist/chunk-MBFBVGUP.mjs.map +1 -0
  35. package/dist/{chunk-52WXASRO.mjs → chunk-SQLEKTT2.mjs} +8 -8
  36. package/dist/chunk-SQLEKTT2.mjs.map +1 -0
  37. package/dist/{chunk-FXFTJW2Y.mjs → chunk-TROOIHOR.mjs} +3 -3
  38. package/dist/{chunk-FXFTJW2Y.mjs.map → chunk-TROOIHOR.mjs.map} +1 -1
  39. package/dist/{chunk-DF6ORMTF.cjs → chunk-ZQX7KIC5.cjs} +8 -8
  40. package/dist/chunk-ZQX7KIC5.cjs.map +1 -0
  41. package/dist/{components-4Z2JIRZI.cjs → components-4FGDYL7K.cjs} +12 -12
  42. package/dist/{components-4Z2JIRZI.cjs.map → components-4FGDYL7K.cjs.map} +1 -1
  43. package/dist/{components-YWYUZQIH.mjs → components-MPEPRVCT.mjs} +3 -3
  44. package/dist/{components-YWYUZQIH.mjs.map → components-MPEPRVCT.mjs.map} +1 -1
  45. package/dist/index.cjs +53 -52
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.css +6 -0
  48. package/dist/index.css.map +1 -0
  49. package/dist/index.d.cts +8 -7
  50. package/dist/index.d.ts +8 -7
  51. package/dist/index.mjs +14 -13
  52. package/dist/index.mjs.map +1 -1
  53. package/package.json +6 -6
  54. package/src/components/FloatingToolbar/FloatingToolbar.css +5 -0
  55. package/src/components/FloatingToolbar/actions/CopyAction.tsx +22 -0
  56. package/src/components/FloatingToolbar/actions/DownloadAction.tsx +46 -0
  57. package/src/components/FloatingToolbar/actions/ExpandAction.tsx +25 -0
  58. package/src/components/FloatingToolbar/actions/FullscreenAction.tsx +30 -0
  59. package/src/components/FloatingToolbar/actions/index.ts +4 -0
  60. package/src/components/FloatingToolbar/hooks/useElementCorner.ts +84 -0
  61. package/src/components/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
  62. package/src/components/FloatingToolbar/index.tsx +131 -0
  63. package/src/styles/index.css +1 -0
  64. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +1 -1
  65. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +10 -10
  66. package/src/tools/JsonTree/JsonTree.story.tsx +3 -2
  67. package/src/tools/JsonTree/README.md +60 -0
  68. package/src/tools/JsonTree/components/JsonContent.tsx +98 -0
  69. package/src/tools/JsonTree/components/JsonToolbar.tsx +88 -0
  70. package/src/tools/JsonTree/hooks/useElementCorner.ts +84 -0
  71. package/src/tools/JsonTree/hooks/useJsonExpand.ts +50 -0
  72. package/src/tools/JsonTree/hooks/useNavbarHeight.ts +83 -0
  73. package/src/tools/JsonTree/index.tsx +46 -278
  74. package/src/tools/JsonTree/types.ts +65 -0
  75. package/src/tools/Mermaid/Mermaid.client.tsx +12 -20
  76. package/src/tools/PrettyCode/PrettyCode.client.tsx +19 -19
  77. package/src/tools/PrettyCode/PrettyCode.story.tsx +476 -41
  78. package/dist/JsonTree-5CRPHRP5.mjs +0 -4
  79. package/dist/JsonTree-IIVOSSVZ.cjs +0 -10
  80. package/dist/Mermaid.client-2TAFAXPW.cjs.map +0 -1
  81. package/dist/Mermaid.client-HG24D5KB.mjs.map +0 -1
  82. package/dist/PlaygroundLayout-DWRNA2QM.mjs.map +0 -1
  83. package/dist/PlaygroundLayout-XYMJBNT4.cjs.map +0 -1
  84. package/dist/PrettyCode.client-RHOS5D6V.mjs.map +0 -1
  85. package/dist/PrettyCode.client-TCVEDDCJ.cjs.map +0 -1
  86. package/dist/chunk-52WXASRO.mjs.map +0 -1
  87. package/dist/chunk-DF6ORMTF.cjs.map +0 -1
  88. package/dist/chunk-DFWXRCIC.cjs +0 -242
  89. package/dist/chunk-DFWXRCIC.cjs.map +0 -1
  90. package/dist/chunk-XXFYTIQK.mjs +0 -240
  91. package/dist/chunk-XXFYTIQK.mjs.map +0 -1
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { CopyButton } from '@djangocfg/ui-core/components';
5
+
6
+ interface CopyActionProps {
7
+ value: string;
8
+ title?: string;
9
+ }
10
+
11
+ const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
12
+
13
+ export const CopyAction: React.FC<CopyActionProps> = ({ value, title = 'Copy' }) => (
14
+ <CopyButton
15
+ value={value}
16
+ variant="ghost"
17
+ size="icon"
18
+ className={BUTTON_CLASS}
19
+ iconClassName="h-3 w-3"
20
+ title={title}
21
+ />
22
+ );
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { Download } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button } 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
+ <Button
37
+ variant="ghost"
38
+ size="icon"
39
+ onClick={handleDownload}
40
+ className={BUTTON_CLASS}
41
+ title={title}
42
+ >
43
+ <Download className="h-3 w-3" />
44
+ </Button>
45
+ );
46
+ };
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, ChevronUp } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button } 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
+ <Button
17
+ variant="ghost"
18
+ size="icon"
19
+ onClick={onToggle}
20
+ className={BUTTON_CLASS}
21
+ title={isExpanded ? 'Collapse All' : 'Expand All'}
22
+ >
23
+ {isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
24
+ </Button>
25
+ );
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { Maximize2, Minimize2 } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button } 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
+ <Button
22
+ variant="ghost"
23
+ size="icon"
24
+ onClick={onToggle}
25
+ className={BUTTON_CLASS}
26
+ title={title ?? (isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
27
+ >
28
+ {isFullscreen ? <Minimize2 className="h-3 w-3" /> : <Maximize2 className="h-3 w-3" />}
29
+ </Button>
30
+ );
@@ -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,84 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ interface Corner {
6
+ top: number;
7
+ right: number;
8
+ bottom: number;
9
+ }
10
+
11
+ /**
12
+ * Tracks the top-right corner of a referenced element in viewport coordinates.
13
+ * Returns { top, right } in px — ready for `position: fixed` toolbar placement.
14
+ *
15
+ * Uses `visualViewport` for accurate viewport width — correctly handles:
16
+ * - DevTools panel open (docked left/right/bottom)
17
+ * - Browser zoom
18
+ * - Mobile virtual keyboard / pinch-zoom
19
+ * - Scrollbars
20
+ *
21
+ * Falls back to `document.documentElement.clientWidth` (excludes scrollbars,
22
+ * matches `position: fixed` coordinate space) when visualViewport is unavailable.
23
+ *
24
+ * Updates on:
25
+ * - Any ancestor scroll (capture phase)
26
+ * - visualViewport resize/scroll
27
+ * - ResizeObserver on the element itself
28
+ */
29
+ export function useElementCorner(ref: React.RefObject<HTMLElement | null>) {
30
+ const [corner, setCorner] = useState<Corner | null>(null);
31
+ const updateRef = useRef<() => void>(() => {});
32
+
33
+ updateRef.current = () => {
34
+ if (!ref.current) return;
35
+ const rect = ref.current.getBoundingClientRect();
36
+
37
+ // `position: fixed` is relative to the layout viewport (document.documentElement.clientWidth),
38
+ // NOT window.innerWidth (which includes scrollbar gutter).
39
+ // visualViewport.width is the visible area — same as layout viewport when no zoom/keyboard.
40
+ const viewportWidth =
41
+ window.visualViewport?.width ?? document.documentElement.clientWidth;
42
+
43
+ setCorner({
44
+ top: rect.top,
45
+ right: viewportWidth - rect.right,
46
+ bottom: rect.bottom,
47
+ });
48
+ };
49
+
50
+ useEffect(() => {
51
+ const handler = () => updateRef.current();
52
+
53
+ handler();
54
+
55
+ // Element size changes
56
+ const ro = new ResizeObserver(handler);
57
+ if (ref.current) ro.observe(ref.current);
58
+
59
+ // Any ancestor scroll (capture catches all, including overflow:auto containers)
60
+ window.addEventListener('scroll', handler, { capture: true, passive: true });
61
+
62
+ // visualViewport handles: DevTools resize, browser zoom, mobile keyboard
63
+ const vv = window.visualViewport;
64
+ if (vv) {
65
+ vv.addEventListener('resize', handler);
66
+ vv.addEventListener('scroll', handler);
67
+ } else {
68
+ window.addEventListener('resize', handler, { passive: true });
69
+ }
70
+
71
+ return () => {
72
+ ro.disconnect();
73
+ window.removeEventListener('scroll', handler, { capture: true });
74
+ if (vv) {
75
+ vv.removeEventListener('resize', handler);
76
+ vv.removeEventListener('scroll', handler);
77
+ } else {
78
+ window.removeEventListener('resize', handler);
79
+ }
80
+ };
81
+ }, [ref]);
82
+
83
+ return corner;
84
+ }
@@ -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,131 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+
5
+ import './FloatingToolbar.css';
6
+ import { useElementCorner } from './hooks/useElementCorner';
7
+ import { useScrollIsolation } from './hooks/useScrollIsolation';
8
+
9
+ export interface FloatingToolbarProps {
10
+ /** Ref to the container element the toolbar anchors to */
11
+ containerRef: React.RefObject<HTMLElement | null>;
12
+ /** Action buttons to render (right side) */
13
+ children: React.ReactNode;
14
+ /** Optional label shown left of the buttons (e.g. language badge) */
15
+ label?: React.ReactNode;
16
+ /** Where to anchor relative to the container (default: bottom-right) */
17
+ position?: 'top-right' | 'bottom-right';
18
+ /** Offset from the edge in px (default: 8) */
19
+ offset?: number;
20
+ /** z-index (default: 30) */
21
+ zIndex?: number;
22
+ /**
23
+ * Block wheel scroll inside the container until user clicks into it.
24
+ * Re-locks when mouse leaves. Like Google Maps scroll isolation.
25
+ * @default true
26
+ */
27
+ scrollIsolation?: boolean;
28
+ }
29
+
30
+ const MIN_HEIGHT = 40; // minimum container height to show toolbar
31
+
32
+ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
33
+ containerRef,
34
+ children,
35
+ label,
36
+ position = 'bottom-right',
37
+ offset = 8,
38
+ zIndex = 30,
39
+ scrollIsolation = true,
40
+ }) => {
41
+ const corner = useElementCorner(containerRef);
42
+ const { locked, unlock } = useScrollIsolation(containerRef, scrollIsolation);
43
+ const [overlayHovered, setOverlayHovered] = useState(false);
44
+
45
+ // Overlay always renders (even before corner is measured) — it's position:absolute
46
+ // inside the container so it works immediately on mount.
47
+ // It absorbs wheel events (pointer-events cover the container) so the page scrolls normally.
48
+ // Click anywhere inside unlocks it; click outside re-locks it.
49
+ const overlay =
50
+ scrollIsolation && locked ? (
51
+ <div
52
+ onClick={unlock}
53
+ onMouseEnter={() => setOverlayHovered(true)}
54
+ onMouseLeave={() => setOverlayHovered(false)}
55
+ style={{
56
+ position: 'absolute',
57
+ inset: 0,
58
+ zIndex: zIndex - 1,
59
+ cursor: 'pointer',
60
+ background: overlayHovered ? 'rgba(0,0,0,0.04)' : 'transparent',
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ justifyContent: 'center',
64
+ transition: 'background 150ms',
65
+ }}
66
+ >
67
+ {overlayHovered && (
68
+ <span style={{
69
+ fontSize: '0.75rem',
70
+ padding: '0.25rem 0.625rem',
71
+ borderRadius: '9999px',
72
+ background: 'rgba(0,0,0,0.55)',
73
+ color: '#fff',
74
+ pointerEvents: 'none',
75
+ userSelect: 'none',
76
+ }}>
77
+ Click to scroll
78
+ </span>
79
+ )}
80
+ </div>
81
+ ) : null;
82
+
83
+ // Toolbar is position:fixed and needs measured coords — skip until ready
84
+ let toolbar: React.ReactNode = null;
85
+ if (corner) {
86
+ const { top, right, bottom } = corner;
87
+ const viewportHeight = window.visualViewport?.height ?? document.documentElement.clientHeight;
88
+
89
+ const inView = bottom > 0 && top < viewportHeight && bottom - top >= MIN_HEIGHT;
90
+
91
+ if (inView) {
92
+ const style: React.CSSProperties = {
93
+ position: 'fixed',
94
+ right: right + offset,
95
+ zIndex,
96
+ };
97
+
98
+ if (position === 'bottom-right') {
99
+ const clampedBottom = Math.min(bottom - offset, viewportHeight - offset);
100
+ style.bottom = viewportHeight - clampedBottom;
101
+ } else {
102
+ if (top >= 0) {
103
+ style.top = top + offset;
104
+ } else {
105
+ // top of block scrolled above viewport — hide toolbar
106
+ }
107
+ }
108
+
109
+ if (style.bottom !== undefined || style.top !== undefined) {
110
+ toolbar = (
111
+ <div className="flex items-center gap-1" style={style}>
112
+ {label && (
113
+ <>
114
+ {label}
115
+ <div className="w-px h-4 bg-border/50 mx-0.5" />
116
+ </>
117
+ )}
118
+ {children}
119
+ </div>
120
+ );
121
+ }
122
+ }
123
+ }
124
+
125
+ return (
126
+ <>
127
+ {overlay}
128
+ {toolbar}
129
+ </>
130
+ );
131
+ };
@@ -3,3 +3,4 @@
3
3
  * Scan tool components for Tailwind class detection
4
4
  */
5
5
  @source "../**/*.{ts,tsx}";
6
+
@@ -120,7 +120,7 @@ function HybridCompactPlayerInner({ title, waveformMode, showTimer, buttonSize,
120
120
  <div className={cn('flex items-center gap-2 w-full', className)}>
121
121
  {/* Play / Pause */}
122
122
  <Button
123
- variant="outline"
123
+ variant="ghost"
124
124
  size="icon"
125
125
  className={cn(BUTTON_SIZE[buttonSize], 'flex-shrink-0')}
126
126
  onClick={controls.togglePlay}
@@ -184,24 +184,24 @@ export const HybridWaveform = memo(function HybridWaveform({
184
184
 
185
185
  ctx.clearRect(0, 0, width, canvasHeight);
186
186
 
187
- // Draw buffered regions
187
+ // Draw progress bar
188
+ const progress = state.duration > 0 ? state.currentTime / state.duration : 0;
189
+ const progressWidth = width * progress;
190
+
191
+ // Background track
192
+ ctx.fillStyle = resolvedWaveColor;
193
+ ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, width, 4 * dpr);
194
+
195
+ // Buffered region (thin strip at bottom)
188
196
  if (state.buffered && state.duration > 0) {
189
197
  ctx.fillStyle = resolvedBufferedColor;
190
198
  for (let i = 0; i < state.buffered.length; i++) {
191
199
  const start = (state.buffered.start(i) / state.duration) * width;
192
200
  const end = (state.buffered.end(i) / state.duration) * width;
193
- ctx.fillRect(start, 0, end - start, canvasHeight);
201
+ ctx.fillRect(start, canvasHeight - 2 * dpr, end - start, 2 * dpr);
194
202
  }
195
203
  }
196
204
 
197
- // Draw progress bar
198
- const progress = state.duration > 0 ? state.currentTime / state.duration : 0;
199
- const progressWidth = width * progress;
200
-
201
- // Background
202
- ctx.fillStyle = resolvedWaveColor;
203
- ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, width, 4 * dpr);
204
-
205
205
  // Progress
206
206
  ctx.fillStyle = resolvedProgressColor;
207
207
  ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, progressWidth, 4 * dpr);
@@ -103,6 +103,7 @@ export const Interactive = () => {
103
103
  maxAutoExpandDepth,
104
104
  showExpandControls,
105
105
  showActionButtons,
106
+ className: 'h-full',
106
107
  }}
107
108
  />
108
109
  </div>
@@ -129,12 +130,12 @@ export const DeepNesting = () => (
129
130
 
130
131
  export const CollapsedByDefault = () => (
131
132
  <div className="max-w-2xl h-96">
132
- <JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 0 }} />
133
+ <JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 0, className: 'h-full' }} />
133
134
  </div>
134
135
  );
135
136
 
136
137
  export const WithMaxDepth = () => (
137
138
  <div className="max-w-2xl h-96">
138
- <JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 2 }} />
139
+ <JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 2, className: 'h-full' }} />
139
140
  </div>
140
141
  );
@@ -0,0 +1,60 @@
1
+ # JsonTree
2
+
3
+ Interactive JSON tree viewer with expand/collapse, copy, and download.
4
+
5
+ ## Structure
6
+
7
+ ```
8
+ JsonTree/
9
+ ├── index.tsx # Main component (thin orchestrator)
10
+ ├── types.ts # Types, interfaces, mode presets
11
+ ├── hooks/
12
+ │ ├── useElementCorner.ts # Tracks element's viewport top-right corner for fixed toolbar
13
+ │ └── useJsonExpand.ts # Expand/collapse state + shouldExpandNodeInitially logic
14
+ ├── components/
15
+ │ ├── JsonToolbar.tsx # Floating icon buttons (expand, copy, download)
16
+ │ └── JsonContent.tsx # JSONTree wrapper with theme, getItemString, postprocessValue
17
+ ├── lazy.tsx # Lazy-loaded export
18
+ ├── JsonTree.story.tsx # Storybook stories
19
+ └── README.md
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```tsx
25
+ import JsonTree from '@djangocfg/ui-tools/JsonTree';
26
+
27
+ <JsonTree data={myObject} />
28
+ ```
29
+
30
+ ## Props
31
+
32
+ | Prop | Type | Default | Description |
33
+ |------|------|---------|-------------|
34
+ | `data` | `unknown` | — | JSON data to display |
35
+ | `mode` | `'full' \| 'compact' \| 'inline'` | `'full'` | Display mode |
36
+ | `title` | `string` | — | Optional title (full mode only) |
37
+ | `config` | `JsonTreeConfig` | `{}` | Fine-grained config overrides |
38
+ | `jsonTreeProps` | `Partial<CommonExternalProps>` | `{}` | Pass-through to react-json-tree |
39
+
40
+ ## Config options
41
+
42
+ | Option | Default | Description |
43
+ |--------|---------|-------------|
44
+ | `maxAutoExpandDepth` | `2` | Auto-expand depth (0 = collapsed by default) |
45
+ | `maxAutoExpandArrayItems` | `10` | Arrays ≤ N items are auto-expanded |
46
+ | `maxAutoExpandObjectKeys` | `5` | Objects ≤ N keys are auto-expanded |
47
+ | `maxStringLength` | `200` | Truncate long strings |
48
+ | `collectionLimit` | `50` | Max items rendered per collection |
49
+ | `showCollectionInfo` | `true` | Show `(N keys)` / `(N items)` labels |
50
+ | `showExpandControls` | `true` (full) | Show expand/collapse button |
51
+ | `showActionButtons` | `true` (full) | Show copy/download buttons |
52
+ | `preserveKeyOrder` | `true` | Preserve object key insertion order |
53
+
54
+ ## Toolbar positioning
55
+
56
+ The toolbar uses `position: fixed` with coordinates from `useElementCorner` (via `getBoundingClientRect`).
57
+ This works correctly in any scroll context — inline, in panels, modals, or full-page — because:
58
+ - Fixed positioning ignores all ancestor `overflow` settings
59
+ - Coordinates are recalculated on every scroll (capture phase) and resize via `ResizeObserver`
60
+ - Toolbar visually follows the block's top-right corner as the page scrolls
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { CommonExternalProps, JSONTree } from 'react-json-tree';
5
+
6
+ const JSON_TREE_THEME = {
7
+ scheme: 'djangocfg-dark',
8
+ base00: 'transparent',
9
+ base01: '#1a1a1a',
10
+ base02: '#2a2a2a',
11
+ base03: '#6b7280',
12
+ base04: '#9ca3af',
13
+ base05: '#e5e7eb',
14
+ base06: '#f3f4f6',
15
+ base07: '#ffffff',
16
+ base08: '#ef4444', // null, undefined
17
+ base09: '#f97316', // numbers
18
+ base0A: '#eab308', // strings
19
+ base0B: '#22c55e', // booleans
20
+ base0C: '#06b6d4', // dates, regex
21
+ base0D: '#3b82f6', // keys
22
+ base0E: '#a855f7', // functions
23
+ base0F: '#f43f5e', // deprecations
24
+ };
25
+
26
+ interface JsonContentProps {
27
+ data: unknown;
28
+ renderKey: number;
29
+ title?: string;
30
+ showTitle: boolean;
31
+ padding: string;
32
+ collectionLimit: number;
33
+ preserveKeyOrder: boolean;
34
+ showCollectionInfo: boolean;
35
+ maxStringLength: number;
36
+ shouldExpandNodeInitially: (keyPath: readonly (string | number)[], nodeData: unknown, level: number) => boolean;
37
+ jsonTreeProps?: Partial<CommonExternalProps>;
38
+ }
39
+
40
+ export const JsonContent: React.FC<JsonContentProps> = ({
41
+ data,
42
+ renderKey,
43
+ title,
44
+ showTitle,
45
+ padding,
46
+ collectionLimit,
47
+ preserveKeyOrder,
48
+ showCollectionInfo,
49
+ maxStringLength,
50
+ shouldExpandNodeInitially,
51
+ jsonTreeProps = {},
52
+ }) => {
53
+ const getItemString = showCollectionInfo
54
+ ? (nodeType: string, nodeData: unknown) => {
55
+ if (nodeType === 'Array') {
56
+ const length = Array.isArray(nodeData) ? nodeData.length : 0;
57
+ return length > 0 ? <span className="text-muted-foreground text-sm">({length} items)</span> : null;
58
+ }
59
+ if (nodeType === 'Object') {
60
+ const keys = nodeData && typeof nodeData === 'object' ? Object.keys(nodeData) : [];
61
+ return keys.length > 0 ? <span className="text-muted-foreground text-sm">({keys.length} keys)</span> : null;
62
+ }
63
+ return null;
64
+ }
65
+ : () => null;
66
+
67
+ const postprocessValue = (value: unknown) => {
68
+ if (typeof value === 'string' && value.length > maxStringLength) {
69
+ return value.substring(0, maxStringLength) + '... (truncated)';
70
+ }
71
+ return value;
72
+ };
73
+
74
+ const isCustomNode = (value: unknown) =>
75
+ typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'));
76
+
77
+ return (
78
+ <div className={`overflow-auto h-full ${padding}`}>
79
+ {showTitle && title && (
80
+ <h6 className="text-sm font-semibold text-foreground mb-2">{title}</h6>
81
+ )}
82
+ <JSONTree
83
+ key={renderKey}
84
+ data={data}
85
+ theme={JSON_TREE_THEME}
86
+ invertTheme={false}
87
+ hideRoot={true}
88
+ shouldExpandNodeInitially={shouldExpandNodeInitially}
89
+ getItemString={getItemString}
90
+ postprocessValue={postprocessValue}
91
+ isCustomNode={isCustomNode}
92
+ collectionLimit={collectionLimit}
93
+ sortObjectKeys={!preserveKeyOrder}
94
+ {...jsonTreeProps}
95
+ />
96
+ </div>
97
+ );
98
+ };