@aiquants/resize-panels 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/GlobalDebugOverlay.d.ts +4 -0
  2. package/dist/Panel.d.ts +13 -0
  3. package/dist/{src/PanelDebugInfo.d.ts → PanelDebugInfo.d.ts} +2 -14
  4. package/dist/PanelGroup.d.ts +2 -0
  5. package/dist/PanelResizeHandle.d.ts +2 -0
  6. package/dist/allocateLayout.d.ts +12 -0
  7. package/dist/context.d.ts +3 -0
  8. package/dist/debugOverlayStore.d.ts +32 -0
  9. package/dist/{src/hooks.d.ts → hooks.d.ts} +8 -23
  10. package/dist/index.d.ts +13 -2
  11. package/dist/reducer.d.ts +8 -0
  12. package/dist/roundHalfToEven.d.ts +1 -0
  13. package/dist/types.d.ts +183 -0
  14. package/dist/utils/simple-logger.d.ts +37 -0
  15. package/dist/utils.d.ts +16 -0
  16. package/package.json +2 -3
  17. package/dist/src/GlobalDebugOverlay.d.ts +0 -21
  18. package/dist/src/GlobalDebugOverlay.d.ts.map +0 -1
  19. package/dist/src/Panel.d.ts +0 -46
  20. package/dist/src/Panel.d.ts.map +0 -1
  21. package/dist/src/PanelDebugInfo.d.ts.map +0 -1
  22. package/dist/src/PanelGroup.d.ts +0 -7
  23. package/dist/src/PanelGroup.d.ts.map +0 -1
  24. package/dist/src/PanelResizeHandle.d.ts +0 -7
  25. package/dist/src/PanelResizeHandle.d.ts.map +0 -1
  26. package/dist/src/allocateLayout.d.ts +0 -60
  27. package/dist/src/allocateLayout.d.ts.map +0 -1
  28. package/dist/src/context.d.ts +0 -12
  29. package/dist/src/context.d.ts.map +0 -1
  30. package/dist/src/debugOverlayStore.d.ts +0 -69
  31. package/dist/src/debugOverlayStore.d.ts.map +0 -1
  32. package/dist/src/hooks.d.ts.map +0 -1
  33. package/dist/src/index.d.ts +0 -32
  34. package/dist/src/index.d.ts.map +0 -1
  35. package/dist/src/reducer.d.ts +0 -49
  36. package/dist/src/reducer.d.ts.map +0 -1
  37. package/dist/src/roundHalfToEven.d.ts +0 -10
  38. package/dist/src/roundHalfToEven.d.ts.map +0 -1
  39. package/dist/src/types.d.ts +0 -481
  40. package/dist/src/types.d.ts.map +0 -1
  41. package/dist/src/utils/simple-logger.d.ts +0 -111
  42. package/dist/src/utils/simple-logger.d.ts.map +0 -1
  43. package/dist/src/utils.d.ts +0 -164
  44. package/dist/src/utils.d.ts.map +0 -1
  45. package/dist/tests/support/dom-harness.d.ts +0 -89
  46. package/dist/tests/support/dom-harness.d.ts.map +0 -1
  47. package/src/GlobalDebugOverlay.tsx +0 -284
  48. package/src/Panel.tsx +0 -297
  49. package/src/PanelDebugInfo.tsx +0 -94
  50. package/src/PanelGroup.tsx +0 -245
  51. package/src/PanelResizeHandle.tsx +0 -758
  52. package/src/allocateLayout.ts +0 -412
  53. package/src/context.ts +0 -25
  54. package/src/debugOverlayStore.ts +0 -355
  55. package/src/hooks.ts +0 -376
  56. package/src/index.ts +0 -93
  57. package/src/reducer.ts +0 -472
  58. package/src/roundHalfToEven.ts +0 -40
  59. package/src/styles/components.entry.css +0 -10
  60. package/src/styles/resize-panels.css +0 -1
  61. package/src/styles/standalone.entry.css +0 -12
  62. package/src/types.ts +0 -505
  63. package/src/utils/simple-logger.ts +0 -186
  64. package/src/utils.ts +0 -320
