@aiquants/daily-report 0.18.0 → 0.18.2

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.
@@ -2,6 +2,7 @@
2
2
  * Virtual-scrolling summary list for daily reports driven by fetcher-based detail hooks.
3
3
  * useFetcher ベースの詳細取得フックで日報サマリーを仮想スクロール表示。
4
4
  */
5
+ import { Panel, PanelGroup, PanelResizeHandle } from "@aiquants/resize-panels"
5
6
  import { SwipeCloseGuardContext, useSwipeGuardState, useSwipeToDismissOverlay, useTapClick, useTransitionDisplay } from "@aiquants/swipe-overlay"
6
7
  import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
7
8
  import Check from "lucide-react/dist/esm/icons/check.js"
@@ -21,7 +22,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
21
22
  import {
22
23
  CONTENT_DRAG_POINTER_INPUTS,
23
24
  DEFAULT_ITEM_HEIGHT,
24
- EMPTY_DETAIL,
25
25
  EMPTY_ITEM,
26
26
  FAST_SCROLL_INERTIA_OPTIONS,
27
27
  FAST_SCROLL_WHEEL_MULTIPLIER,
@@ -40,11 +40,39 @@ import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-c
40
40
  import { DailyReportEditForm } from "./daily-report-edit-form"
41
41
  import { UnreadIndicator } from "./unread-indicator"
42
42
 
43
+ /**
44
+ * Static behavior options for the virtual scroll component to ensure stable references across renders.
45
+ * レンダリングをまたいで安定した参照を保証するための仮想スクロール静的動作オプション。
46
+ */
47
+ const VIRTUAL_SCROLL_BEHAVIOR_OPTIONS = {
48
+ pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
49
+ inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
50
+ wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
51
+ enableKeyboardNavigation: false,
52
+ } as const
53
+
54
+ /**
55
+ * Static content insets for the virtual scroll component.
56
+ * 仮想スクロールコンポーネント用の静的インセット定義。
57
+ */
58
+ const VIRTUAL_SCROLL_CONTENT_INSETS = {
59
+ top: ITEM_VERTICAL_PADDING,
60
+ bottom: ITEM_VERTICAL_PADDING,
61
+ } as const
62
+
63
+ /**
64
+ * Testable virtual scroll handle extended with search and ID inspection methods.
65
+ * 検索および ID 参照メソッドを拡張したテスト用仮想スクロールハンドル型。
66
+ */
43
67
  type TestableVirtualScrollHandle = VirtualScrollHandle & {
44
68
  findReportIndex: (id: number) => number
45
69
  getReportIds: (limit?: number) => number[]
46
70
  }
47
71
 
72
+ /**
73
+ * Props for the DailyReportList component.
74
+ * DailyReportList コンポーネントのプロパティ型定義。
75
+ */
48
76
  type DailyReportListProps = {
49
77
  dailyReportItems: DailyReportItem[]
50
78
  autoMarkRead?: boolean
@@ -52,11 +80,10 @@ type DailyReportListProps = {
52
80
  onSelectItem: (reportHubId: number | null) => void
53
81
  userId?: string | null
54
82
  /**
83
+ * Shared slot to preserve scroll position across unmounted tab switches.
55
84
  * タブ切替 (アンマウント) をまたいでスクロール位置を持ち回る共有スロット。
56
- * 値は VirtualScroll の**論理座標** (inset 非依存。onScroll が配信する座標系)。
85
+ * 値は VirtualScroll の論理座標 (inset 非依存。onScroll が配信する座標系)。
57
86
  * マウント時に一度だけ読み、スクロールのたびに書き戻す。
58
- * ❗ 値をレンダー時に読んで props へ焼き込む方式は禁止: 非制御タブでは親が
59
- * 再レンダーされず、復帰マウントに古い値が渡る (実測で再現した実害バグ)
60
87
  */
61
88
  scrollOffsetRef?: RefObject<number>
62
89
  }
@@ -64,6 +91,12 @@ type DailyReportListProps = {
64
91
  /**
65
92
  * Manages mobile overlay state, history navigation, and scroll locking.
66
93
  * モバイルオーバーレイの状態、履歴ナビゲーション、スクロールロックを管理するフック。
94
+ *
95
+ * @param isMobileLayout - Whether the current viewport matches mobile layout. モバイルレイアウトかどうか。
96
+ * @param selectedReportHubId - Currently selected report identifier. 現在選択中の日報 ID。
97
+ * @param dailyReportItems - Full list of daily report items. 日報アイテム一覧。
98
+ * @param onSelectItem - Callback to update selected report identifier. 選択日報 ID 更新コールバック。
99
+ * @returns Mobile overlay state and control handlers. モバイルオーバーレイ状態と操作ハンドラ。
67
100
  */
68
101
  const useMobileOverlayManager = (isMobileLayout: boolean, selectedReportHubId: number | null, dailyReportItems: DailyReportItem[], onSelectItem: (reportHubId: number | null) => void) => {
69
102
  const [isMobileDetailOpen, setIsMobileDetailOpen] = useState(false)
@@ -71,8 +104,36 @@ const useMobileOverlayManager = (isMobileLayout: boolean, selectedReportHubId: n
71
104
  const overlayHistoryKeyRef = useRef<string | null>(null)
72
105
  const lastClosedItemIdRef = useRef<number | null>(null)
73
106
  const bodyOverflowRef = useRef<string | null>(null)
107
+ const prevIsMobileRef = useRef(isMobileLayout)
108
+ const isInitialMountRef = useRef(true)
74
109
 
75
110
  const selectedItem = useMemo(() => (selectedReportHubId === null ? null : (dailyReportItems.find((e) => e.reportHubId === selectedReportHubId) ?? null)), [dailyReportItems, selectedReportHubId])
111
+
112
+ // デスクトップで日報を選択中に画面幅がモバイル幅へ縮小・回転した際、詳細の閲覧文脈を失わないようオーバーレイへ引き継ぐ
113
+ useEffect(() => {
114
+ // 初回マウント時は SSR とのハイドレーション整合 (useIsMobileLayout の false -> true 遷移) による
115
+ // 誤展開を防止するため、リサイズ遷移処理をスキップする
116
+ if (isInitialMountRef.current) {
117
+ isInitialMountRef.current = false
118
+ prevIsMobileRef.current = isMobileLayout
119
+ return
120
+ }
121
+
122
+ const wasDesktop = !prevIsMobileRef.current
123
+ prevIsMobileRef.current = isMobileLayout
124
+
125
+ if (wasDesktop && isMobileLayout && selectedReportHubId !== null) {
126
+ setIsMobileDetailOpen(true)
127
+ } else if (!isMobileLayout && isMobileDetailOpen) {
128
+ // デスクトップ復帰時はオーバーレイを閉じ、ロックされていたスクロールを即時解放する
129
+ setIsMobileDetailOpen(false)
130
+ if (bodyOverflowRef.current !== null) {
131
+ document.body.style.overflow = bodyOverflowRef.current
132
+ bodyOverflowRef.current = null
133
+ }
134
+ }
135
+ }, [isMobileLayout, selectedReportHubId, isMobileDetailOpen])
136
+
76
137
  const shouldShowOverlay = isMobileLayout && isMobileDetailOpen && selectedItem !== null
77
138
 
78
139
  /**
@@ -103,7 +164,7 @@ const useMobileOverlayManager = (isMobileLayout: boolean, selectedReportHubId: n
103
164
  useEffect(() => {
104
165
  if (typeof window === "undefined") return
105
166
 
106
- // History Management
167
+ // モバイルオーバーレイ展開時のみブラウザの戻る操作でオーバーレイを閉じられるよう履歴を積む
107
168
  if (isMobileLayout && isMobileDetailOpen && selectedReportHubId !== null && !overlayHistoryKeyRef.current) {
108
169
  const key = `overlay-${Date.now()}`
109
170
  window.history.pushState({ ...window.history.state, dailyReportOverlay: key }, "", window.location.href)
@@ -112,8 +173,8 @@ const useMobileOverlayManager = (isMobileLayout: boolean, selectedReportHubId: n
112
173
  overlayHistoryKeyRef.current = null
113
174
  }
114
175
 
115
- // Scroll Lock
116
- if (isMobileDetailOpen) {
176
+ // オーバーレイ背面のスクロールを抑止して誤スクロールを防止
177
+ if (isMobileLayout && isMobileDetailOpen) {
117
178
  bodyOverflowRef.current = document.body.style.overflow
118
179
  document.body.style.overflow = "hidden"
119
180
  } else if (bodyOverflowRef.current !== null) {
@@ -251,11 +312,37 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
251
312
  const [overlayLabel, setOverlayLabel] = useState<string>("")
252
313
  const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
253
314
  const isMobileLayout = useIsMobileLayout()
315
+ const rootContainerRef = useRef<HTMLDivElement>(null)
316
+ const [isContainerNarrow, setIsContainerNarrow] = useState(false)
317
+
318
+ /**
319
+ * Observes root container width to fallback to single-column layout when available space is constrained.
320
+ * 利用可能なコンテナ幅が狭小化した際に単一カラム表示へフォールバックするためのコンテナ監視。
321
+ */
322
+ useEffect(() => {
323
+ if (typeof window === "undefined" || typeof ResizeObserver === "undefined") return
324
+ const el = rootContainerRef.current
325
+ if (!el) return
326
+
327
+ const observer = new ResizeObserver((entries) => {
328
+ for (const entry of entries) {
329
+ if (entry.target === el) {
330
+ const width = entry.contentRect.width
331
+ // 2 カラム表示に必要な安全幅 (460px) を下回った場合は 1 カラムへフォールバック
332
+ setIsContainerNarrow(width > 0 && width < 460)
333
+ }
334
+ }
335
+ })
336
+ observer.observe(el)
337
+ return () => observer.disconnect()
338
+ }, [])
339
+
340
+ const effectiveIsMobile = isMobileLayout || isContainerNarrow
254
341
  const wheelResetTimerRef = useRef<number | null>(null)
255
342
  const itemCount = dailyReportItems.length
256
343
  const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
257
344
 
258
- const { isMobileDetailOpen, setIsMobileDetailOpen, isOverlayMounted, shouldShowOverlay, handleOverlayClose, lastClosedItemIdRef, selectedItem } = useMobileOverlayManager(isMobileLayout, selectedReportHubId, dailyReportItems, onSelectItem)
345
+ const { isMobileDetailOpen, setIsMobileDetailOpen, isOverlayMounted, shouldShowOverlay, handleOverlayClose, lastClosedItemIdRef, selectedItem } = useMobileOverlayManager(effectiveIsMobile, selectedReportHubId, dailyReportItems, onSelectItem)
259
346
 
260
347
  const columnHeightStyle = useMemo<CSSProperties | undefined>(() => (Number.isFinite(viewportHeight) && viewportHeight > 0 ? { height: viewportHeight } : undefined), [viewportHeight])
261
348
 
@@ -314,7 +401,7 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
314
401
  * Cleanup: None.
315
402
  */
316
403
  useEffect(() => {
317
- if (isMobileLayout || isMobileDetailOpen || lastClosedItemIdRef.current === null) return
404
+ if (effectiveIsMobile || isMobileDetailOpen || lastClosedItemIdRef.current === null) return
318
405
  const targetId = lastClosedItemIdRef.current
319
406
  lastClosedItemIdRef.current = null
320
407
 
@@ -330,7 +417,7 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
330
417
  card?.focus()
331
418
  })
332
419
  }
333
- }, [isMobileDetailOpen, isMobileLayout, dailyReportItems, lastClosedItemIdRef])
420
+ }, [isMobileDetailOpen, effectiveIsMobile, dailyReportItems, lastClosedItemIdRef])
334
421
 
