@aiquants/resize-panels 1.8.0 → 1.9.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.
@@ -6,6 +6,7 @@
6
6
  import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
7
7
  import { twMerge } from "tailwind-merge"
8
8
  import { usePanelGroup } from "./context"
9
+ import { roundHalfToEven } from "./roundHalfToEven"
9
10
  import {
10
11
  PANEL_COLLAPSE_THRESHOLD_MAX,
11
12
  PANEL_COLLAPSE_THRESHOLD_RATIO,
@@ -14,6 +15,12 @@ import {
14
15
  PANEL_HANDLE_DRAG_THROTTLE_MS,
15
16
  PANEL_HANDLE_THICKNESS,
16
17
  PANEL_HANDLE_VISIBILITY_THRESHOLD,
18
+ PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL,
19
+ PANEL_INDICATOR_BAR_LENGTH_VERTICAL,
20
+ PANEL_INDICATOR_BAR_THICKNESS,
21
+ PANEL_INDICATOR_LENGTH_HORIZONTAL,
22
+ PANEL_INDICATOR_LENGTH_VERTICAL,
23
+ PANEL_INDICATOR_THICKNESS,
17
24
  PANEL_KEYBOARD_RESIZE_STEP,
18
25
  PANEL_VISIBLE_INDICATOR_SPACING,
19
26
  type PanelResizeHandleProps,
@@ -21,11 +28,50 @@ import {
21
28
  } from "./types"
22
29
  import { calculateSnapThreshold, getConstraintInPixels, getContainerSize } from "./utils"
23
30
 
31
+ /**
32
+ * Common styles applied to hide the handle and its interactive area entirely.
33
+ * 非表示時のハンドルおよび操作領域全体に適用する共通スタイル定数。
34
+ */
35
+ const HIDDEN_HANDLE_STYLE: CSSProperties = {
36
+ display: "none",
37
+ pointerEvents: "none",
38
+ visibility: "hidden",
39
+ }
40
+
41
+ /**
42
+ * Common styles applied to collapse the visual indicator.
43
+ * 視覚インジケーターを非表示にする共通スタイル定数。
44
+ */
45
+ const HIDDEN_INDICATOR_STYLE: CSSProperties = {
46
+ display: "none",
47
+ }
48
+
49
+ /**
50
+ * Sanitize an optional dimension to ensure a positive finite number.
51
+ * オプション寸法を正の有限数値へ正規化する。
52
+ */
53
+ const sanitizeDimension = (value: unknown, defaultValue: number): number => {
54
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
55
+ return defaultValue
56
+ }
57
+ return value
58
+ }
59
+
60
+ /**
61
+ * Ensure a numeric spacing value is an even integer for symmetric pixel alignment.
62
+ * ピクセル配置の対称性を担保するため数値を偶数整数に整流する。
63
+ */
64
+ const ensureEven = (value: number): number => {
65
+ const rounded = Math.round(value)
66
+ return rounded % 2 === 0 ? rounded : rounded + 1
67
+ }
68
+
24
69
  /**
25
70
  * Render an interactive divider that manages adjacent panel resizing gestures.
26
71
  * 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
27
72
  */
28
- export const PanelResizeHandle = memo(({ id, disabled = false, className, style, children, onDragging }: PanelResizeHandleProps) => {
73
+ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
74
+ const { id, disabled = false, className, style, children, onDragging, thickness, indicator = true, indicatorThickness, indicatorLength, indicatorBarThickness, indicatorBarLength, title } = props
29
75
  const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
30
76
  const [isDragging, setIsDragging] = useState(false)
31
77
  const handleRef = useRef<HTMLButtonElement>(null)
@@ -66,7 +112,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
66
112
  let leftPanelElement: HTMLElement | null = null
67
113
  let rightPanelElement: HTMLElement | null = null
68
114
 
69
- // ハンドルより前方の要素を逆順に走査して左パネルを特定
70
115
  for (let i = index - 1; i >= 0; i -= 1) {
71
116
  const element = childrenElements[i] as HTMLElement
72
117
  if (element.dataset.panelId) {
@@ -75,7 +120,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
75
120
  }
76
121
  }
77
122
 
78
- // ハンドルより後方の要素を走査して右パネルを特定
79
123
  for (let i = index + 1; i < childrenElements.length; i += 1) {
80
124
  const element = childrenElements[i] as HTMLElement
81
125
  if (element.dataset.panelId) {
@@ -87,7 +131,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
87
131
  const nextLeftId = leftPanelElement?.dataset.panelId || ""
88
132
  const nextRightId = rightPanelElement?.dataset.panelId || ""
89
133
 
90
- // 検出結果が前回と同じなら state 更新を省略する
91
134
  if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
92
135
  return
93
136
  }
@@ -194,15 +237,12 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
194
237
  moveEvent.preventDefault()
195
238
  moveEvent.stopPropagation()
196
239
 
197
- // スロットル処理: 頻繁な更新を抑制して計算負荷を下げる
198
240
  const now = performance.now()
199
241
  if (now - lastUpdateTime < throttleMs) return
200
242
  lastUpdateTime = now
201
243
 
202
- // ドラッグ開始位置からのピクセル変位を計算 (RTL の水平ドラッグは符号を反転)
203
244
  const rawPixelDelta = direction === "horizontal" ? moveEvent.clientX - startPosition.x : moveEvent.clientY - startPosition.y
204
245
  const pixelDelta = isRtlDrag ? -rawPixelDelta : rawPixelDelta
205
- // 差分がゼロのときは状態変化がなく後続処理が無駄になるため中断
206
246
  if (pixelDelta === 0) {
207
247
  return
208
248
  }
@@ -210,23 +250,18 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
210
250
  const newLeftSizeInPixels = initialLeftSize + pixelDelta
211
251
  const newRightSizeInPixels = initialRightSize - pixelDelta
212
252
 
213
- // 最新のパネル状態を取得して折りたたみ状態などの変化を反映
214
253
  const updatedLeftPanel = getPanel(leftPanelId.current)
215
254
  const updatedRightPanel = getPanel(rightPanelId.current)
216
255
 
217
256
  if (updatedLeftPanel && updatedRightPanel && leftPanelId.current && rightPanelId.current) {
218
257
  const leftMinSizePixels = getConstraintInPixels(updatedLeftPanel.minSize, 0, containerSize)
219
258
  const rightMinSizePixels = getConstraintInPixels(updatedRightPanel.minSize, 0, containerSize)
220
- const leftMinConstraintPixels = leftMinSizePixels
221
- const rightMinConstraintPixels = rightMinSizePixels
222
259
 
223
- // 折りたたみ・展開の閾値をコンテナサイズから算出
224
260
  const collapseThresholdPixels = Math.min(containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
225
261
  const expandThresholdPixels = Math.min(containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
226
262
 
227
263
  const rightCanCollapseFromStart = updatedRightPanel.collapseFromStart
228
264
  const rightCollapseDirection = updatedRightPanel.collapsedByDirection ?? "start"
229
- // 右パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
230
265
  if (rightCanCollapseFromStart && !updatedRightPanel.collapsed && newRightSizeInPixels <= Math.max(rightMinSizePixels * 0.3, collapseThresholdPixels)) {
231
266
  collapsePanel(rightPanelId.current, "start")
232
267
  return
@@ -234,46 +269,39 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
234
269
 
235
270
  const leftCanCollapseFromEnd = updatedLeftPanel.collapseFromEnd
236
271
  const leftCollapseDirection = updatedLeftPanel.collapsedByDirection ?? "end"
237
- // 左パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
238
272
  if (leftCanCollapseFromEnd && !updatedLeftPanel.collapsed && newLeftSizeInPixels <= Math.max(leftMinSizePixels * 0.3, collapseThresholdPixels)) {
239
273
  collapsePanel(leftPanelId.current, "end")
240
274
  return
241
275
  }
242
276
 
243
- // 右パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
244
277
  if (updatedRightPanel.collapsed && newRightSizeInPixels > Math.max(rightMinSizePixels * 0.3, expandThresholdPixels)) {
245
278
  expandPanel(rightPanelId.current, rightCollapseDirection, newRightSizeInPixels)
246
279
  return
247
280
  }
248
281
 
249
- // 左パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
250
282
  if (updatedLeftPanel.collapsed && newLeftSizeInPixels > Math.max(leftMinSizePixels * 0.3, expandThresholdPixels)) {
251
283
  expandPanel(leftPanelId.current, leftCollapseDirection, newLeftSizeInPixels)
252
284
  return
253
285
  }
254
286
 
255
- // スナップ処理: 最小サイズ以下になった場合はゼロサイズにスナップさせる
256
287
  if (containerSize > 0) {
257
288
  const totalPanelPixels = updatedLeftPanel.size + updatedRightPanel.size
258
- // コンテナ比率と定数に基づきゼロスナップの閾値を算出
259
289
  const snapThreshold = calculateSnapThreshold(containerSize)
260
290
  let snapped = false
261
291
 
262
- // 左パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
263
- if (leftMinConstraintPixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
292
+ if (leftMinSizePixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
264
293
  const snappedLeft = 0
265
294
  const snappedRight = Math.max(0, totalPanelPixels - snappedLeft)
266
295
 
267
- if (snappedRight >= rightMinConstraintPixels) {
296
+ if (snappedRight >= rightMinSizePixels) {
268
297
  resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
269
298
  snapped = true
270
299
  }
271
- } else if (rightMinConstraintPixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
272
- // 右パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
300
+ } else if (rightMinSizePixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
273
301
  const snappedRight = 0
274
302
  const snappedLeft = Math.max(0, totalPanelPixels - snappedRight)
275
303
 
276
- if (snappedLeft >= leftMinConstraintPixels) {
304
+ if (snappedLeft >= leftMinSizePixels) {
277
305
  resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
278
306
  snapped = true
279
307
  }
@@ -284,12 +312,10 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
284
312
  }
285
313
  }
286
314
 
287
- // いずれかのパネルが折りたたまれている場合はリサイズ処理を中断
288
315
  if (updatedLeftPanel.collapsed || updatedRightPanel.collapsed) {
289
316
  return
290
317
  }
291
318
 
292
- // 通常のリサイズ処理を実行して隣接パネルのサイズを更新
293
319
  resizePanels(leftPanelId.current, rightPanelId.current, newLeftSizeInPixels, newRightSizeInPixels)
294
320
  }
295
321
  }
@@ -381,20 +407,23 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
381
407
  const isHorizontal = direction === "horizontal"
382
408
  const cursor = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
383
409
 
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,
410
+ const baseHandleClasses = useMemo(
411
+ () =>
412
+ twMerge(
413
+ "group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
414
+ cursor,
415
+ isHorizontal ? "h-full" : "w-full",
416
+ !isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
417
+ !isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
418
+ isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
419
+ isHandleDisabled && "cursor-not-allowed opacity-50",
420
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
421
+ className,
422
+ ),
423
+ [cursor, isHorizontal, isHandleDisabled, isDragging, className],
394
424
  )
395
425
 
396
426
  const adjacentIndices = useMemo(() => {
397
- // パネル ID からパネル配列内のインデックスを探索するヘルパー関数
398
427
  const findIndex = (panelId: string | null) => {
399
428
  if (!panelId) {
400
429
  return -1
@@ -416,7 +445,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
416
445
  const containerLength = isHorizontal ? containerSize.width : containerSize.height
417
446
 
418
447
  const boundaryOffset = useMemo(() => {
419
- // 指定範囲のパネルサイズ合計を安全に算出するヘルパー
420
448
  const safeSum = (startIndex: number, endIndexInclusive: number) => {
421
449
  if (startIndex > endIndexInclusive) {
422
450
  return 0
@@ -446,7 +474,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
446
474
  }, [adjacentIndices.left, adjacentIndices.right, panels])
447
475
 
448
476
  const clampedOffset = useMemo(() => {
449
- // 境界位置が無効値やコンテナ範囲外に逸脱した場合に補正
450
477
  if (!Number.isFinite(boundaryOffset) || boundaryOffset < 0) {
451
478
  return 0
452
479
  }
@@ -456,34 +483,68 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
456
483
  return boundaryOffset
457
484
  }, [boundaryOffset, containerLength])
458
485
 
486
+ const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
487
+ const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
488
+
489
+ const rawThickness = sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS)
490
+ const defaultIndicatorLength = isHorizontal ? PANEL_INDICATOR_LENGTH_HORIZONTAL : PANEL_INDICATOR_LENGTH_VERTICAL
491
+ const defaultBarLength = isHorizontal ? PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL : PANEL_INDICATOR_BAR_LENGTH_VERTICAL
492
+
493
+ const rawIndicatorThickness = sanitizeDimension(indicatorThickness ?? indicatorConfig?.thickness, PANEL_INDICATOR_THICKNESS)
494
+ const rawIndicatorLength = sanitizeDimension(indicatorLength ?? indicatorConfig?.length, defaultIndicatorLength)
495
+ const rawBarThickness = sanitizeDimension(indicatorBarThickness ?? indicatorConfig?.barThickness, PANEL_INDICATOR_BAR_THICKNESS)
496
+ const rawBarLength = sanitizeDimension(indicatorBarLength ?? indicatorConfig?.barLength, defaultBarLength)
497
+
498
+ // 幾何学的内包不変条件: 内部バー寸法がインジケーター外枠およびハンドル操作領域を超えないようクランプ
499
+ const resolvedThickness = Math.max(1, rawThickness)
500
+ const resolvedIndicatorThickness = Math.min(resolvedThickness, Math.max(1, rawIndicatorThickness))
501
+ const resolvedIndicatorLength = Math.max(1, rawIndicatorLength)
502
+ const resolvedBarThickness = Math.min(resolvedIndicatorThickness, Math.max(1, rawBarThickness))
503
+ const resolvedBarLength = Math.min(resolvedIndicatorLength, Math.max(1, rawBarLength))
504
+
505
+ // 静的 CSS 変数はドラッグ中に変化しないため、スタイル再計算時の不要なプロパティ再設定を防止
506
+ const customProperties = useMemo<Record<string, string>>(
507
+ () => ({
508
+ "--rp-handle-thickness": `${resolvedThickness}px`,
509
+ "--rp-indicator-thickness": `${resolvedIndicatorThickness}px`,
510
+ "--rp-indicator-length": `${resolvedIndicatorLength}px`,
511
+ "--rp-indicator-bar-thickness": `${resolvedBarThickness}px`,
512
+ "--rp-indicator-bar-length": `${resolvedBarLength}px`,
513
+ }),
514
+ [resolvedThickness, resolvedIndicatorThickness, resolvedIndicatorLength, resolvedBarThickness, resolvedBarLength],
515
+ )
516
+
459
517
  const overlayBaseStyle = useMemo<CSSProperties>(() => {
518
+ // RTL では右端起点から論理距離を差し引いた位置が物理境界中心線となる
519
+ const centerLine = isHorizontal && isRtl && containerLength > 0 ? containerLength - clampedOffset : clampedOffset
520
+
521
+ // transform: translate(-50%) による 0.5px サブピクセル滲みを防ぐため、中心線から半厚みを引き銀行丸めで整数ピクセル原点を決定
522
+ const startCoord = roundHalfToEven(centerLine - resolvedThickness / 2, 0)
523
+
460
524
  const base: CSSProperties = {
461
525
  position: "absolute",
462
526
  zIndex: 20,
463
527
  touchAction: "none",
528
+ ...customProperties,
464
529
  }
465
530
 
466
531
  if (isHorizontal) {
467
532
  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`
533
+ base.left = `${startCoord}px`
534
+ base.width = `${resolvedThickness}px`
472
535
  base.height = "100%"
473
536
  } else {
474
537
  base.left = 0
475
- base.top = `${clampedOffset}px`
476
- base.transform = "translateY(-50%)"
477
- base.height = `${PANEL_HANDLE_THICKNESS}px`
538
+ base.top = `${startCoord}px`
539
+ base.height = `${resolvedThickness}px`
478
540
  base.width = "100%"
479
541
  }
480
542
 
481
543
  return base
482
- }, [clampedOffset, isHorizontal, isRtl, containerLength])
544
+ }, [clampedOffset, isHorizontal, isRtl, containerLength, resolvedThickness, customProperties])
483
545
 
484
546
  const indicatorGrouping = useMemo(() => {
485
547
  const snapThreshold = calculateSnapThreshold(containerLength)
486
- // パネルが実質的に折りたたまれているか判定するヘルパー関数
487
548
  const isEffectivelyCollapsed = (panelIndex: number) => {
488
549
  if (panelIndex < 0 || panelIndex >= panels.length) {
489
550
  return false
@@ -498,7 +559,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
498
559
  return target.size <= snapThreshold
499
560
  }
500
561
 
501
- // 指定インデックスから連続する折りたたみパネルの数を数える
502
562
  const countCollapsed = (startIndex: number, step: 1 | -1) => {
503
563
  if (!isEffectivelyCollapsed(startIndex)) {
504
564
  return 0
@@ -531,60 +591,86 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
531
591
  if (hasLayoutConstraintViolation) {
532
592
  return true
533
593
  }
534
- // グループサイズが 1 以下の場合は単独ハンドルなので非表示にしない
535
594
  if (indicatorGrouping.groupSize <= 1) {
536
595
  return false
537
596
  }
538
- // ハンドルがグループ内の中間位置にある場合は重複防止のため非表示にする
597
+ // 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
539
598
  const lastIndex = indicatorGrouping.groupSize - 1
540
599
  return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
541
600
  }, [indicatorGrouping, hasLayoutConstraintViolation])
542
601
 
543
602
  const indicatorOffset = useMemo(() => {
544
- // ハンドルが非表示の場合またはグループサイズが 1 以下の場合はオフセット不要
545
603
  if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
546
604
  return 0
547
605
  }
548
606
 
549
- // 表示すべきインジケーターの個数を決定
550
607
  const visibleCount = indicatorGrouping.groupSize > 1 ? 2 : 1
551
608
  if (visibleCount === 1) {
552
609
  return 0
553
610
  }
554
611
 
555
- // このハンドルが表示インジケーター群内のどの位置にあるか計算
612
+ // 半ピクセル除算による非対称性を防ぐため偶数スペーシングを保証
613
+ const dynamicSpacing = ensureEven(Math.max(PANEL_VISIBLE_INDICATOR_SPACING, resolvedIndicatorThickness + 4))
556
614
  const visibleIndex = indicatorGrouping.overlapIndex === 0 ? 0 : visibleCount - 1
557
615
  const centerIndex = (visibleCount - 1) / 2
558
- return (visibleIndex - centerIndex) * PANEL_VISIBLE_INDICATOR_SPACING
559
- }, [indicatorGrouping, shouldHideHandle])
616
+ const rawOffset = (visibleIndex - centerIndex) * dynamicSpacing
560
617
 
561
- const indicatorWrapperStyle = useMemo(() => {
562
- // ハンドルを非表示にする場合は表示なし
563
- if (shouldHideHandle) {
564
- return { display: "none" }
618
+ // 水平かつ RTL の環境では start パネルが物理右側にあるため論理順の正シフトを物理左方向(負)へ反転
619
+ return isHorizontal && isRtl ? -rawOffset : rawOffset
620
+ }, [indicatorGrouping, shouldHideHandle, resolvedIndicatorThickness, isHorizontal, isRtl])
621
+
622
+ const indicatorWrapperStyle = useMemo<CSSProperties>(() => {
623
+ if (shouldHideHandle || isIndicatorExplicitlyHidden) {
624
+ return HIDDEN_INDICATOR_STYLE
565
625
  }
566
626
 
567
- // オフセットがゼロの場合はスタイル不要
568
- if (indicatorOffset === 0) {
569
- return undefined
627
+ const width = isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength
628
+ const height = isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness
629
+
630
+ const dynamicStyle: CSSProperties = {
631
+ width: `${width}px`,
632
+ height: `${height}px`,
633
+ flexShrink: 0,
634
+ }
635
+
636
+ if (indicatorOffset !== 0) {
637
+ dynamicStyle.transform = isHorizontal ? `translateX(${indicatorOffset}px)` : `translateY(${indicatorOffset}px)`
570
638
  }
571
639
 
572
- // オフセット分だけインジケーターを移動
573
- return isHorizontal ? { transform: `translateX(${indicatorOffset}px)` } : { transform: `translateY(${indicatorOffset}px)` }
574
- }, [indicatorOffset, isHorizontal, shouldHideHandle])
640
+ return dynamicStyle
641
+ }, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
575
642
 
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)]",
643
+ const indicatorBarStyle = useMemo<CSSProperties>(() => {
644
+ const width = isHorizontal ? resolvedBarThickness : resolvedBarLength
645
+ const height = isHorizontal ? resolvedBarLength : resolvedBarThickness
646
+
647
+ return {
648
+ width: `${width}px`,
649
+ height: `${height}px`,
650
+ flexShrink: 0,
651
+ }
652
+ }, [isHorizontal, resolvedBarThickness, resolvedBarLength])
653
+
654
+ const indicatorWrapperClasses = useMemo(
655
+ () =>
656
+ twMerge(
657
+ "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)]",
658
+ "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)]",
659
+ 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)]",
660
+ indicatorConfig?.className,
661
+ ),
662
+ [isDragging, indicatorConfig?.className],
581
663
  )
582
664
 
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",
665
+ const indicatorBarClasses = useMemo(
666
+ () =>
667
+ twMerge(
668
+ "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)]",
669
+ isDragging && "bg-blue-500 dark:bg-blue-300",
670
+ !isHandleDisabled && "group-hover:bg-slate-700 dark:group-hover:bg-slate-100",
671
+ indicatorConfig?.barClassName,
672
+ ),
673
+ [isDragging, isHandleDisabled, indicatorConfig?.barClassName],
588
674
  )
589
675
 
590
676
  const updateHandleMeasurement = useCallback(() => {
@@ -595,13 +681,11 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
595
681
 
596
682
  let thickness = 0
597
683
 
598
- // ハンドルが非表示の場合は厚みをゼロとして扱う
599
684
  if (!shouldHideHandle) {
600
685
  const baseSize = direction === "horizontal" ? element.offsetWidth : element.offsetHeight
601
686
  let marginStart = 0
602
687
  let marginEnd = 0
603
688
 
604
- // CSS マージンを含めた実測厚みを計算
605
689
  if (typeof window !== "undefined") {
606
690
  const computedStyle = window.getComputedStyle(element)
607
691
  if (direction === "horizontal") {
@@ -626,7 +710,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
626
710
  thickness = 0
627
711
  }
628
712
 
629
- // 測定結果オブジェクトを構築
630
713
  const measurement: ResizeHandleLayoutData = {
631
714
  id: resolvedHandleId,
632
715
  thickness,
@@ -638,7 +721,6 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
638
721
 
639
722
  const lastMeasurement = lastHandleMeasurementRef.current
640
723
 
641
- // 測定値が前回と実質的に同一であれば更新をスキップ
642
724
  if (
643
725
  lastMeasurement &&
644
726
  Math.abs(lastMeasurement.thickness - measurement.thickness) <= 0.25 &&
@@ -664,13 +746,11 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
664
746
  return
665
747
  }
666
748
 
667
- // ResizeObserver が利用できない環境では初回のみ測定
668
749
  if (typeof ResizeObserver === "undefined") {
669
750
  updateHandleMeasurement()
670
751
  return
671
752
  }
672
753
 
673
- // ハンドル要素のサイズ変化を監視して測定結果を更新
674
754
  const observer = new ResizeObserver(() => {
675
755
  updateHandleMeasurement()
676
756
  })
@@ -687,9 +767,7 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
687
767
  if (shouldHideHandle) {
688
768
  return {
689
769
  ...merged,
690
- display: "none" as CSSProperties["display"],
691
- pointerEvents: "none" as CSSProperties["pointerEvents"],
692
- visibility: "hidden" as CSSProperties["visibility"],
770
+ ...HIDDEN_HANDLE_STYLE,
693
771
  }
694
772
  }
695
773
  return merged
@@ -707,12 +785,13 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
707
785
  aria-hidden={shouldHideHandle || undefined}
708
786
  aria-label={`Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`}
709
787
  data-resize-handle-id={resolvedHandleId}
710
- title={`ドラッグして${isHorizontal ? "" : ""}をリサイズ、端まで移動して折りたたみ/展開`}>
711
- {children || (
712
- <div className={indicatorWrapperClasses} style={indicatorWrapperStyle}>
713
- <span className={indicatorBarClasses} />
714
- </div>
715
- )}
788
+ title={title ?? `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`}>
789
+ {children ||
790
+ (!isIndicatorExplicitlyHidden && (
791
+ <div className={indicatorWrapperClasses} style={indicatorWrapperStyle} data-resize-handle-indicator="" aria-hidden="true">
792
+ <span className={indicatorBarClasses} style={indicatorBarStyle} data-resize-handle-indicator-bar="" aria-hidden="true" />
793
+ </div>
794
+ ))}
716
795
  </button>
717
796
  )
718
797
  })
package/src/index.ts CHANGED
@@ -17,13 +17,18 @@ export type {
17
17
  ContainerAxisMeasurement,
18
18
  ContainerSize,
19
19
  FlexibleSize,
20
+ LayoutConstraintViolation,
21
+ LayoutConstraintViolationReason,
20
22
  PanelCollapseDirection,
21
23
  PanelDirection,
22
24
  PanelGroupContextValue,
23
25
  PanelGroupProps,
24
26
  PanelLayoutData,
27
+ PanelMeasurement,
25
28
  PanelProps,
29
+ PanelResizeHandleIndicatorConfig,
26
30
  PanelResizeHandleProps,
31
+ ResizeHandleLayoutData,
27
32
  SizeConfig,
28
33
  SizeUnit,
29
34
  } from "./types"
@@ -37,12 +42,23 @@ export {
37
42
  saveLayout,
38
43
  } from "./utils"
39
44
 
45
+ /**
46
+ * Safely escape a selector identifier for use in CSS query selectors.
47
+ * セレクタ文字列を安全にエスケープする補助関数。
48
+ */
49
+ const escapeIdentifier = (id: string): string => {
50
+ return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&")
51
+ }
52
+
40
53
  /**
41
54
  * Locate a panel element in the DOM by its data attribute.
42
55
  * data 属性を手がかりにパネル要素を DOM から取得する補助関数。
43
56
  */
44
57
  export const getPanelElement = (id: string): HTMLElement | null => {
45
- return document.querySelector(`[data-panel-id="${id}"]`)
58
+ if (typeof document === "undefined") {
59
+ return null
60
+ }
61
+ return document.querySelector(`[data-panel-id="${escapeIdentifier(id)}"]`)
46
62
  }
47
63
 
48
64
  /**
@@ -50,7 +66,10 @@ export const getPanelElement = (id: string): HTMLElement | null => {
50
66
  * data 属性を手がかりにパネルグループ要素を DOM から取得する補助関数。
51
67
  */
52
68
  export const getPanelGroupElement = (id: string): HTMLElement | null => {
53
- return document.querySelector(`[data-panel-group-id="${id}"]`)
69
+ if (typeof document === "undefined") {
70
+ return null
71
+ }
72
+ return document.querySelector(`[data-panel-group-id="${escapeIdentifier(id)}"]`)
54
73
  }
55
74
 
56
75
  /**
@@ -58,5 +77,8 @@ export const getPanelGroupElement = (id: string): HTMLElement | null => {
58
77
  * data 属性を手がかりにリサイズハンドル要素を DOM から取得する補助関数。
59
78
  */
60
79
  export const getResizeHandleElement = (id: string): HTMLElement | null => {
61
- return document.querySelector(`[data-resize-handle-id="${id}"]`)
80
+ if (typeof document === "undefined") {
81
+ return null
82
+ }
83
+ return document.querySelector(`[data-resize-handle-id="${escapeIdentifier(id)}"]`)
62
84
  }
@@ -5,4 +5,6 @@
5
5
  * (preflight を Tailwind ホストへ流し込むとホストの base 層を破壊するため厳禁)。
6
6
  */
7
7
  @import "tailwindcss/theme.css" theme(reference);
8
- @import "./resize-panels.css";
8
+ @layer components {
9
+ @import "./resize-panels.css";
10
+ }
@@ -1,3 +1 @@
1
- @layer components {
2
- /* nothing */
3
- }
1
+ /* nothing */