@@ -1,758 +0,0 @@
1
- /**
2
- * @file PanelResizeHandle component for custom resizable panels
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・垂直方向のいずれであっても、ハンドルは境界そのものなので必ず一致する。
13
- */
14
-
15
- import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
16
- import { twMerge } from "tailwind-merge"
17
- import { resolvePanelBounds } from "./allocateLayout"
18
- import { usePanelGroup } from "./context"
19
- import {
20
- PANEL_ALLOCATION_EPSILON,
21
- PANEL_COLLAPSE_THRESHOLD_MAX,
22
- PANEL_COLLAPSE_THRESHOLD_RATIO,
23
- PANEL_EXPAND_THRESHOLD_MAX,
24
- PANEL_EXPAND_THRESHOLD_RATIO,
25
- PANEL_HANDLE_DRAG_THROTTLE_MS,
26
- PANEL_HANDLE_THICKNESS,
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,
34
- PANEL_KEYBOARD_RESIZE_STEP,
35
- PANEL_VISIBLE_INDICATOR_SPACING,
36
- type PanelLayoutData,
37
- type PanelResizeHandleProps,
38
- type ResizeHandleLayoutData,
39
- } from "./types"
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
- }
159
-
160
- /**
161
- * Render an interactive divider that manages adjacent panel resizing gestures.
162
- * 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
163
- */
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
-
168
- const [isDragging, setIsDragging] = useState(false)
169
- const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ start: string | null; end: string | null }>({ start: null, end: null })
170
-
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("")
179
- // 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
180
- const dragCleanupRef = useRef<(() => void) | null>(null)
181
- const lastMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
182
-
183
- const generatedId = useId()
184
- const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
185
- const resolvedHandleId = id ?? handleIdRef.current
186
- const isHorizontal = direction === "horizontal"
187
- const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
188
- const isHandleDisabled = disabled || hasLayoutConstraintViolation
189
-
190
- /**
191
- * Detect the panels this handle sits between by scanning its siblings in the DOM.
192
- * DOM の兄弟要素を走査して、このハンドルが挟んでいるパネルを検出する処理。
193
- */
194
- const detectAdjacentPanels = useCallback(() => {
195
- const anchor = anchorRef.current
196
- // 中間要素が挟まると親はグループではないため、所有グループを辿って解決する
197
- const group = findOwningGroup(anchor)
198
- if (!(anchor && group)) {
199
- return
200
- }
201
-
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
210
- break
211
- }
212
- }
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
217
- break
218
- }
219
- }
220
-
221
- if (startId === startPanelIdRef.current && endId === endPanelIdRef.current) {
222
- return
223
- }
224
- startPanelIdRef.current = startId
225
- endPanelIdRef.current = endId
226
- setAdjacentPanelIds({ start: startId || null, end: endId || null })
227
- }, [])
228
-
229
- useEffect(() => {
230
- // マウント時と、グループの DOM 順が変わるたびに隣接パネルを再検出する。
231
- // パネルの id 列だけを見ていると、ハンドル自身が別の境界へ移った並べ替えを取りこぼし、
232
- // 見た目とは違う 2 枚を掴み続ける
233
- detectAdjacentPanels()
234
- }, [detectAdjacentPanels, layoutOrderKey])
235
-
236
- useEffect(() => {
237
- return () => {
238
- // ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
239
- dragCleanupRef.current?.()
240
- }
241
- }, [])
242
-
243
- /**
244
- * Start a resize drag when the handle receives a pointer down event.
245
- * リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
246
- */
247
- const handlePointerDown = useCallback(
248
- (event: React.PointerEvent<HTMLButtonElement>) => {
249
- if (isHandleDisabled) {
250
- return
251
- }
252
- // 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
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
- }
267
-
268
- event.preventDefault()
269
- event.stopPropagation()
270
-
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
282
-
283
- const dragPointerId = event.pointerId
284
- const captureTarget = event.currentTarget
285
- // 追従は document の pointermove / pointerup で行うため、キャプチャは無くても成立する。
286
- // 実装しない環境 (jsdom など) で例外を投げると、利用者側のテストがドラッグを検証できなくなる
287
- captureTarget.setPointerCapture?.(dragPointerId)
288
-
289
- setIsDragging(true)
290
- onDragging?.(true)
291
-
292
- let lastUpdateTime = 0
293
- let throttledMove: PointerEvent | null = null
294
-
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
299
-
300
- const layout = layoutRef.current
301
- const currentStart = layout.getPanel(startId)
302
- const currentEnd = layout.getPanel(endId)
303
- if (!(currentStart && currentEnd)) {
304
- return
305
- }
306
-
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)
309
-
310
- if (currentEnd.collapsed) {
311
- if (nextEndSize > expandThreshold) {
312
- expandPanel(endId, "start", nextEndSize)
313
- }
314
- return
315
- }
316
- if (currentStart.collapsed) {
317
- if (nextStartSize > expandThreshold) {
318
- expandPanel(startId, "end", nextStartSize)
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
- }
330
-
331
- resizePanels(startId, endId, nextStartSize)
332
- }
333
-
334
- const handlePointerMove = (moveEvent: PointerEvent) => {
335
- // 別ポインタのイベントはドラッグ計算に混入させない
336
- if (moveEvent.pointerId !== dragPointerId) {
337
- return
338
- }
339
- moveEvent.preventDefault()
340
- moveEvent.stopPropagation()
341
-
342
- const now = performance.now()
343
- if (now - lastUpdateTime < PANEL_HANDLE_DRAG_THROTTLE_MS) {
344
- // 間引いた移動は捨てずに保持する。離上時に反映しないと、指を離した位置と確定した境界がずれる
345
- throttledMove = moveEvent
346
- return
347
- }
348
- lastUpdateTime = now
349
- throttledMove = null
350
- applyMove(moveEvent)
351
- }
352
-
353
- /**
354
- * Tear down the active drag: remove listeners, release capture, and reset state.
355
- * 進行中ドラッグの後始末 (リスナー除去・キャプチャ解放・状態リセット)。
356
- * pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
357
- */
358
- let dragActive = true
359
- const stopDragging = () => {
360
- if (!dragActive) {
361
- return
362
- }
363
- dragActive = false
364
- dragCleanupRef.current = null
365
- setIsDragging(false)
366
- onDragging?.(false)
367
-
368
- document.removeEventListener("pointermove", handlePointerMove)
369
- document.removeEventListener("pointerup", handlePointerUp)
370
- document.removeEventListener("pointercancel", handlePointerCancel)
371
- captureTarget.removeEventListener("lostpointercapture", handleLostPointerCapture)
372
- if (captureTarget.hasPointerCapture?.(dragPointerId)) {
373
- captureTarget.releasePointerCapture(dragPointerId)
374
- }
375
- }
376
-
377
- const handlePointerUp = (upEvent: PointerEvent) => {
378
- // 別ポインタの離上ではドラッグを終了しない
379
- if (upEvent.pointerId !== dragPointerId) {
380
- return
381
- }
382
- if (throttledMove) {
383
- const pending = throttledMove
384
- throttledMove = null
385
- applyMove(pending)
386
- }
387
- stopDragging()
388
- }
389
- const handlePointerCancel = (cancelEvent: PointerEvent) => {
390
- // OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
391
- if (cancelEvent.pointerId === dragPointerId) {
392
- stopDragging()
393
- }
394
- }
395
- const handleLostPointerCapture = (lostEvent: PointerEvent) => {
396
- // キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
397
- if (lostEvent.pointerId === dragPointerId) {
398
- stopDragging()
399
- }
400
- }
401
-
402
- document.addEventListener("pointermove", handlePointerMove)
403
- document.addEventListener("pointerup", handlePointerUp)
404
- document.addEventListener("pointercancel", handlePointerCancel)
405
- captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
406
- dragCleanupRef.current = stopDragging
407
- },
408
- [isHandleDisabled, isHorizontal, direction, getPanel, resizePanels, collapsePanel, expandPanel, onDragging],
409
- )
410
-
411
- /**
412
- * Resize adjacent panels with arrow keys for keyboard accessibility.
413
- * キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
414
- */
415
- const handleKeyDown = useCallback(
416
- (event: React.KeyboardEvent<HTMLButtonElement>) => {
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
- }
425
-
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
- }
439
-
440
- event.preventDefault()
441
- event.stopPropagation()
442
-
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
457
- }
458
- if (endPanel.collapsed) {
459
- if (delta < 0) {
460
- expandPanel(endId, "start")
461
- }
462
- return
463
- }
464
-
465
- // リサイズで届く端まで来たパネルをさらに押すキーは、ドラッグで端まで引いたときと同じく折りたたみになる。
466
- // 判定に使うのはパネル自身の最小値ではなく対の可動域の端である。隣の上限が先に効く構成では、
467
- // パネルは自分の最小値へ決して到達できず、キーボードからは永久に折りたためなくなる
468
- if (delta < 0 && startPanel.collapseFromEnd && bounds.position <= bounds.resizeLower + PANEL_ALLOCATION_EPSILON) {
469
- collapsePanel(startId, "end")
470
- return
471
- }
472
- if (delta > 0 && endPanel.collapseFromStart && bounds.position >= bounds.resizeUpper - PANEL_ALLOCATION_EPSILON) {
473
- collapsePanel(endId, "start")
474
- return
475
- }
476
- resizePanels(startId, endId, startPanel.size + delta)
477
- },
478
- [isHandleDisabled, isHorizontal, getPanel, resizePanels, collapsePanel, expandPanel, containerSize],
479
- )
480
-
481
- const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
482
- const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
483
-
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
487
-
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)))
493
-
494
- const indicatorGrouping = useMemo(() => {
495
- // 隣接パネルが閉じていると複数のハンドルが同じ境界へ重なるため、その重なり具合を数える
496
- const snapThreshold = calculateSnapThreshold(containerSize)
497
- const isEffectivelyCollapsed = (panelIndex: number) => {
498
- const target = panels[panelIndex]
499
- if (!target) {
500
- return false
501
- }
502
- return target.collapsed || target.size <= snapThreshold
503
- }
504
- const countCollapsed = (startIndex: number, step: 1 | -1) => {
505
- let count = 0
506
- for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
507
- if (!isEffectivelyCollapsed(index)) {
508
- break
509
- }
510
- count += 1
511
- }
512
- return count
513
- }
514
-
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
519
-
520
- return { groupSize: startCollapsedCount + endCollapsedCount + 1, overlapIndex: startCollapsedCount }
521
- }, [adjacentPanelIds.start, adjacentPanelIds.end, panels, containerSize])
522
-
523
- const shouldHideHandle = useMemo(() => {
524
- if (hasLayoutConstraintViolation) {
525
- return true
526
- }
527
- if (indicatorGrouping.groupSize <= 1) {
528
- return false
529
- }
530
- // 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
531
- return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < indicatorGrouping.groupSize - 1
532
- }, [indicatorGrouping, hasLayoutConstraintViolation])
533
-
534
- const indicatorOffset = useMemo(() => {
535
- if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
536
- return 0
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])
542
-
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
- )
569
-
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
- }
604
- }
605
- return dynamicStyle
606
- }, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
607
-
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
- )
616
-
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
- )
631
-
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],
641
- )
642
-
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],
652
- )
653
-
654
- useEffect(() => {
655
- // ハンドル ID が外部から変更された場合に、旧 ID の測定結果を破棄する
656
- if (resolvedHandleId === handleIdRef.current) {
657
- return
658
- }
659
- const previousId = handleIdRef.current
660
- handleIdRef.current = resolvedHandleId
661
- lastMeasurementRef.current = null
662
- reportHandleMeasurement(previousId, null)
663
- }, [reportHandleMeasurement, resolvedHandleId])
664
-
665
- useEffect(() => {
666
- const measurement: ResizeHandleLayoutData = {
667
- id: resolvedHandleId,
668
- thickness: shouldHideHandle ? 0 : resolvedThickness,
669
- direction,
670
- visible: !shouldHideHandle && resolvedThickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
671
- startPanelId: adjacentPanelIds.start,
672
- endPanelId: adjacentPanelIds.end,
673
- }
674
- lastMeasurementRef.current = measurement
675
- reportHandleMeasurement(resolvedHandleId, measurement)
676
- }, [resolvedHandleId, resolvedThickness, shouldHideHandle, direction, adjacentPanelIds.start, adjacentPanelIds.end, reportHandleMeasurement])
677
-
678
- useEffect(() => {
679
- return () => {
680
- // アンマウント時にこのハンドルの測定結果を破棄する。
681
- // ID は effect の実行時ではなく後始末の時点で読む (途中で ID を変えたハンドルの登録が残らないように)
682
- reportHandleMeasurement(handleIdRef.current, null)
683
- }
684
- }, [reportHandleMeasurement])
685
-
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
- )
697
-
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
- )
707
-
708
- const accessibleLabel = `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`
709
-
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
718
- }
719
- const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
720
- if (!bounds) {
721
- return undefined
722
- }
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])
726
-
727
- return (
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>
757
- )
758
- })