335
422
  /**
336
423
  * Syncs overlay label with the first item's date.
@@ -368,9 +455,9 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
368
455
  (id: number) => {
369
456
  if (id <= 0) return
370
457
  onSelectItem(id)
371
- if (isMobileLayout) setIsMobileDetailOpen(true)
458
+ if (effectiveIsMobile) setIsMobileDetailOpen(true)
372
459
  },
373
- [isMobileLayout, onSelectItem, setIsMobileDetailOpen],
460
+ [effectiveIsMobile, onSelectItem, setIsMobileDetailOpen],
374
461
  )
375
462
 
376
463
  const getItem = useCallback((index: number) => (index < itemCount ? dailyReportItems[index] : EMPTY_ITEM), [dailyReportItems, itemCount])
@@ -397,11 +484,7 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
397
484
 
398
485
  const handleScroll = useCallback(
399
486
  (position: number) => {
400
- // onScroll が配信する論理座標をそのまま共有スロットへ書く (保存と復元で座標系を一致させる)。
401
- // 補足 (virtualscroll 2.0.0): ハンドル getScrollPosition も論理座標に統一されたため
402
- // 読み戻しても座標は一致するが、配信引数が正 (追加のハンドル呼び出しは冗長)。
403
- // 歴史的経緯: 2.0.0 以前はペイン座標を返す非対称があり、読み戻しが inset 分ずれる
404
- // 実害バグの原因だった
487
+ // onScroll が配信する論理座標をそのまま共有スロットへ記録し、タブ復帰時の復元座標を一致させる
405
488
  if (scrollOffsetRef) {
406
489
  scrollOffsetRef.current = position
407
490
  }
@@ -429,59 +512,70 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
429
512
 
430
513
  const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
431
514
 
432
- return (
433
- <>
434
- <div className="flex h-full min-h-0 gap-4">
435
- <div ref={scrollContainerRef} className="flex min-h-0 w-full sm:w-90" style={columnHeightStyle}>
436
- <VirtualScroll
437
- ref={virtualScrollRef}
438
- itemCount={itemCount}
439
- getItem={getItem}
440
- getItemHeight={getItemHeight}
441
- viewportSize={viewportHeight}
442
- initialScrollOffset={initialScrollOffset}
443
- overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
444
- scrollBarOptions={{
445
- width: SCROLL_BAR_WIDTH,
446
- tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
447
- renderThumbOverlay: renderThumbOverlay,
448
- }}
449
- behaviorOptions={{
450
- // ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
451
- // 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
452
- pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
453
- inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
454
- wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
455
- // 行ラッパーへの tabIndex 付与 (タブ順汚染) と pointerdown フォーカス移動を止める。
456
- // 「★/既読タップで詳細ペインが切り替わる」不具合は 2 経路あり、これはその 1 つ目
457
- // (focus 経由)。2 つ目 (useTapClick の pointerup バブリング) はボタン側の
458
- // stopPointerPropagation が塞ぐ。onItemFocus は発火しなくなる
459
- enableKeyboardNavigation: false,
460
- }}
461
- onRangeChange={handleRangeChange}
462
- onScroll={handleScroll}
463
- contentInsets={{ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING }}
464
- testId="daily-report-list"
465
- className="relative w-full"
466
- contentProps={{
467
- // 行 (role="option" ではなく role="listitem") だけを所有する list を、
468
- // スクロールバーと兄弟のコンテンツ要素に置く (virtualscroll 1.23.0 の口)。
469
- // 各行が aria-posinset/aria-setsize を持つので SR は「全 N 件中 M 番目」を読める
470
- role: "list",
471
- "aria-label": title,
472
- }}>
473
- {renderListRow}
474
- </VirtualScroll>
475
- </div>
476
- <DailyReportSidePane dailyReportItems={dailyReportItems} selectedItem={selectedItem} heightStyle={columnHeightStyle} autoMarkRead={!isMobileLayout && autoMarkRead} isActive={selectedReportHubId !== null} userId={userId} />
515
+ const virtualScrollNode = useMemo(
516
+ () => (
517
+ <div ref={scrollContainerRef} className="isolate flex h-full min-h-0 w-full overflow-hidden" style={columnHeightStyle}>
518
+ <VirtualScroll
519
+ ref={virtualScrollRef}
520
+ itemCount={itemCount}
521
+ getItem={getItem}
522
+ getItemHeight={getItemHeight}
523
+ viewportSize={viewportHeight}
524
+ initialScrollOffset={initialScrollOffset}
525
+ overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
526
+ scrollBarOptions={{
527
+ width: SCROLL_BAR_WIDTH,
528
+ tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
529
+ renderThumbOverlay: renderThumbOverlay,
530
+ }}
531
+ behaviorOptions={VIRTUAL_SCROLL_BEHAVIOR_OPTIONS}
532
+ onRangeChange={handleRangeChange}
533
+ onScroll={handleScroll}
534
+ contentInsets={VIRTUAL_SCROLL_CONTENT_INSETS}
535
+ testId="daily-report-list"
536
+ className="relative w-full"
537
+ contentProps={{
538
+ // 行 (role="option" ではなく role="listitem") だけを所有する list を、
539
+ // スクロールバーと兄弟のコンテンツ要素に置く (virtualscroll 1.23.0 の口)。
540
+ // 各行が aria-posinset/aria-setsize を持つので SR は「全 N 件中 M 番目」を読める
541
+ role: "list",
542
+ "aria-label": title,
543
+ }}>
544
+ {renderListRow}
545
+ </VirtualScroll>
477
546
  </div>
547
+ ),
548
+ [columnHeightStyle, itemCount, getItem, getItemHeight, viewportHeight, initialScrollOffset, renderThumbOverlay, handleRangeChange, handleScroll, title, renderListRow],
549
+ )
550
+
551
+ return (
552
+ <div ref={rootContainerRef} className="h-full min-h-0 w-full" data-testid="daily-report-root">
553
+ {effectiveIsMobile ? (
554
+ <div className="flex h-full min-h-0">{virtualScrollNode}</div>
555
+ ) : (
556
+ <PanelGroup direction="horizontal" className="flex h-full min-h-0" data-testid="daily-report-panel-group">
557
+ <Panel id="daily-report-list-panel" defaultSize={{ value: 360, unit: "pixels" }} minSize={{ value: 160, unit: "pixels" }} maxSize={{ value: 520, unit: "pixels" }} contentOverflow="hidden" className="flex min-h-0">
558
+ {virtualScrollNode}
559
+ </Panel>
560
+ <PanelResizeHandle
561
+ id="daily-report-list-handle"
562
+ thickness={10}
563
+ indicator={{ visible: true, thickness: 6, length: 32, barThickness: 2, barLength: 16 }}
564
+ className="cursor-col-resize rounded transition-colors hover:bg-slate-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 dark:hover:bg-slate-700/60"
565
+ aria-label="日報一覧と詳細ペインの境界リサイズ"
566
+ />
567
+ <Panel id="daily-report-detail-panel" minSize={{ value: 160, unit: "pixels" }} contentOverflow="hidden" className="flex min-h-0 flex-1 pl-1.5">
568
+ <DailyReportSidePane dailyReportItems={dailyReportItems} selectedItem={selectedItem} autoMarkRead={autoMarkRead} isActive={selectedReportHubId !== null} userId={userId} />
569
+ </Panel>
570
+ </PanelGroup>
571
+ )}
478
572
  {itemCount === 0 && (
479
573
  <div className="py-8 text-center">
480
574
  <p className="text-slate-500">データがありません</p>
481
575
  </div>
482
576
  )}
483
- {isOverlayMounted && <DailyReportMobileOverlay isOpen={shouldShowOverlay} selectedItem={selectedItem} onClose={handleOverlayClose} autoMarkRead={isMobileLayout && autoMarkRead} userId={userId} />}
484
- </>
577
+ {isOverlayMounted && <DailyReportMobileOverlay isOpen={shouldShowOverlay} selectedItem={selectedItem} onClose={handleOverlayClose} autoMarkRead={effectiveIsMobile && autoMarkRead} userId={userId} />}
578
+ </div>
485
579
  )
486
580
  }
487
581
 
@@ -509,6 +603,10 @@ const ListSkeletonItem = (wrapperProps: React.HTMLAttributes<HTMLDivElement> & {
509
603
  </div>
510
604
  )
511
605
 
606
+ /**
607
+ * Tab types available in the daily report side pane.
608
+ * 日報サイドペインで利用可能なタブ種別。
609
+ */
512
610
  type DailyReportSidePaneTab = "article" | "relations"
