@aiquants/resize-panels 1.8.1 → 2.0.0

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 (81) hide show
  1. package/README.md +213 -108
  2. package/dist/GlobalDebugOverlay-Cf3GOn2A.cjs +1 -0
  3. package/dist/{GlobalDebugOverlay-DYCR0FIu.js → GlobalDebugOverlay-DQtUdNeF.js} +28 -28
  4. package/dist/debugOverlayStore-D7FT6zPE.js +135 -0
  5. package/dist/debugOverlayStore-DY09saU8.cjs +1 -0
  6. package/dist/index-DZ0itiBD.js +1299 -0
  7. package/dist/index-DqATP_BA.cjs +2 -0
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.ts +2 -29
  10. package/dist/index.js +25 -13
  11. package/dist/src/GlobalDebugOverlay.d.ts.map +1 -0
  12. package/dist/src/Panel.d.ts +46 -0
  13. package/dist/src/Panel.d.ts.map +1 -0
  14. package/dist/src/PanelDebugInfo.d.ts +34 -0
  15. package/dist/src/PanelDebugInfo.d.ts.map +1 -0
  16. package/dist/src/PanelGroup.d.ts.map +1 -0
  17. package/dist/{PanelResizeHandle.d.ts → src/PanelResizeHandle.d.ts} +1 -1
  18. package/dist/src/PanelResizeHandle.d.ts.map +1 -0
  19. package/dist/src/allocateLayout.d.ts +60 -0
  20. package/dist/src/allocateLayout.d.ts.map +1 -0
  21. package/dist/src/context.d.ts.map +1 -0
  22. package/dist/{debugOverlayStore.d.ts → src/debugOverlayStore.d.ts} +10 -2
  23. package/dist/src/debugOverlayStore.d.ts.map +1 -0
  24. package/dist/src/hooks.d.ts +48 -0
  25. package/dist/src/hooks.d.ts.map +1 -0
  26. package/dist/src/index.d.ts +32 -0
  27. package/dist/src/index.d.ts.map +1 -0
  28. package/dist/src/reducer.d.ts +49 -0
  29. package/dist/src/reducer.d.ts.map +1 -0
  30. package/dist/src/roundHalfToEven.d.ts.map +1 -0
  31. package/dist/src/types.d.ts +481 -0
  32. package/dist/src/types.d.ts.map +1 -0
  33. package/dist/src/utils/simple-logger.d.ts.map +1 -0
  34. package/dist/src/utils.d.ts +164 -0
  35. package/dist/src/utils.d.ts.map +1 -0
  36. package/dist/styles/resize-panels.standalone.css +1 -1
  37. package/dist/tests/support/dom-harness.d.ts +89 -0
  38. package/dist/tests/support/dom-harness.d.ts.map +1 -0
  39. package/package.json +3 -3
  40. package/src/GlobalDebugOverlay.tsx +3 -3
  41. package/src/Panel.tsx +203 -541
  42. package/src/PanelDebugInfo.tsx +52 -106
  43. package/src/PanelGroup.tsx +27 -42
  44. package/src/PanelResizeHandle.tsx +544 -504
  45. package/src/allocateLayout.ts +412 -0
  46. package/src/debugOverlayStore.ts +19 -15
  47. package/src/hooks.ts +187 -779
  48. package/src/index.ts +37 -6
  49. package/src/reducer.ts +364 -1126
  50. package/src/types.ts +282 -65
  51. package/src/utils.ts +205 -164
  52. package/dist/GlobalDebugOverlay-afs0vF-Q.cjs +0 -1
  53. package/dist/GlobalDebugOverlay.d.ts.map +0 -1
  54. package/dist/Panel.d.ts +0 -7
  55. package/dist/Panel.d.ts.map +0 -1
  56. package/dist/PanelDebugInfo.d.ts +0 -26
  57. package/dist/PanelDebugInfo.d.ts.map +0 -1
  58. package/dist/PanelGroup.d.ts.map +0 -1
  59. package/dist/PanelResizeHandle.d.ts.map +0 -1
  60. package/dist/context.d.ts.map +0 -1
  61. package/dist/debugOverlayStore-Cntyxboe.js +0 -141
  62. package/dist/debugOverlayStore-Dkl-fHoa.cjs +0 -1
  63. package/dist/debugOverlayStore.d.ts.map +0 -1
  64. package/dist/hooks.d.ts +0 -45
  65. package/dist/hooks.d.ts.map +0 -1
  66. package/dist/index-BzgId8aQ.js +0 -1972
  67. package/dist/index-xTV-c0PN.cjs +0 -2
  68. package/dist/index.d.ts.map +0 -1
  69. package/dist/reducer.d.ts +0 -39
  70. package/dist/reducer.d.ts.map +0 -1
  71. package/dist/roundHalfToEven.d.ts.map +0 -1
  72. package/dist/types.d.ts +0 -253
  73. package/dist/types.d.ts.map +0 -1
  74. package/dist/utils/simple-logger.d.ts.map +0 -1
  75. package/dist/utils.d.ts +0 -61
  76. package/dist/utils.d.ts.map +0 -1
  77. /package/dist/{GlobalDebugOverlay.d.ts → src/GlobalDebugOverlay.d.ts} +0 -0
  78. /package/dist/{PanelGroup.d.ts → src/PanelGroup.d.ts} +0 -0
  79. /package/dist/{context.d.ts → src/context.d.ts} +0 -0
  80. /package/dist/{roundHalfToEven.d.ts → src/roundHalfToEven.d.ts} +0 -0
  81. /package/dist/{utils → src/utils}/simple-logger.d.ts +0 -0
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * @file PanelResizeHandle component for custom resizable panels
3
3
  * カスタムリサイズ可能パネルのための PanelResizeHandle コンポーネント
4
+ *
5
+ * The handle does not compute where the boundary is. It renders a zero-length flex item — the anchor —
6
+ * between its two panels, so flex layout itself places it exactly on the boundary, and the interactive
7
+ * button is drawn symmetrically around that anchor. Whatever the panels do (a container resize between two
8
+ * React renders, padding on the group, right-to-left text, a vertical axis), the handle is on the boundary
9
+ * because it *is* the boundary.
10
+ * ハンドルは境界位置を計算しない。2 枚のパネルの間に長さ 0 のフレックスアイテム (アンカー) を描画するため、
11
+ * フレックスレイアウト自身がそれを境界へ配置し、操作用ボタンはそのアンカーを中心に対称に描かれる。
12
+ * コンテナのリサイズ・グループのパディング・RTL・垂直方向のいずれであっても、ハンドルは境界そのものなので必ず一致する。
4
13
  */
5
14
 
6
15
  import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
7
16
  import { twMerge } from "tailwind-merge"
17
+ import { resolvePanelBounds } from "./allocateLayout"
8
18
  import { usePanelGroup } from "./context"
