|
1 | | -import React, { useState, useRef, useCallback, useEffect } from 'react'; |
| 1 | +import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; |
2 | 2 | import { useTranslation } from 'react-i18next'; |
| 3 | +import { useRootFontSize } from '../utils/useRootFontSize.js'; |
3 | 4 |
|
4 | | - export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800, actions = null }) { |
5 | | - const [height, setHeight] = useState(initialHeight); |
6 | | - const { t } = useTranslation(); |
| 5 | +const HEADER_OFFSET_PX = 60; |
| 6 | + |
| 7 | +export function ResizablePanel({ |
| 8 | + children, |
| 9 | + title, |
| 10 | + initialHeight = 440, |
| 11 | + minHeight = 200, |
| 12 | + maxHeight = 800, |
| 13 | + actions = null |
| 14 | +}) { |
| 15 | + const { t } = useTranslation(); |
| 16 | + const rootFontSize = useRootFontSize(); |
| 17 | + const pxPerRem = rootFontSize || 16; |
| 18 | + |
| 19 | + const pxToRem = useCallback((px) => px / pxPerRem, [pxPerRem]); |
| 20 | + const minHeightRem = useMemo(() => pxToRem(minHeight), [minHeight, pxToRem]); |
| 21 | + const maxHeightRem = useMemo(() => pxToRem(maxHeight), [maxHeight, pxToRem]); |
| 22 | + const headerOffsetRem = useMemo(() => pxToRem(HEADER_OFFSET_PX), [pxToRem]); |
| 23 | + |
| 24 | + const [heightRem, setHeightRem] = useState(() => pxToRem(initialHeight)); |
7 | 25 | const [isResizing, setIsResizing] = useState(false); |
8 | 26 | const panelRef = useRef(null); |
9 | 27 | const startY = useRef(0); |
10 | | - const startHeight = useRef(0); |
| 28 | + const startHeightRem = useRef(0); |
11 | 29 |
|
12 | | - const handleMouseMove = useCallback((e) => { |
| 30 | + const handleMouseMove = useCallback((event) => { |
13 | 31 | if (!isResizing) return; |
14 | | - |
15 | | - const deltaY = e.clientY - startY.current; |
16 | | - const newHeight = Math.min(Math.max(startHeight.current + deltaY, minHeight), maxHeight); |
17 | | - setHeight(newHeight); |
18 | | - }, [isResizing, minHeight, maxHeight]); |
| 32 | + |
| 33 | + const deltaRem = (event.clientY - startY.current) / pxPerRem; |
| 34 | + const nextHeightRem = Math.min( |
| 35 | + Math.max(startHeightRem.current + deltaRem, minHeightRem), |
| 36 | + maxHeightRem |
| 37 | + ); |
| 38 | + setHeightRem(nextHeightRem); |
| 39 | + }, [isResizing, maxHeightRem, minHeightRem, pxPerRem]); |
19 | 40 |
|
20 | 41 | const handleMouseUp = useCallback(() => { |
21 | 42 | setIsResizing(false); |
22 | | - document.removeEventListener('mousemove', handleMouseMove); |
23 | | - document.removeEventListener('mouseup', handleMouseUp); |
24 | | - }, [handleMouseMove]); |
| 43 | + }, []); |
25 | 44 |
|
26 | 45 | useEffect(() => { |
27 | | - if (isResizing) { |
28 | | - document.addEventListener('mousemove', handleMouseMove); |
29 | | - document.addEventListener('mouseup', handleMouseUp); |
30 | | - } |
31 | | - |
| 46 | + if (!isResizing) return undefined; |
| 47 | + |
| 48 | + const stopResize = () => handleMouseUp(); |
| 49 | + document.addEventListener('mousemove', handleMouseMove); |
| 50 | + document.addEventListener('mouseup', stopResize); |
| 51 | + |
32 | 52 | return () => { |
33 | 53 | document.removeEventListener('mousemove', handleMouseMove); |
34 | | - document.removeEventListener('mouseup', handleMouseUp); |
| 54 | + document.removeEventListener('mouseup', stopResize); |
35 | 55 | }; |
36 | | - }, [isResizing, handleMouseMove, handleMouseUp]); |
| 56 | + }, [handleMouseMove, handleMouseUp, isResizing]); |
37 | 57 |
|
38 | | - const handleMouseDown = useCallback((e) => { |
| 58 | + const handleMouseDown = useCallback((event) => { |
39 | 59 | setIsResizing(true); |
40 | | - startY.current = e.clientY; |
41 | | - startHeight.current = height; |
42 | | - e.preventDefault(); |
43 | | - e.stopPropagation(); |
44 | | - }, [height]); |
| 60 | + startY.current = event.clientY; |
| 61 | + startHeightRem.current = heightRem; |
| 62 | + event.preventDefault(); |
| 63 | + event.stopPropagation(); |
| 64 | + }, [heightRem]); |
| 65 | + |
| 66 | + const handleKeyboardResize = useCallback((event) => { |
| 67 | + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { |
| 68 | + return; |
| 69 | + } |
| 70 | + |
| 71 | + event.preventDefault(); |
| 72 | + const deltaRem = (event.key === 'ArrowUp' ? -10 : 10) / pxPerRem; |
| 73 | + const nextHeightRem = Math.min( |
| 74 | + Math.max(heightRem + deltaRem, minHeightRem), |
| 75 | + maxHeightRem |
| 76 | + ); |
| 77 | + setHeightRem(nextHeightRem); |
| 78 | + }, [heightRem, maxHeightRem, minHeightRem, pxPerRem]); |
| 79 | + |
| 80 | + const panelTitleId = `panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`; |
| 81 | + const contentHeightRem = Math.max(heightRem - headerOffsetRem, minHeightRem - headerOffsetRem); |
45 | 82 |
|
46 | 83 | return ( |
47 | | - <section |
| 84 | + <section |
48 | 85 | ref={panelRef} |
49 | 86 | className="chart-panel p-3" |
50 | | - style={{ height: `${height}px` }} |
51 | | - aria-labelledby={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`} |
| 87 | + style={{ height: `${heightRem}rem` }} |
| 88 | + aria-labelledby={panelTitleId} |
52 | 89 | > |
53 | | - <div className="flex items-center justify-between mb-2"> |
| 90 | + <div className="mb-2 flex items-center justify-between"> |
54 | 91 | <h3 |
55 | | - id={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`} |
| 92 | + id={panelTitleId} |
56 | 93 | className="text-base font-semibold text-gray-800 dark:text-gray-100" |
57 | 94 | > |
58 | | - 📊 {title} |
59 | | - </h3> |
| 95 | + 📊 {title} |
| 96 | + </h3> |
60 | 97 | {actions && ( |
61 | 98 | <div className="flex gap-2" aria-label={t('chart.actions')}> |
62 | 99 | {actions} |
63 | 100 | </div> |
64 | 101 | )} |
65 | 102 | </div> |
66 | | - |
67 | | - <div |
68 | | - className="chart-container" |
69 | | - style={{ height: `${height - 60}px` }} |
| 103 | + |
| 104 | + <div |
| 105 | + className="chart-container" |
| 106 | + style={{ height: `${contentHeightRem}rem` }} |
70 | 107 | role="img" |
71 | | - aria-label={`${title} ${t('chart')}`} |
72 | | - > |
73 | | - {children} |
74 | | - </div> |
75 | | - |
| 108 | + aria-label={`${title} ${t('chart')}`} |
| 109 | + > |
| 110 | + {children} |
| 111 | + </div> |
| 112 | + |
76 | 113 | <button |
77 | 114 | className="resize-handle" |
78 | | - onMouseDown={handleMouseDown} |
79 | | - title={t('resize.drag')} |
80 | | - aria-label={t('resize.adjust', { title })} |
81 | | - onKeyDown={(e) => { |
82 | | - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { |
83 | | - e.preventDefault(); |
84 | | - const delta = e.key === 'ArrowUp' ? -10 : 10; |
85 | | - const newHeight = Math.min(Math.max(height + delta, minHeight), maxHeight); |
86 | | - setHeight(newHeight); |
87 | | - } |
88 | | - }} |
| 115 | + onMouseDown={handleMouseDown} |
| 116 | + onKeyDown={handleKeyboardResize} |
| 117 | + title={t('resize.drag')} |
| 118 | + aria-label={t('resize.adjust', { title })} |
89 | 119 | tabIndex={0} |
| 120 | + type="button" |
90 | 121 | /> |
91 | 122 | </section> |
92 | 123 | ); |
|
0 commit comments