513
611
 
514
612
  /**
@@ -523,7 +621,8 @@ const DailyReportSidePane = ({
523
621
  isActive = true,
524
622
  userId,
525
623
  }: {
526
- dailyReportItems?: DailyReportItem[] // Optional for mobile overlay usage
624
+ /** Full list of daily report items; omitted when rendered inside mobile overlay. 日報アイテム一覧。モバイルオーバーレイ時は未指定。 */
625
+ dailyReportItems?: DailyReportItem[]
527
626
  selectedItem: DailyReportItem | null
528
627
  heightStyle?: CSSProperties
529
628
  autoMarkRead?: boolean
@@ -578,8 +677,8 @@ const DailyReportSidePane = ({
578
677
 
579
678
  if (dailyReportItems && (dailyReportItems.length === 0 || !selectedItem)) {
580
679
  return (
581
- <aside className="hidden min-h-0 min-w-0 flex-1 gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:block dark:border-slate-700 dark:bg-slate-900/70" style={heightStyle}>
582
- <div className="flex flex-1 items-center justify-center rounded-xl border border-slate-300 border-dashed bg-slate-50 p-4 text-center text-slate-500 text-sm dark:border-slate-600 dark:bg-slate-900/40 dark:text-slate-300">
680
+ <aside className="hidden h-full min-h-0 min-w-0 flex-1 gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:flex dark:border-slate-700 dark:bg-slate-900/70" style={heightStyle}>
681
+ <div className="flex h-full flex-1 items-center justify-center rounded-xl border border-slate-300 border-dashed bg-slate-50 p-4 text-center text-slate-500 text-sm dark:border-slate-600 dark:bg-slate-900/40 dark:text-slate-300">
583
682
  <p>左側の一覧から日報を選択してください。</p>
584
683
  </div>
585
684
  </aside>
@@ -646,19 +745,18 @@ const DailyReportSidePane = ({
646
745
  </>
647
746
  )
648
747
 
649
- // If rendered as a standalone pane (desktop)
650
748
  if (dailyReportItems) {
651
749
  return (
652
750
  <aside
653
751
  data-testid="daily-report-side-pane"
654
752
  data-report-id={selectedItem?.reportHubId}
655
- className="hidden min-h-0 min-w-0 flex-1 flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:flex dark:border-slate-700 dark:bg-slate-900/70"
753
+ className="hidden h-full min-h-0 w-full min-w-0 flex-1 flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:flex dark:border-slate-700 dark:bg-slate-900/70"
656
754
  style={heightStyle}>
657
755
  {content}
658
756
  </aside>
659
757
  )
660
758
  }
661
- // If rendered inside mobile overlay
759
+
662
760
  return (
663
761
  <div data-testid="daily-report-side-pane" data-report-id={selectedItem?.reportHubId} className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
664
762
  {content}
@@ -779,14 +877,29 @@ const DailyReportDisplayPane = ({
779
877
  <div className="relative space-y-4 text-slate-700 dark:text-slate-200" onDoubleClick={onDoubleClick}>
780
878
  {!showSkeleton && report && (
781
879
  <div className="absolute top-0 right-2 z-10 flex items-center justify-end gap-3">
782
- <button type="button" onClick={() => handleToggleRead(false)} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title={isRead ? "未読にする" : "既読にする"} data-detail-read-button={report.reportHubId}>
880
+ <button
881
+ type="button"
882
+ onClick={() => handleToggleRead(false)}
883
+ className="rounded text-lg leading-none transition-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 active:scale-95"
884
+ title={isRead ? "未読にする" : "既読にする"}
885
+ data-detail-read-button={report.reportHubId}>
783
886
  {isRead ? <span className="text-slate-300 hover:text-blue-500">✓</span> : <UnreadIndicator className="hover:text-blue-600" />}
784
887
  </button>
785
- <button type="button" onClick={handleToggleStar} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title={isStarred ? "スターを外す" : "スターを付ける"} data-detail-star-button={report.reportHubId}>
888
+ <button
889
+ type="button"
890
+ onClick={handleToggleStar}
891
+ className="rounded text-lg leading-none transition-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 active:scale-95"
892
+ title={isStarred ? "スターを外す" : "スターを付ける"}
893
+ data-detail-star-button={report.reportHubId}>
786
894
  {isStarred ? <span className="text-yellow-400">★</span> : <span className="text-slate-300 hover:text-yellow-400">☆</span>}
787
895
  </button>
788
896
  {isMine && (
789
- <button type="button" onClick={handleDelete} data-detail-delete-button={report.reportHubId} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title="削除する">
897
+ <button
898
+ type="button"
899
+ onClick={handleDelete}
900
+ data-detail-delete-button={report.reportHubId}
901
+ className="rounded text-lg leading-none transition-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 active:scale-95"
902
+ title="削除する">
790
903
  <Trash2 className="h-4 w-4 text-slate-300 hover:text-red-500" />
791
904
  </button>
792
905
  )}
@@ -866,8 +979,6 @@ const DailyReportMobileOverlay = ({ isOpen, selectedItem, onClose, autoMarkRead
866
979
  const { report, error, isLoading } = useDailyReportDetail(selectedItem?.reportHubId ?? -1, selectedItem?.businessDate ?? null)
867
980
  const displayData = useTransitionDisplay(isOpen, selectedItem ? { item: selectedItem, detail: { report, error, isLoading } } : null)
868
981
  const displayItem = displayData?.item ?? null
869
- const _displayDetail = displayData?.detail ?? EMPTY_DETAIL
870
- _displayDetail
871
982
  const { overlayRef, overlayStyle, attemptClose, isSwiping } = useSwipeToDismissOverlay({
872
983
  isOpen,
873
984
  onClose,
@@ -899,13 +1010,21 @@ const DailyReportMobileOverlay = ({ isOpen, selectedItem, onClose, autoMarkRead
899
1010
  if (!displayItem) return null
900
1011
 
901
1012
  return (
902
- <div ref={containerRef} className={`fixed inset-0 z-50 flex touch-manipulation transition-[visibility] duration-200 sm:hidden ${isOpen ? "visible" : "invisible"} ${isOpen && !isSwiping ? "pointer-events-auto" : "pointer-events-none"}`} aria-hidden={!isOpen}>
1013
+ <div
1014
+ ref={containerRef}
1015
+ data-daily-report-mobile-overlay="true"
1016
+ className={`fixed top-0 bottom-0 left-0 z-[100] flex touch-manipulation transition-[visibility] duration-200 ${isOpen ? "visible" : "invisible"} ${isOpen && !isSwiping ? "pointer-events-auto" : "pointer-events-none"}`}
1017
+ style={{
1018
+ right: "var(--aqdr-overlay-right, 0px)",
1019
+ transition: "right 0.5s, visibility 0.2s",
1020
+ }}
1021
+ aria-hidden={!isOpen}>
903
1022
  <button type="button" aria-label="背景をタップして閉じる" className={`absolute inset-0 touch-manipulation bg-slate-900/50 transition-opacity duration-200 focus:outline-none ${isOpen ? "opacity-100" : "opacity-0"}`} {...tapHandlers}></button>
904
1023
  <aside
905
1024
  role="dialog"
906
1025
  aria-modal="true"
907
1026
  aria-label="日報詳細"
908
- className={`relative ml-auto flex h-full w-full max-w-full transform touch-none select-none bg-white shadow-xl transition-transform duration-200 ease-out dark:bg-slate-900/95 ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}
1027
+ className={`relative ml-auto flex h-full w-full max-w-full transform touch-none bg-white shadow-xl transition-transform duration-200 ease-out dark:bg-slate-900/95 ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}
909
1028
  ref={overlayRef}
910
1029
  onKeyDown={(e) => {
911
1030
  if (e.key === "Escape") {
@@ -1011,7 +1130,7 @@ const ListReportItem = memo(({ item, index, setSize, isActive, onSelect }: { ite
1011
1130
  try {
1012
1131
  await toggleStar(report.reportHubId, nextValue, report.date ?? "")
1013
1132
  } catch (_e) {
1014
- // Error handled in context
1133
+ // コンテキスト層のアクションハンドラでトースト通知およびロールバックが処理されるため握り潰す
1015
1134
  }
1016
1135
  }
1017
1136
 
@@ -1021,7 +1140,7 @@ const ListReportItem = memo(({ item, index, setSize, isActive, onSelect }: { ite
1021
1140
  try {
1022
1141
  await toggleRead(report.reportHubId, nextValue, report.date ?? "")
1023
1142
  } catch (_e) {
1024
- // Error handled in context
1143
+ // コンテキスト層のアクションハンドラでトースト通知およびロールバックが処理されるため握り潰す
1025
1144
  }
1026
1145
  }
1027
1146
 
@@ -1041,7 +1160,7 @@ const ListReportItem = memo(({ item, index, setSize, isActive, onSelect }: { ite
1041
1160
  {/* biome-ignore lint/a11y/useSemanticElements: Card contains nested interactive elements (Star/Read buttons) */}
1042
1161
  <div
1043
1162
  data-daily-report-button={item.reportHubId}
1044
- className={`flex h-full w-full in-[[data-scrolling]]:transform-none cursor-pointer select-none flex-col rounded-2xl border in-[[data-scrolling]]:border-slate-200 bg-white p-4 text-left in-[[data-scrolling]]:shadow-sm shadow-sm in-[[data-scrolling]]:transition-none transition-all duration-200 hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-lg dark:bg-slate-900/70 ${
1163
+ className={`flex h-full w-full in-[[data-scrolling]]:transform-none cursor-pointer select-none flex-col rounded-2xl border in-[[data-scrolling]]:border-slate-200 bg-white p-4 text-left in-[[data-scrolling]]:shadow-sm shadow-sm in-[[data-scrolling]]:transition-none transition-[transform,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-lg dark:bg-slate-900/70 ${
1045
1164
  isActive ? "border-blue-300 shadow-md ring-2 ring-blue-200 dark:border-blue-500/70 dark:ring-blue-500/40" : "border-slate-200 dark:border-slate-700"
1046
1165
  }`}
1047
1166
  style={{ touchAction: "manipulation" }}