9
19
  import {
20
+ PANEL_ALLOCATION_EPSILON,
10
21
  PANEL_COLLAPSE_THRESHOLD_MAX,
11
22
  PANEL_COLLAPSE_THRESHOLD_RATIO,
12
23
  PANEL_EXPAND_THRESHOLD_MAX,
@@ -14,297 +25,343 @@ import {
14
25
  PANEL_HANDLE_DRAG_THROTTLE_MS,
15
26
  PANEL_HANDLE_THICKNESS,
16
27
  PANEL_HANDLE_VISIBILITY_THRESHOLD,
28
+ PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL,
29
+ PANEL_INDICATOR_BAR_LENGTH_VERTICAL,
30
+ PANEL_INDICATOR_BAR_THICKNESS,
31
+ PANEL_INDICATOR_LENGTH_HORIZONTAL,
32
+ PANEL_INDICATOR_LENGTH_VERTICAL,
33
+ PANEL_INDICATOR_THICKNESS,
17
34
  PANEL_KEYBOARD_RESIZE_STEP,
18
35
  PANEL_VISIBLE_INDICATOR_SPACING,
36
+ type PanelLayoutData,
19
37
  type PanelResizeHandleProps,
20
38
  type ResizeHandleLayoutData,
21
39
  } from "./types"
22
- import { calculateSnapThreshold, getConstraintInPixels, getContainerSize } from "./utils"
40
+ import { calculateSnapThreshold, findOwningGroup, getGroupLayoutElements, getVisualScale } from "./utils"
41
+
42
+ /**
43
+ * Styles that remove the handle and its anchor from layout and interaction.
44
+ * ハンドルとアンカーをレイアウトと操作の両方から外すスタイル。
45
+ */
46
+ const HIDDEN_HANDLE_STYLE: CSSProperties = {
47
+ display: "none",
48
+ pointerEvents: "none",
49
+ visibility: "hidden",
50
+ }
51
+
52
+ /**
53
+ * Style that collapses the visual indicator.
54
+ * 視覚インジケーターを非表示にするスタイル。
55
+ */
56
+ const HIDDEN_INDICATOR_STYLE: CSSProperties = {
57
+ display: "none",
58
+ }
59
+
60
+ /**
61
+ * Sanitize an optional dimension to ensure a positive finite number.
62
+ * オプション寸法を正の有限数値へ正規化する処理。
63
+ */
64
+ const sanitizeDimension = (value: unknown, defaultValue: number): number => {
65
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
66
+ return defaultValue
67
+ }
68
+ return value
69
+ }
70
+
71
+ /**
72
+ * Ensure a numeric spacing value is an even integer for symmetric pixel alignment.
73
+ * ピクセル配置の対称性を担保するため数値を偶数整数に整流する処理。
74
+ */
75
+ const ensureEven = (value: number): number => {
76
+ const rounded = Math.round(value)
77
+ return rounded % 2 === 0 ? rounded : rounded + 1
78
+ }
79
+
80
+ /**
81
+ * Report whether an element is laid out right-to-left at this moment.
82
+ * その時点で要素が右から左へレイアウトされているかの判定。
83
+ *
84
+ * The direction is read at the start of each gesture instead of being cached, so a language switch takes
85
+ * effect without remounting the group.
86
+ * 方向はキャッシュせずジェスチャー開始のたびに読む。言語切替がグループの再マウント無しで反映されるようにするため。
87
+ */
88
+ const isRightToLeft = (element: HTMLElement | null): boolean => {
89
+ if (!element || typeof window === "undefined") {
90
+ return false
91
+ }
92
+ return window.getComputedStyle(element).direction === "rtl"
93
+ }
94
+
95
+ /**
96
+ * Travel range of the boundary a handle sits on, in pixels from the start of the panel pair.
97
+ * ハンドルが載る境界の可動域 (パネル対の start からのピクセル距離)。
98
+ */
99
+ type SeparatorBounds = {
100
+ /** Where the boundary sits now / 境界の現在位置 */
101
+ position: number
102
+ /** Lowest position this handle can reach, collapsing included / このハンドルが到達できる最小位置 (折りたたみを含む) */
103
+ lower: number
104
+ /** Highest position this handle can reach, collapsing included / このハンドルが到達できる最大位置 (折りたたみを含む) */
105
+ upper: number
106
+ /** Lowest position a plain resize can reach / 単なるリサイズで到達できる最小位置 */
107
+ resizeLower: number
108
+ /** Highest position a plain resize can reach / 単なるリサイズで到達できる最大位置 */
109
+ resizeUpper: number
110
+ }
111
+
112
+ /**
113
+ * Resolve where the boundary between two panels is and how far it can travel.
114
+ * 2 枚のパネルの境界が今どこにあり、どこまで動けるかを解決する処理。
115
+ *
116
+ * The range is what **this** handle can reach, not what the pair could reach through any means. Collapsing
117
+ * is a position the boundary can hold, so it widens the range — but only in the direction this handle is
118
+ * allowed to collapse, and a panel already collapsed can only be reopened from the side it was closed from
119
+ * (`EXPAND_PANEL` rejects any other direction). A handle that can reach nothing therefore reports a range of
120
+ * zero width instead of advertising travel it would swallow the key press for.
121
+ * 公開するのは **このハンドル** が到達できる範囲であり、対が何らかの手段で到達しうる範囲ではない。
122
+ * 折りたたみは境界が取りうる位置なので範囲を広げるが、広がるのはこのハンドルが折りたためる向きだけで、
123
+ * 既に折りたたまれたパネルは閉じたのと同じ向きからしか開けない (`EXPAND_PANEL` が他の向きを拒否する)。
124
+ * どこへも動けないハンドルは、幅 0 の範囲を報告する (動けると偽って キー入力だけ飲み込まないために)。
125
+ *
126
+ * @param startPanel - Panel before the boundary / 境界より手前のパネル
127
+ * @param endPanel - Panel after the boundary / 境界より奥のパネル
128
+ * @param containerSize - Container content-box length / コンテナ内容領域長
129
+ * @returns Boundary position and its travel range, or undefined when the pair has no length / 境界位置と可動域、対に長さが無ければ undefined
130
+ */
131
+ const resolveSeparatorBounds = (startPanel: PanelLayoutData, endPanel: PanelLayoutData, containerSize: number): SeparatorBounds | undefined => {
132
+ const startSize = startPanel.collapsed ? 0 : startPanel.size
133
+ const endSize = endPanel.collapsed ? 0 : endPanel.size
134
+ const total = startSize + endSize
135
+ if (total <= 0) {
136
+ return undefined
137
+ }
138
+ const startBounds = resolvePanelBounds(startPanel, containerSize)
139
+ const endBounds = resolvePanelBounds(endPanel, containerSize)
140
+ // 単なるリサイズで届く範囲は、双方の制約が同時に効いた区間 (reducer の RESIZE_PANELS と同じ式)
141
+ const resizeLower = Math.max(startBounds.min, total - endBounds.max)
142
+ const resizeUpper = Math.max(resizeLower, Math.min(startBounds.max, total - endBounds.min))
143
+
144
+ if (startPanel.collapsed) {
145
+ // 閉じた向きと同じ向きからしか開けない。手前のパネルはこのハンドルから見て "end" 側で閉じている
146
+ const canReopen = startPanel.collapsedByDirection === "end"
147
+ return { position: 0, lower: 0, upper: canReopen ? Math.max(0, resizeUpper) : 0, resizeLower, resizeUpper }
148
+ }
149
+ if (endPanel.collapsed) {
150
+ const canReopen = endPanel.collapsedByDirection === "start"
151
+ return { position: total, lower: canReopen ? Math.min(total, resizeLower) : total, upper: total, resizeLower, resizeUpper }
152
+ }
153
+
154
+ // 折りたたみは境界が対の端へ到達した状態そのものなので、許される向きだけ範囲を端まで広げる
155
+ const lower = Math.min(startSize, startPanel.collapseFromEnd ? 0 : resizeLower)
156
+ const upper = Math.max(startSize, lower, endPanel.collapseFromStart ? total : resizeUpper)
157
+ return { position: startSize, lower, upper, resizeLower, resizeUpper }
158
+ }
23
159
 
24
160
  /**
25
161
  * Render an interactive divider that manages adjacent panel resizing gestures.
26
162
  * 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
27
163
  */
28
- export const PanelResizeHandle = memo(({ id, disabled = false, className, style, children, onDragging }: PanelResizeHandleProps) => {
29
- const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
164
+ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
165
+ const { id, disabled = false, className, style, children, onDragging, thickness, indicator = true, indicatorThickness, indicatorLength, indicatorBarThickness, indicatorBarLength, title, onPointerDown, onKeyDown, ...elementProps } = props
166
+ const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, layoutOrderKey, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
167
+
30
168
  const [isDragging, setIsDragging] = useState(false)
31
- const handleRef = useRef<HTMLButtonElement>(null)
32
- const containerRef = useRef<HTMLElement | null>(null)
169
+ const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ start: string | null; end: string | null }>({ start: null, end: null })
33
170
 
34
- const leftPanelId = useRef<string>("")
35
- const rightPanelId = useRef<string>("")
171
+ const anchorRef = useRef<HTMLDivElement>(null)
172
+ const handleRef = useRef<HTMLButtonElement>(null)
173
+ // ドラッグ中の move ハンドラーは開始時のクロージャで動き続けるため、最新の状態は ref 経由で読む。
174
+ // これが無いと、同じジェスチャーの中で折りたたんだパネルを引き戻しても展開できない
175
+ const layoutRef = useRef({ getPanel, containerSize })
176
+ layoutRef.current = { getPanel, containerSize }
177
+ const startPanelIdRef = useRef("")
178
+ const endPanelIdRef = useRef("")
36
179
  // 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
37
180
  const dragCleanupRef = useRef<(() => void) | null>(null)
38
- // RTL コンテキストでは DOM (論理順) と視覚順が反転するため、座標計算の符号とオーバーレイ位置を切り替える
39
- const [isRtl, setIsRtl] = useState(false)
40
- const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ left: string | null; right: string | null }>({ left: null, right: null })
181
+ const lastMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
182
+
41
183
  const generatedId = useId()
42
184
  const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
43
- const lastHandleMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
44
185
  const resolvedHandleId = id ?? handleIdRef.current
186
+ const isHorizontal = direction === "horizontal"
45
187
  const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
46
188
  const isHandleDisabled = disabled || hasLayoutConstraintViolation
47
189
 
48
190
  /**
49
- * Detect the panels adjacent to this handle by scanning sibling DOM elements.
50
- * DOM の兄弟要素を走査してこのハンドルに隣接する左右のパネルを検出する。
191
+ * Detect the panels this handle sits between by scanning its siblings in the DOM.
192
+ * DOM の兄弟要素を走査して、このハンドルが挟んでいるパネルを検出する処理。
51
193
  */
52
194
  const detectAdjacentPanels = useCallback(() => {
53
- const handleElement = handleRef.current
54
- if (!handleElement) return
55
-
56
- const parent = handleElement.parentElement
57
- if (!parent) return
58
-
59
- containerRef.current = parent
60
- if (typeof window !== "undefined") {
61
- setIsRtl(window.getComputedStyle(parent).direction === "rtl")
195
+ const anchor = anchorRef.current
196
+ // 中間要素が挟まると親はグループではないため、所有グループを辿って解決する
197
+ const group = findOwningGroup(anchor)
198
+ if (!(anchor && group)) {
199
+ return
62
200
  }
63
- const childrenElements = Array.from(parent.children)
64
- const index = childrenElements.indexOf(handleElement)
65
201
 
66
- let leftPanelElement: HTMLElement | null = null
67
- let rightPanelElement: HTMLElement | null = null
68
-
69
- // ハンドルより前方の要素を逆順に走査して左パネルを特定
70
- for (let i = index - 1; i >= 0; i -= 1) {
71
- const element = childrenElements[i] as HTMLElement
72
- if (element.dataset.panelId) {
73
- leftPanelElement = element
202
+ const elements = getGroupLayoutElements(group)
203
+ const index = elements.indexOf(anchor)
204
+ let startId = ""
205
+ let endId = ""
206
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
207
+ const candidate = elements[cursor].dataset.panelId
208
+ if (candidate !== undefined) {
209
+ startId = candidate
74
210
  break
75
211
  }
76
212
  }
77
-
78
- // ハンドルより後方の要素を走査して右パネルを特定
79
- for (let i = index + 1; i < childrenElements.length; i += 1) {
80
- const element = childrenElements[i] as HTMLElement
81
- if (element.dataset.panelId) {
82
- rightPanelElement = element
213
+ for (let cursor = index + 1; cursor < elements.length; cursor += 1) {
214
+ const candidate = elements[cursor].dataset.panelId
215
+ if (candidate !== undefined) {
216
+ endId = candidate
83
217
  break
84
218
  }
85
219
  }
86
220
 
87
- const nextLeftId = leftPanelElement?.dataset.panelId || ""
88
- const nextRightId = rightPanelElement?.dataset.panelId || ""
89
-
90
- // 検出結果が前回と同じなら state 更新を省略する
91
- if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
221
+ if (startId === startPanelIdRef.current && endId === endPanelIdRef.current) {
92
222
  return
93
223
  }
94
-
95
- leftPanelId.current = nextLeftId
96
- rightPanelId.current = nextRightId
97
-
98
- setAdjacentPanelIds({
99
- left: nextLeftId || null,
100
- right: nextRightId || null,
101
- })
224
+ startPanelIdRef.current = startId
225
+ endPanelIdRef.current = endId
226
+ setAdjacentPanelIds({ start: startId || null, end: endId || null })
102
227
  }, [])
103
228
 
104
- // パネル構成 (ID 列) の変化を検知するためのキー。ドラッグ中のサイズ更新では変化しない
105
- const panelIdsKey = useMemo(() => panels.map((panel) => panel.id).join("|"), [panels])
106
-
107
229
  useEffect(() => {
108
- // マウント時とパネルの増減・並び替え時に隣接パネルを再検出する
109
- // (マウント時のみだと兄弟 Panel の条件付き追加/削除でハンドルが古い ID を掴んだままになる)
230
+ // マウント時と、グループの DOM 順が変わるたびに隣接パネルを再検出する。
231
+ // パネルの id 列だけを見ていると、ハンドル自身が別の境界へ移った並べ替えを取りこぼし、
232
+ // 見た目とは違う 2 枚を掴み続ける
110
233
  detectAdjacentPanels()
111
- }, [detectAdjacentPanels, panelIdsKey])
234
+ }, [detectAdjacentPanels, layoutOrderKey])
112
235
 
113
236
  useEffect(() => {
114
- // ハンドル ID が外部から変更された場合に、旧 ID の測定結果をクリーンアップする
115
- if (resolvedHandleId === handleIdRef.current) {
116
- return
117
- }
118
-
119
- const previousId = handleIdRef.current
120
- handleIdRef.current = resolvedHandleId
121
- lastHandleMeasurementRef.current = null
122
-
123
- if (previousId !== resolvedHandleId) {
124
- reportHandleMeasurement(previousId, null)
125
- }
126
- }, [reportHandleMeasurement, resolvedHandleId])
127
-
128
- useEffect(() => {
129
- // コンポーネントのアンマウント時に、このハンドルの測定結果を破棄する
130
- const finalId = handleIdRef.current
131
- return () => {
132
- reportHandleMeasurement(finalId, null)
133
- }
134
- }, [reportHandleMeasurement])
135
-
136
- useEffect(() => {
137
- // ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
138
237
  return () => {
238
+ // ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
139
239
  dragCleanupRef.current?.()
140
240
  }
141
241
  }, [])
142
242
 
143
243
  /**
144
- * Initiates a resize drag interaction when the handle receives a pointer down event.
244
+ * Start a resize drag when the handle receives a pointer down event.
145
245
  * リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
146
246
  */
147
247
  const handlePointerDown = useCallback(
148
248
  (event: React.PointerEvent<HTMLButtonElement>) => {
149
- if (isHandleDisabled || !containerRef.current) return
150
-
249
+ if (isHandleDisabled) {
250
+ return
251
+ }
151
252
  // 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
152
- if (dragCleanupRef.current) return
253
+ if (dragCleanupRef.current) {
254
+ return
255
+ }
256
+ // 主ボタン以外は掴まない。右ボタンで掴むと、コンテキストメニューを開きながらレイアウトが動く
257
+ // (タッチ・ペンの pointerdown も主ボタンとして 0 が届く)
258
+ if (event.button !== 0) {
259
+ return
260
+ }
261
+
262
+ const startPanel = getPanel(startPanelIdRef.current)
263
+ const endPanel = getPanel(endPanelIdRef.current)
264
+ if (!(startPanel && endPanel)) {
265
+ return
266
+ }
153
267
 
154
268
  event.preventDefault()
155
269
  event.stopPropagation()
156
270
 
157
- const container = containerRef.current
158
- const { inner: containerSize } = getContainerSize(container, direction)
159
- const startPosition = { x: event.clientX, y: event.clientY }
160
-
161
- const leftPanel = getPanel(leftPanelId.current)
162
- const rightPanel = getPanel(rightPanelId.current)
163
-
164
- if (!(leftPanel && rightPanel)) return
165
-
166
- const initialLeftSize = leftPanel.size
167
- const initialRightSize = rightPanel.size
168
-
169
- let lastUpdateTime = 0
170
- const throttleMs = PANEL_HANDLE_DRAG_THROTTLE_MS
171
-
172
- setIsDragging(true)
173
- onDragging?.(true)
271
+ const startId = startPanelIdRef.current
272
+ const endId = endPanelIdRef.current
273
+ const initialStartSize = startPanel.size
274
+ const initialTotal = startPanel.size + endPanel.size
275
+ const origin = isHorizontal ? event.clientX : event.clientY
276
+ const group = findOwningGroup(anchorRef.current)
277
+ // RTL では DOM 順 (論理順) と視覚順が反転するため、水平ドラッグの変位符号を反転する
278
+ const directionSign = isHorizontal && isRightToLeft(group) ? -1 : 1
279
+ // ポインタ座標は視覚ピクセル、配分器が扱うのはレイアウトピクセル。
280
+ // 変形された祖先の下で換算を省くと、掴んだ点がカーソルから滑り出していく
281
+ const visualScale = group ? getVisualScale(group, direction) : 1
174
282
 
175
- // このドラッグを開始したポインタだけを追跡する (2 本目のタッチ等の混入を防ぐ)
176
283
  const dragPointerId = event.pointerId
177
284
  const captureTarget = event.currentTarget
285
+ // 追従は document の pointermove / pointerup で行うため、キャプチャは無くても成立する。
286
+ // 実装しない環境 (jsdom など) で例外を投げると、利用者側のテストがドラッグを検証できなくなる
287
+ captureTarget.setPointerCapture?.(dragPointerId)
178
288
 
179
- // RTL では DOM 順 (論理順) と視覚順が反転するため、水平ドラッグの変位符号を反転する
180
- const isRtlDrag = direction === "horizontal" && typeof window !== "undefined" && window.getComputedStyle(container).direction === "rtl"
181
-
182
- // ポインタキャプチャを取得してドラッグ中のイベント配送を固定
183
- captureTarget.setPointerCapture(dragPointerId)
184
-
185
- /**
186
- * Handle pointer move events emitted during an active resize drag interaction.
187
- * リサイズドラッグ操作中に発生するポインタ移動イベントを処理する関数。
188
- */
189
- const handlePointerMove = (moveEvent: PointerEvent) => {
190
- // 別ポインタのイベントはドラッグ計算に混入させない
191
- if (moveEvent.pointerId !== dragPointerId) return
192
- if (!handleRef.current) return
289
+ setIsDragging(true)
290
+ onDragging?.(true)
193
291
 
194
- moveEvent.preventDefault()
195
- moveEvent.stopPropagation()
292
+ let lastUpdateTime = 0
293
+ let throttledMove: PointerEvent | null = null
196
294
 
197
- // スロットル処理: 頻繁な更新を抑制して計算負荷を下げる
198
- const now = performance.now()
199
- if (now - lastUpdateTime < throttleMs) return
200
- lastUpdateTime = now
295
+ const applyMove = (moveEvent: PointerEvent) => {
296
+ const delta = (((isHorizontal ? moveEvent.clientX : moveEvent.clientY) - origin) * directionSign) / visualScale
297
+ const nextStartSize = initialStartSize + delta
298
+ const nextEndSize = initialTotal - nextStartSize
201
299
 
202
- // ドラッグ開始位置からのピクセル変位を計算 (RTL の水平ドラッグは符号を反転)
203
- const rawPixelDelta = direction === "horizontal" ? moveEvent.clientX - startPosition.x : moveEvent.clientY - startPosition.y
204
- const pixelDelta = isRtlDrag ? -rawPixelDelta : rawPixelDelta
205
- // 差分がゼロのときは状態変化がなく後続処理が無駄になるため中断
206
- if (pixelDelta === 0) {
300
+ const layout = layoutRef.current
301
+ const currentStart = layout.getPanel(startId)
302
+ const currentEnd = layout.getPanel(endId)
303
+ if (!(currentStart && currentEnd)) {
207
304
  return
208
305
  }
209
306
 
210
- const newLeftSizeInPixels = initialLeftSize + pixelDelta
211
- const newRightSizeInPixels = initialRightSize - pixelDelta
212
-
213
- // 最新のパネル状態を取得して折りたたみ状態などの変化を反映
214
- const updatedLeftPanel = getPanel(leftPanelId.current)
215
- const updatedRightPanel = getPanel(rightPanelId.current)
216
-
217
- if (updatedLeftPanel && updatedRightPanel && leftPanelId.current && rightPanelId.current) {
218
- const leftMinSizePixels = getConstraintInPixels(updatedLeftPanel.minSize, 0, containerSize)
219
- const rightMinSizePixels = getConstraintInPixels(updatedRightPanel.minSize, 0, containerSize)
220
- const leftMinConstraintPixels = leftMinSizePixels
221
- const rightMinConstraintPixels = rightMinSizePixels
222
-
223
- // 折りたたみ・展開の閾値をコンテナサイズから算出
224
- const collapseThresholdPixels = Math.min(containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
225
- const expandThresholdPixels = Math.min(containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
226
-
227
- const rightCanCollapseFromStart = updatedRightPanel.collapseFromStart
228
- const rightCollapseDirection = updatedRightPanel.collapsedByDirection ?? "start"
229
- // 右パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
230
- if (rightCanCollapseFromStart && !updatedRightPanel.collapsed && newRightSizeInPixels <= Math.max(rightMinSizePixels * 0.3, collapseThresholdPixels)) {
231
- collapsePanel(rightPanelId.current, "start")
232
- return
233
- }
307
+ const collapseThreshold = Math.min(layout.containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
308
+ const expandThreshold = Math.min(layout.containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
234
309
 
235
- const leftCanCollapseFromEnd = updatedLeftPanel.collapseFromEnd
236
- const leftCollapseDirection = updatedLeftPanel.collapsedByDirection ?? "end"
237
- // 左パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
238
- if (leftCanCollapseFromEnd && !updatedLeftPanel.collapsed && newLeftSizeInPixels <= Math.max(leftMinSizePixels * 0.3, collapseThresholdPixels)) {
239
- collapsePanel(leftPanelId.current, "end")
240
- return
310
+ if (currentEnd.collapsed) {
311
+ if (nextEndSize > expandThreshold) {
312
+ expandPanel(endId, "start", nextEndSize)
241
313
  }
242
-
243
- // 右パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
244
- if (updatedRightPanel.collapsed && newRightSizeInPixels > Math.max(rightMinSizePixels * 0.3, expandThresholdPixels)) {
245
- expandPanel(rightPanelId.current, rightCollapseDirection, newRightSizeInPixels)
246
- return
247
- }
248
-
249
- // 左パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
250
- if (updatedLeftPanel.collapsed && newLeftSizeInPixels > Math.max(leftMinSizePixels * 0.3, expandThresholdPixels)) {
251
- expandPanel(leftPanelId.current, leftCollapseDirection, newLeftSizeInPixels)
252
- return
314
+ return
315
+ }
316
+ if (currentStart.collapsed) {
317
+ if (nextStartSize > expandThreshold) {
318
+ expandPanel(startId, "end", nextStartSize)
253
319
  }
320
+ return
321
+ }
322
+ if (currentEnd.collapseFromStart && nextEndSize <= collapseThreshold) {
323
+ collapsePanel(endId, "start")
324
+ return
325
+ }
326
+ if (currentStart.collapseFromEnd && nextStartSize <= collapseThreshold) {
327
+ collapsePanel(startId, "end")
328
+ return
329
+ }
254
330
 
255
- // スナップ処理: 最小サイズ以下になった場合はゼロサイズにスナップさせる
256
- if (containerSize > 0) {
257
- const totalPanelPixels = updatedLeftPanel.size + updatedRightPanel.size
258
- // コンテナ比率と定数に基づきゼロスナップの閾値を算出
259
- const snapThreshold = calculateSnapThreshold(containerSize)
260
- let snapped = false
261
-
262
- // 左パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
263
- if (leftMinConstraintPixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
264
- const snappedLeft = 0
265
- const snappedRight = Math.max(0, totalPanelPixels - snappedLeft)
266
-
267
- if (snappedRight >= rightMinConstraintPixels) {
268
- resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
269
- snapped = true
270
- }
271
- } else if (rightMinConstraintPixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
272
- // 右パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
273
- const snappedRight = 0
274
- const snappedLeft = Math.max(0, totalPanelPixels - snappedRight)
275
-
276
- if (snappedLeft >= leftMinConstraintPixels) {
277
- resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
278
- snapped = true
279
- }
280
- }
281
-
282
- if (snapped) {
283
- return
284
- }
285
- }
331
+ resizePanels(startId, endId, nextStartSize)
332
+ }
286
333
 
287
- // いずれかのパネルが折りたたまれている場合はリサイズ処理を中断
288
- if (updatedLeftPanel.collapsed || updatedRightPanel.collapsed) {
289
- return
290
- }
334
+ const handlePointerMove = (moveEvent: PointerEvent) => {
335
+ // 別ポインタのイベントはドラッグ計算に混入させない
336
+ if (moveEvent.pointerId !== dragPointerId) {
337
+ return
338
+ }
339
+ moveEvent.preventDefault()
340
+ moveEvent.stopPropagation()
291
341
 
292
- // 通常のリサイズ処理を実行して隣接パネルのサイズを更新
293
- resizePanels(leftPanelId.current, rightPanelId.current, newLeftSizeInPixels, newRightSizeInPixels)
342
+ const now = performance.now()
343
+ if (now - lastUpdateTime < PANEL_HANDLE_DRAG_THROTTLE_MS) {
344
+ // 間引いた移動は捨てずに保持する。離上時に反映しないと、指を離した位置と確定した境界がずれる
345
+ throttledMove = moveEvent
346
+ return
294
347
  }
348
+ lastUpdateTime = now
349
+ throttledMove = null
350
+ applyMove(moveEvent)
295
351
  }
296
352
 
297
353
  /**
298
354
  * Tear down the active drag: remove listeners, release capture, and reset state.
299
- * 進行中ドラッグの後始末: リスナー除去・キャプチャ解放・状態リセットを一括で行う。
355
+ * 進行中ドラッグの後始末 (リスナー除去・キャプチャ解放・状態リセット)。
300
356
  * pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
301
357
  */
302
358
  let dragActive = true
303
359
  const stopDragging = () => {
304
- if (!dragActive) return
360
+ if (!dragActive) {
361
+ return
362
+ }
305
363
  dragActive = false
306
364
  dragCleanupRef.current = null
307
-
308
365
  setIsDragging(false)
309
366
  onDragging?.(false)
310
367
 
@@ -319,20 +376,27 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
319
376
 
320
377
  const handlePointerUp = (upEvent: PointerEvent) => {
321
378
  // 別ポインタの離上ではドラッグを終了しない
322
- if (upEvent.pointerId !== dragPointerId) return
379
+ if (upEvent.pointerId !== dragPointerId) {
380
+ return
381
+ }
382
+ if (throttledMove) {
383
+ const pending = throttledMove
384
+ throttledMove = null
385
+ applyMove(pending)
386
+ }
323
387
  stopDragging()
324
388
  }
325
-
326
389
  const handlePointerCancel = (cancelEvent: PointerEvent) => {
327
390
  // OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
328
- if (cancelEvent.pointerId !== dragPointerId) return
329
- stopDragging()
391
+ if (cancelEvent.pointerId === dragPointerId) {
392
+ stopDragging()
393
+ }
330
394
  }
331
-
332
395
  const handleLostPointerCapture = (lostEvent: PointerEvent) => {
333
396
  // キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
334
- if (lostEvent.pointerId !== dragPointerId) return
335
- stopDragging()
397
+ if (lostEvent.pointerId === dragPointerId) {
398
+ stopDragging()
399
+ }
336
400
  }
337
401
 
338
402
  document.addEventListener("pointermove", handlePointerMove)
@@ -341,169 +405,103 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
341
405
  captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
342
406
  dragCleanupRef.current = stopDragging
343
407
  },
344
- [isHandleDisabled, direction, resizePanels, getPanel, collapsePanel, expandPanel, onDragging],
408
+ [isHandleDisabled, isHorizontal, direction, getPanel, resizePanels, collapsePanel, expandPanel, onDragging],
345
409
  )
346
410
 
347
411
  /**
348
412
  * Resize adjacent panels with arrow keys for keyboard accessibility.
349
413
  * キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
350
- * Shift 併用で 5 倍のステップ幅を適用する。
351
414
  */
352
415
  const handleKeyDown = useCallback(
353
416
  (event: React.KeyboardEvent<HTMLButtonElement>) => {
354
- if (isHandleDisabled) return
417
+ if (isHandleDisabled) {
418
+ return
419
+ }
420
+ const decreaseKey = isHorizontal ? "ArrowLeft" : "ArrowUp"
421
+ const increaseKey = isHorizontal ? "ArrowRight" : "ArrowDown"
422
+ if (event.key !== decreaseKey && event.key !== increaseKey) {
423
+ return
424
+ }
355
425
 
356
- const isHorizontalDirection = direction === "horizontal"
357
- const decreaseKey = isHorizontalDirection ? "ArrowLeft" : "ArrowUp"
358
- const increaseKey = isHorizontalDirection ? "ArrowRight" : "ArrowDown"
359
- if (event.key !== decreaseKey && event.key !== increaseKey) return
426
+ const startId = startPanelIdRef.current
427
+ const endId = endPanelIdRef.current
428
+ const startPanel = getPanel(startId)
429
+ const endPanel = getPanel(endId)
430
+ if (!(startPanel && endPanel)) {
431
+ return
432
+ }
433
+ const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
434
+ // どこへも動けないハンドルはキーを飲み込まない。飲み込むと、何も起きないまま
435
+ // ページのスクロールまで止まる「効かないキー」になる
436
+ if (!bounds || bounds.upper - bounds.lower <= PANEL_ALLOCATION_EPSILON) {
437
+ return
438
+ }
360
439
 
361
440
  event.preventDefault()
362
441
  event.stopPropagation()
363
442
 
364
- let delta = (event.key === increaseKey ? 1 : -1) * (event.shiftKey ? PANEL_KEYBOARD_RESIZE_STEP * 5 : PANEL_KEYBOARD_RESIZE_STEP)
365
- // RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する (ドラッグ開始時と同様に押下時点の値を都度参照)
366
- const isRtlNow = isHorizontalDirection && typeof window !== "undefined" && containerRef.current !== null && window.getComputedStyle(containerRef.current).direction === "rtl"
367
- if (isRtlNow) {
368
- delta = -delta
369
- }
370
-
371
- const leftPanel = getPanel(leftPanelId.current)
372
- const rightPanel = getPanel(rightPanelId.current)
373
- if (!(leftPanel && rightPanel) || leftPanel.collapsed || rightPanel.collapsed) return
374
-
375
- // 制約の最終判定はリデューサー側で行われる
376
- resizePanels(leftPanelId.current, rightPanelId.current, leftPanel.size + delta, rightPanel.size - delta)
377
- },
378
- [isHandleDisabled, direction, getPanel, resizePanels],
379
- )
380
-
381
- const isHorizontal = direction === "horizontal"
382
- const cursor = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
383
-
384
- const baseHandleClasses = twMerge(
385
- "group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
386
- cursor,
387
- isHorizontal ? "h-full" : "w-full",
388
- !isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
389
- !isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
390
- isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
391
- isHandleDisabled && "cursor-not-allowed opacity-50",
392
- "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
393
- className,
394
- )
395
-
396
- const adjacentIndices = useMemo(() => {
397
- // パネル ID からパネル配列内のインデックスを探索するヘルパー関数
398
- const findIndex = (panelId: string | null) => {
399
- if (!panelId) {
400
- return -1
443
+ const step = event.shiftKey ? PANEL_KEYBOARD_RESIZE_STEP * 5 : PANEL_KEYBOARD_RESIZE_STEP
444
+ // RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する
445
+ const directionSign = isHorizontal && isRightToLeft(findOwningGroup(anchorRef.current)) ? -1 : 1
446
+ const delta = (event.key === increaseKey ? step : -step) * directionSign
447
+
448
+ // 折りたたまれたパネルは、境界を自分の方へ押し返すキーで開く。
449
+ // 開けないと、キーボードだけを使う利用者にとって折りたたみは戻れない行き止まりになる
450
+ if (startPanel.collapsed) {
451
+ if (delta > 0) {
452
+ // 目標長を渡さずに開く: 折りたたむ前の長さへ戻すのが「開く」の意味であり、
453
+ // 1 ステップ分だけ開いても最小値へ切り上げられるだけで何も伝わらない
454
+ expandPanel(startId, "end")
455
+ }
456
+ return
401
457
  }
402
- for (let index = 0; index < panels.length; index += 1) {
403
- if (panels[index].id === panelId) {
404
- return index
458
+ if (endPanel.collapsed) {
459
+ if (delta < 0) {
460
+ expandPanel(endId, "start")
405
461
  }
462
+ return
406
463
  }
407
- return -1
408
- }
409
-
410
- return {
411
- left: findIndex(adjacentPanelIds.left),
412
- right: findIndex(adjacentPanelIds.right),
413
- }
414
- }, [adjacentPanelIds.left, adjacentPanelIds.right, panels])
415
464
 
416
- const containerLength = isHorizontal ? containerSize.width : containerSize.height
417
-
418
- const boundaryOffset = useMemo(() => {
419
- // 指定範囲のパネルサイズ合計を安全に算出するヘルパー
420
- const safeSum = (startIndex: number, endIndexInclusive: number) => {
421
- if (startIndex > endIndexInclusive) {
422
- return 0
465
+ // リサイズで届く端まで来たパネルをさらに押すキーは、ドラッグで端まで引いたときと同じく折りたたみになる。
466
+ // 判定に使うのはパネル自身の最小値ではなく対の可動域の端である。隣の上限が先に効く構成では、
467
+ // パネルは自分の最小値へ決して到達できず、キーボードからは永久に折りたためなくなる
468
+ if (delta < 0 && startPanel.collapseFromEnd && bounds.position <= bounds.resizeLower + PANEL_ALLOCATION_EPSILON) {
469
+ collapsePanel(startId, "end")
470
+ return
423
471
  }
424
- let total = 0
425
- for (let index = startIndex; index <= endIndexInclusive; index += 1) {
426
- const panel = panels[index]
427
- if (!panel) {
428
- continue
429
- }
430
- const candidateSize = panel.size ?? 0
431
- const sanitizedSize = Math.max(0, candidateSize)
432
- total += panel.collapsed ? 0 : sanitizedSize
472
+ if (delta > 0 && endPanel.collapseFromStart && bounds.position >= bounds.resizeUpper - PANEL_ALLOCATION_EPSILON) {
473
+ collapsePanel(endId, "start")
474
+ return
433
475
  }
434
- return total
435
- }
436
-
437
- if (adjacentIndices.left >= 0) {
438
- return safeSum(0, adjacentIndices.left)
439
- }
440
-
441
- if (adjacentIndices.right > 0) {
442
- return safeSum(0, adjacentIndices.right - 1)
443
- }
444
-
445
- return 0
446
- }, [adjacentIndices.left, adjacentIndices.right, panels])
447
-
448
- const clampedOffset = useMemo(() => {
449
- // 境界位置が無効値やコンテナ範囲外に逸脱した場合に補正
450
- if (!Number.isFinite(boundaryOffset) || boundaryOffset < 0) {
451
- return 0
452
- }
453
- if (containerLength > 0 && boundaryOffset > containerLength) {
454
- return containerLength
455
- }
456
- return boundaryOffset
457
- }, [boundaryOffset, containerLength])
476
+ resizePanels(startId, endId, startPanel.size + delta)
477
+ },
478
+ [isHandleDisabled, isHorizontal, getPanel, resizePanels, collapsePanel, expandPanel, containerSize],
479
+ )
458
480
 
459
- const overlayBaseStyle = useMemo<CSSProperties>(() => {
460
- const base: CSSProperties = {
461
- position: "absolute",
462
- zIndex: 20,
463
- touchAction: "none",
464
- }
481
+ const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
482
+ const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
465
483
 
466
- if (isHorizontal) {
467
- base.top = 0
468
- // clampedOffset DOM (論理順) の先頭からの距離。RTL では右端基準へ変換して物理 left を求める
469
- base.left = isRtl && containerLength > 0 ? `${containerLength - clampedOffset}px` : `${clampedOffset}px`
470
- base.transform = "translateX(-50%)"
471
- base.width = `${PANEL_HANDLE_THICKNESS}px`
472
- base.height = "100%"
473
- } else {
474
- base.left = 0
475
- base.top = `${clampedOffset}px`
476
- base.transform = "translateY(-50%)"
477
- base.height = `${PANEL_HANDLE_THICKNESS}px`
478
- base.width = "100%"
479
- }
484
+ const resolvedThickness = Math.max(1, sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS))
485
+ const defaultIndicatorLength = isHorizontal ? PANEL_INDICATOR_LENGTH_HORIZONTAL : PANEL_INDICATOR_LENGTH_VERTICAL
486
+ const defaultBarLength = isHorizontal ? PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL : PANEL_INDICATOR_BAR_LENGTH_VERTICAL
480
487
 
481
- return base
482
- }, [clampedOffset, isHorizontal, isRtl, containerLength])
488
+ // 幾何学的内包不変条件: 内部バー寸法がインジケーター外枠およびハンドル操作領域を超えないようクランプ
489
+ const resolvedIndicatorThickness = Math.min(resolvedThickness, Math.max(1, sanitizeDimension(indicatorThickness ?? indicatorConfig?.thickness, PANEL_INDICATOR_THICKNESS)))
490
+ const resolvedIndicatorLength = Math.max(1, sanitizeDimension(indicatorLength ?? indicatorConfig?.length, defaultIndicatorLength))
491
+ const resolvedBarThickness = Math.min(resolvedIndicatorThickness, Math.max(1, sanitizeDimension(indicatorBarThickness ?? indicatorConfig?.barThickness, PANEL_INDICATOR_BAR_THICKNESS)))
492
+ const resolvedBarLength = Math.min(resolvedIndicatorLength, Math.max(1, sanitizeDimension(indicatorBarLength ?? indicatorConfig?.barLength, defaultBarLength)))
483
493
 
484
494
  const indicatorGrouping = useMemo(() => {
485
- const snapThreshold = calculateSnapThreshold(containerLength)
486
- // パネルが実質的に折りたたまれているか判定するヘルパー関数
495
+ // 隣接パネルが閉じていると複数のハンドルが同じ境界へ重なるため、その重なり具合を数える
496
+ const snapThreshold = calculateSnapThreshold(containerSize)
487
497
  const isEffectivelyCollapsed = (panelIndex: number) => {
488
- if (panelIndex < 0 || panelIndex >= panels.length) {
489
- return false
490
- }
491
498
  const target = panels[panelIndex]
492
499
  if (!target) {
493
500
  return false
494
501
  }
495
- if (target.collapsed) {
496
- return true
497
- }
498
- return target.size <= snapThreshold
502
+ return target.collapsed || target.size <= snapThreshold
499
503
  }
500
-
501
- // 指定インデックスから連続する折りたたみパネルの数を数える
502
504
  const countCollapsed = (startIndex: number, step: 1 | -1) => {
503
- if (!isEffectivelyCollapsed(startIndex)) {
504
- return 0
505
- }
506
-
507
505
  let count = 0
508
506
  for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
509
507
  if (!isEffectivelyCollapsed(index)) {
@@ -514,205 +512,247 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
514
512
  return count
515
513
  }
516
514
 
517
- const leftIndex = adjacentIndices.left
518
- const rightIndex = adjacentIndices.right
519
- const leftCollapsedCount = leftIndex >= 0 ? countCollapsed(leftIndex, -1) : 0
520
- const rightCollapsedCount = rightIndex >= 0 ? countCollapsed(rightIndex, 1) : 0
521
- const groupSize = leftCollapsedCount + rightCollapsedCount + 1
522
- const overlapIndex = leftCollapsedCount
515
+ const startIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.start)
516
+ const endIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.end)
517
+ const startCollapsedCount = startIndex >= 0 ? countCollapsed(startIndex, -1) : 0
518
+ const endCollapsedCount = endIndex >= 0 ? countCollapsed(endIndex, 1) : 0
523
519
 
524
- return {
525
- groupSize,
526
- overlapIndex,
527
- }
528
- }, [adjacentIndices.left, adjacentIndices.right, panels, containerLength])
520
+ return { groupSize: startCollapsedCount + endCollapsedCount + 1, overlapIndex: startCollapsedCount }
521
+ }, [adjacentPanelIds.start, adjacentPanelIds.end, panels, containerSize])
529
522
 
530
523
  const shouldHideHandle = useMemo(() => {
531
524
  if (hasLayoutConstraintViolation) {
532
525
  return true
533
526
  }
534
- // グループサイズが 1 以下の場合は単独ハンドルなので非表示にしない
535
527
  if (indicatorGrouping.groupSize <= 1) {
536
528
  return false
537
529
  }
538
- // ハンドルがグループ内の中間位置にある場合は重複防止のため非表示にする
539
- const lastIndex = indicatorGrouping.groupSize - 1
540
- return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
530
+ // 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
531
+ return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < indicatorGrouping.groupSize - 1
541
532
  }, [indicatorGrouping, hasLayoutConstraintViolation])
542
533
 
543
534
  const indicatorOffset = useMemo(() => {
544
- // ハンドルが非表示の場合またはグループサイズが 1 以下の場合はオフセット不要
545
535
  if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
546
536
  return 0
547
537
  }
538
+ // 重なった 2 つの端点インジケーターを等間隔で振り分ける (半ピクセル除算を避けるため偶数間隔)
539
+ const spacing = ensureEven(Math.max(PANEL_VISIBLE_INDICATOR_SPACING, resolvedIndicatorThickness + 4))
540
+ return (indicatorGrouping.overlapIndex === 0 ? -0.5 : 0.5) * spacing
541
+ }, [indicatorGrouping, shouldHideHandle, resolvedIndicatorThickness])
548
542
 
549
- // 表示すべきインジケーターの個数を決定
550
- const visibleCount = indicatorGrouping.groupSize > 1 ? 2 : 1
551
- if (visibleCount === 1) {
552
- return 0
553
- }
554
-
555
- // このハンドルが表示インジケーター群内のどの位置にあるか計算
556
- const visibleIndex = indicatorGrouping.overlapIndex === 0 ? 0 : visibleCount - 1
557
- const centerIndex = (visibleCount - 1) / 2
558
- return (visibleIndex - centerIndex) * PANEL_VISIBLE_INDICATOR_SPACING
559
- }, [indicatorGrouping, shouldHideHandle])
543
+ const anchorStyle = useMemo<CSSProperties>(() => {
544
+ const base: CSSProperties = {
545
+ // 長さ 0 のフレックスアイテム: フレックスレイアウトがこれを境界そのものへ配置する
546
+ position: "relative",
547
+ flex: "0 0 0px",
548
+ alignSelf: "stretch",
549
+ // DOM 順を配置順の真実として扱うため、クラス由来の order で並びが変わらないようにする
550
+ order: 0,
551
+ margin: 0,
552
+ overflow: "visible",
553
+ ...(isHorizontal ? { width: 0, minWidth: 0 } : { height: 0, minHeight: 0 }),
554
+ }
555
+ return shouldHideHandle ? { ...base, ...HIDDEN_HANDLE_STYLE } : base
556
+ }, [isHorizontal, shouldHideHandle])
557
+
558
+ // 利用者が独自に装飾できるよう、解決済みの寸法を CSS 変数として公開する
559
+ const customProperties = useMemo<Record<string, string>>(
560
+ () => ({
561
+ "--rp-handle-thickness": `${resolvedThickness}px`,
562
+ "--rp-indicator-thickness": `${resolvedIndicatorThickness}px`,
563
+ "--rp-indicator-length": `${resolvedIndicatorLength}px`,
564
+ "--rp-indicator-bar-thickness": `${resolvedBarThickness}px`,
565
+ "--rp-indicator-bar-length": `${resolvedBarLength}px`,
566
+ }),
567
+ [resolvedThickness, resolvedIndicatorThickness, resolvedIndicatorLength, resolvedBarThickness, resolvedBarLength],
568
+ )
560
569
 
561
- const indicatorWrapperStyle = useMemo(() => {
562
- // ハンドルを非表示にする場合は表示なし
563
- if (shouldHideHandle) {
564
- return { display: "none" }
570
+ const handleStyle = useMemo<CSSProperties>(() => {
571
+ // アンカー原点が境界。半厚みだけ手前へずらすと操作領域の中心線が境界に重なる。
572
+ // 端数は描画時にピクセルへスナップされるため、ここで丸める必要はない
573
+ return {
574
+ ...customProperties,
575
+ ...style,
576
+ // ここから下は境界に載るという前提そのもの。利用者のスタイルやクラスで変わると
577
+ // 操作領域の中心が境界から外れ、touch-action が戻るとタッチではスクロールが優先されてしまう
578
+ position: "absolute",
579
+ zIndex: 20,
580
+ touchAction: "none",
581
+ // 位置は論理プロパティで与える。アンカーは寸法 0 なので RTL でも同じ点に重なるが、
582
+ // 「境界からの距離」という意図をそのまま表せる
583
+ ...(isHorizontal ? { insetBlockStart: 0, insetInlineStart: `${-resolvedThickness / 2}px`, width: `${resolvedThickness}px`, height: "100%" } : { insetInlineStart: 0, insetBlockStart: `${-resolvedThickness / 2}px`, height: `${resolvedThickness}px`, width: "100%" }),
584
+ }
585
+ }, [isHorizontal, resolvedThickness, customProperties, style])
586
+
587
+ const indicatorWrapperStyle = useMemo<CSSProperties>(() => {
588
+ if (shouldHideHandle || isIndicatorExplicitlyHidden) {
589
+ return HIDDEN_INDICATOR_STYLE
590
+ }
591
+ const dynamicStyle: CSSProperties = {
592
+ width: `${isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength}px`,
593
+ height: `${isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness}px`,
594
+ flexShrink: 0,
595
+ }
596
+ if (indicatorOffset !== 0) {
597
+ // 論理方向のオフセットなので RTL では自動的に鏡像になる (符号を分岐させない)
598
+ dynamicStyle.position = "relative"
599
+ if (isHorizontal) {
600
+ dynamicStyle.insetInlineStart = `${indicatorOffset}px`
601
+ } else {
602
+ dynamicStyle.insetBlockStart = `${indicatorOffset}px`
603
+ }
565
604
  }
605
+ return dynamicStyle
606
+ }, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
566
607
 
567
- // オフセットがゼロの場合はスタイル不要
568
- if (indicatorOffset === 0) {
569
- return undefined
570
- }
608
+ const indicatorBarStyle = useMemo<CSSProperties>(
609
+ () => ({
610
+ width: `${isHorizontal ? resolvedBarThickness : resolvedBarLength}px`,
611
+ height: `${isHorizontal ? resolvedBarLength : resolvedBarThickness}px`,
612
+ flexShrink: 0,
613
+ }),
614
+ [isHorizontal, resolvedBarThickness, resolvedBarLength],
615
+ )
571
616
 
572
- // オフセット分だけインジケーターを移動
573
- return isHorizontal ? { transform: `translateX(${indicatorOffset}px)` } : { transform: `translateY(${indicatorOffset}px)` }
574
- }, [indicatorOffset, isHorizontal, shouldHideHandle])
617
+ const baseHandleClasses = useMemo(
618
+ () =>
619
+ twMerge(
620
+ "group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
621
+ isHorizontal ? "cursor-col-resize h-full" : "cursor-row-resize w-full",
622
+ !isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
623
+ !isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
624
+ isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
625
+ isHandleDisabled && "cursor-not-allowed opacity-50",
626
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
627
+ className,
628
+ ),
629
+ [isHorizontal, isHandleDisabled, isDragging, className],
630
+ )
575
631
 
576
- const indicatorWrapperClasses = twMerge(
577
- "flex items-center justify-center rounded-full bg-white text-slate-700 ring-1 ring-inset ring-white/80 backdrop-blur-sm transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.85),0_1px_3px_rgba(15,23,42,0.18)]",
578
- "dark:bg-slate-800/95 dark:text-slate-200 dark:ring-slate-500/60 dark:shadow-[0_0_0_1px_rgba(148,163,184,0.45),0_1px_3px_rgba(2,6,23,0.55)]",
579
- isHorizontal ? "h-12 w-[6px] px-[2px]" : "h-[6px] w-20 py-[2px]",
580
- isDragging && "bg-blue-200/80 ring-blue-300 shadow-[0_0_0_1px_rgba(191,219,254,0.65),0_1px_4px_rgba(59,130,246,0.45)] dark:bg-blue-900/70 dark:ring-blue-400 dark:shadow-[0_0_0_1px_rgba(147,197,253,0.5),0_1px_4px_rgba(59,130,246,0.55)]",
632
+ const indicatorWrapperClasses = useMemo(
633
+ () =>
634
+ twMerge(
635
+ "pointer-events-none flex items-center justify-center rounded-full bg-white text-slate-700 ring-1 ring-inset ring-white/80 backdrop-blur-sm transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.85),0_1px_3px_rgba(15,23,42,0.18)]",
636
+ "dark:bg-slate-800/95 dark:text-slate-200 dark:ring-slate-500/60 dark:shadow-[0_0_0_1px_rgba(148,163,184,0.45),0_1px_3px_rgba(2,6,23,0.55)]",
637
+ isDragging && "bg-blue-200/80 ring-blue-300 shadow-[0_0_0_1px_rgba(191,219,254,0.65),0_1px_4px_rgba(59,130,246,0.45)] dark:bg-blue-900/70 dark:ring-blue-400 dark:shadow-[0_0_0_1px_rgba(147,197,253,0.5),0_1px_4px_rgba(59,130,246,0.55)]",
638
+ indicatorConfig?.className,
639
+ ),
640
+ [isDragging, indicatorConfig?.className],
581
641
  )
582
642
 
583
- const indicatorBarClasses = twMerge(
584
- "rounded-full bg-slate-600 transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.9)] dark:bg-slate-200 dark:shadow-[0_0_0_1px_rgba(15,23,42,0.55)]",
585
- isHorizontal ? "h-8 w-[2px]" : "h-[2px] w-16",
586
- isDragging && "bg-blue-500 dark:bg-blue-300",
587
- !isHandleDisabled && "group-hover:bg-slate-700 dark:group-hover:bg-slate-100",
643
+ const indicatorBarClasses = useMemo(
644
+ () =>
645
+ twMerge(
646
+ "pointer-events-none rounded-full bg-slate-600 transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.9)] dark:bg-slate-200 dark:shadow-[0_0_0_1px_rgba(15,23,42,0.55)]",
647
+ isDragging && "bg-blue-500 dark:bg-blue-300",
648
+ !isHandleDisabled && "group-hover:bg-slate-700 dark:group-hover:bg-slate-100",
649
+ indicatorConfig?.barClassName,
650
+ ),
651
+ [isDragging, isHandleDisabled, indicatorConfig?.barClassName],
588
652
  )
589
653
 
590
- const updateHandleMeasurement = useCallback(() => {
591
- const element = handleRef.current
592
- if (!element) {
654
+ useEffect(() => {
655
+ // ハンドル ID が外部から変更された場合に、旧 ID の測定結果を破棄する
656
+ if (resolvedHandleId === handleIdRef.current) {
593
657
  return
594
658
  }
659
+ const previousId = handleIdRef.current
660
+ handleIdRef.current = resolvedHandleId
661
+ lastMeasurementRef.current = null
662
+ reportHandleMeasurement(previousId, null)
663
+ }, [reportHandleMeasurement, resolvedHandleId])
595
664
 
596
- let thickness = 0
597
-
598
- // ハンドルが非表示の場合は厚みをゼロとして扱う
599
- if (!shouldHideHandle) {
600
- const baseSize = direction === "horizontal" ? element.offsetWidth : element.offsetHeight
601
- let marginStart = 0
602
- let marginEnd = 0
603
-
604
- // CSS マージンを含めた実測厚みを計算
605
- if (typeof window !== "undefined") {
606
- const computedStyle = window.getComputedStyle(element)
607
- if (direction === "horizontal") {
608
- marginStart = Number.parseFloat(computedStyle.marginLeft || "0")
609
- marginEnd = Number.parseFloat(computedStyle.marginRight || "0")
610
- } else {
611
- marginStart = Number.parseFloat(computedStyle.marginTop || "0")
612
- marginEnd = Number.parseFloat(computedStyle.marginBottom || "0")
613
- }
614
- }
615
-
616
- const candidates = [baseSize, marginStart, marginEnd]
617
- thickness = candidates.reduce((sum, value) => {
618
- if (!Number.isFinite(value)) {
619
- return sum
620
- }
621
- return sum + value
622
- }, 0)
623
- }
624
-
625
- if (!Number.isFinite(thickness) || thickness < 0) {
626
- thickness = 0
627
- }
628
-
629
- // 測定結果オブジェクトを構築
665
+ useEffect(() => {
630
666
  const measurement: ResizeHandleLayoutData = {
631
667
  id: resolvedHandleId,
632
- thickness,
668
+ thickness: shouldHideHandle ? 0 : resolvedThickness,
633
669
  direction,
634
- visible: !shouldHideHandle && thickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
635
- startPanelId: adjacentPanelIds.left,
636
- endPanelId: adjacentPanelIds.right,
670
+ visible: !shouldHideHandle && resolvedThickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
671
+ startPanelId: adjacentPanelIds.start,
672
+ endPanelId: adjacentPanelIds.end,
637
673
  }
638
-
639
- const lastMeasurement = lastHandleMeasurementRef.current
640
-
641
- // 測定値が前回と実質的に同一であれば更新をスキップ
642
- if (
643
- lastMeasurement &&
644
- Math.abs(lastMeasurement.thickness - measurement.thickness) <= 0.25 &&
645
- lastMeasurement.visible === measurement.visible &&
646
- lastMeasurement.direction === measurement.direction &&
647
- lastMeasurement.startPanelId === measurement.startPanelId &&
648
- lastMeasurement.endPanelId === measurement.endPanelId
649
- ) {
650
- return
651
- }
652
-
653
- lastHandleMeasurementRef.current = measurement
674
+ lastMeasurementRef.current = measurement
654
675
  reportHandleMeasurement(resolvedHandleId, measurement)
655
- }, [direction, adjacentPanelIds.left, adjacentPanelIds.right, reportHandleMeasurement, resolvedHandleId, shouldHideHandle])
676
+ }, [resolvedHandleId, resolvedThickness, shouldHideHandle, direction, adjacentPanelIds.start, adjacentPanelIds.end, reportHandleMeasurement])
656
677
 
657
678
  useEffect(() => {
658
- updateHandleMeasurement()
659
- }, [updateHandleMeasurement])
660
-
661
- useEffect(() => {
662
- const element = handleRef.current
663
- if (!element) {
664
- return
679
+ return () => {
680
+ // アンマウント時にこのハンドルの測定結果を破棄する。
681
+ // ID は effect の実行時ではなく後始末の時点で読む (途中で ID を変えたハンドルの登録が残らないように)
682
+ reportHandleMeasurement(handleIdRef.current, null)
665
683
  }
684
+ }, [reportHandleMeasurement])
666
685
 
667
- // ResizeObserver が利用できない環境では初回のみ測定
668
- if (typeof ResizeObserver === "undefined") {
669
- updateHandleMeasurement()
670
- return
671
- }
686
+ // 利用者のハンドラーを黙って捨てず、先に呼んでから自分の処理へ進む。
687
+ // preventDefault() されたイベントは利用者が握り潰したものとして、ジェスチャーを開始しない
688
+ const composedPointerDown = useCallback(
689
+ (event: React.PointerEvent<HTMLButtonElement>) => {
690
+ onPointerDown?.(event)
691
+ if (!event.defaultPrevented) {
692
+ handlePointerDown(event)
693
+ }
694
+ },
695
+ [onPointerDown, handlePointerDown],
696
+ )
672
697
 
673
- // ハンドル要素のサイズ変化を監視して測定結果を更新
674
- const observer = new ResizeObserver(() => {
675
- updateHandleMeasurement()
676
- })
698
+ const composedKeyDown = useCallback(
699
+ (event: React.KeyboardEvent<HTMLButtonElement>) => {
700
+ onKeyDown?.(event)
701
+ if (!event.defaultPrevented) {
702
+ handleKeyDown(event)
703
+ }
704
+ },
705
+ [onKeyDown, handleKeyDown],
706
+ )
677
707
 
678
- observer.observe(element)
708
+ const accessibleLabel = `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`
679
709
 
680
- return () => {
681
- observer.disconnect()
710
+ const separatorValues = useMemo(() => {
711
+ // スプリッターの現在値は「境界より手前のパネルがコンテナに占める割合」。可動域も同じ尺度で公開する。
712
+ // 折りたたまれたパネルは長さ 0 として扱う: 値を落とすと role="separator" が必須属性を失い、
713
+ // 支援技術からは「動かせない仕切り」に見えてしまう
714
+ const startPanel = adjacentPanelIds.start === null ? undefined : getPanel(adjacentPanelIds.start)
715
+ const endPanel = adjacentPanelIds.end === null ? undefined : getPanel(adjacentPanelIds.end)
716
+ if (!(startPanel && endPanel) || containerSize <= 0) {
717
+ return undefined
682
718
  }
683
- }, [updateHandleMeasurement])
684
-
685
- const combinedStyle = useMemo<CSSProperties>(() => {
686
- const merged = style ? { ...overlayBaseStyle, ...style } : overlayBaseStyle
687
- if (shouldHideHandle) {
688
- return {
689
- ...merged,
690
- display: "none" as CSSProperties["display"],
691
- pointerEvents: "none" as CSSProperties["pointerEvents"],
692
- visibility: "hidden" as CSSProperties["visibility"],
693
- }
719
+ const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
720
+ if (!bounds) {
721
+ return undefined
694
722
  }
695
- return merged
696
- }, [overlayBaseStyle, shouldHideHandle, style])
723
+ const toPercentage = (value: number) => Math.round((value / containerSize) * 100)
724
+ return { now: toPercentage(bounds.position), min: toPercentage(bounds.lower), max: toPercentage(bounds.upper) }
725
+ }, [adjacentPanelIds.start, adjacentPanelIds.end, getPanel, containerSize])
697
726
 
698
727
  return (
699
- <button
700
- type="button"
701
- ref={handleRef}
702
- className={baseHandleClasses}
703
- style={combinedStyle}
704
- onPointerDown={handlePointerDown}
705
- onKeyDown={handleKeyDown}
706
- disabled={isHandleDisabled}
707
- aria-hidden={shouldHideHandle || undefined}
708
- aria-label={`Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`}
709
- data-resize-handle-id={resolvedHandleId}
710
- title={`ドラッグして${isHorizontal ? "" : "行"}をリサイズ、端まで移動して折りたたみ/展開`}>
711
- {children || (
712
- <div className={indicatorWrapperClasses} style={indicatorWrapperStyle}>
713
- <span className={indicatorBarClasses} />
714
- </div>
715
- )}
716
- </button>
728
+ <div ref={anchorRef} style={anchorStyle} data-resize-handle-anchor="">
729
+ {/* biome-ignore lint/a11y/useSemanticElements: 操作できるスプリッターは <hr> では表現できない (WAI-ARIA の window splitter パターンは focusable な role="separator" を要求する) */}
730
+ <button
731
+ {...elementProps}
732
+ type="button"
733
+ ref={handleRef}
734
+ className={baseHandleClasses}
735
+ style={handleStyle}
736
+ onPointerDown={composedPointerDown}
737
+ onKeyDown={composedKeyDown}
738
+ disabled={isHandleDisabled}
739
+ role="separator"
740
+ aria-orientation={isHorizontal ? "vertical" : "horizontal"}
741
+ aria-valuenow={separatorValues?.now}
742
+ aria-valuemin={separatorValues?.min}
743
+ aria-valuemax={separatorValues?.max}
744
+ aria-disabled={isHandleDisabled || undefined}
745
+ aria-hidden={shouldHideHandle || undefined}
746
+ aria-label={elementProps["aria-label"] ?? accessibleLabel}
747
+ data-resize-handle-id={resolvedHandleId}
748
+ title={title ?? accessibleLabel}>
749
+ {children ||
750
+ (!isIndicatorExplicitlyHidden && (
751
+ <div className={indicatorWrapperClasses} style={indicatorWrapperStyle} data-resize-handle-indicator="" aria-hidden="true">
752
+ <span className={indicatorBarClasses} style={indicatorBarStyle} data-resize-handle-indicator-bar="" aria-hidden="true" />
753
+ </div>
754
+ ))}
755
+ </button>
756
+ </div>
717
757
  )
718
758
  })