@aiquants/daily-report 0.7.0 → 0.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.
Files changed (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +438 -26
  3. package/dist/client.d.ts +438 -26
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +115 -3
  9. package/dist/index.d.ts +115 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +15 -0
  15. package/dist/server.d.ts +15 -0
  16. package/dist/server.js +9 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +9 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  21. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/package.json +3 -3
  24. package/src/client/components/daily-report-comment-item.tsx +6 -1
  25. package/src/client/components/daily-report-detail-list.tsx +65 -34
  26. package/src/client/components/daily-report-edit-form.tsx +6 -0
  27. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  28. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  29. package/src/client/components/daily-report-list.tsx +88 -55
  30. package/src/client/components/daily-report-page.spec.tsx +119 -0
  31. package/src/client/components/daily-report-page.tsx +62 -69
  32. package/src/client/components/daily-report-resolved-content.tsx +36 -10
  33. package/src/client/components/report-views.spec.tsx +37 -2
  34. package/src/client/config-context.tsx +18 -0
  35. package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
  36. package/src/client/contexts/daily-report-action-context.tsx +203 -65
  37. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  38. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  39. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  40. package/src/client/hooks/use-animated-number.ts +69 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  42. package/src/client/hooks/use-daily-report.ts +30 -1
  43. package/src/client/route-helpers.spec.ts +100 -0
  44. package/src/client/route-helpers.ts +20 -41
  45. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
  46. package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
  47. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  48. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  49. package/src/client.ts +5 -0
  50. package/src/index.ts +1 -0
  51. package/src/server/handlers.action.spec.ts +44 -0
  52. package/src/server/handlers.ids-stream.spec.ts +414 -0
  53. package/src/server/handlers.ts +324 -8
  54. package/src/server/service.spec.ts +83 -7
  55. package/src/server/service.ts +100 -3
  56. package/src/server/test-helpers/handlers-config.ts +48 -1
  57. package/src/server.spec.ts +49 -0
  58. package/src/server.ts +7 -0
  59. package/src/shared/business-date.spec.ts +22 -1
  60. package/src/shared/business-date.ts +20 -0
  61. package/src/shared/comment-adapter.spec.ts +38 -0
  62. package/src/shared/comment-adapter.ts +9 -7
  63. package/src/shared/ids-stream.spec.ts +65 -0
  64. package/src/shared/ids-stream.ts +139 -0
  65. package/src/shared/sse-schema.ts +5 -0
  66. package/src/shared/text-utils.spec.ts +42 -0
  67. package/src/shared/text-utils.ts +8 -0
@@ -1,54 +1,31 @@
1
1
  /**
2
- * Top-level daily-report page content (chrome-agnostic).
3
- * アプリのレイアウトに依存しない日報ページ本体コンポーネント。
2
+ * Top-level daily-report page content (chrome-agnostic, stream-driven).
3
+ * アプリのレイアウトに依存しない日報ページ本体コンポーネント (ストリーム駆動)。
4
4
  *
5
- * アプリ側のルートは本コンポーネントを自前のレイアウト (Main 等) で包み、
6
- * `config` でヘッダー描画・パス・ラベル名を注入する。
5
+ * ID 一覧はモジュール常駐の NDJSON ストリームセッションから購読し、最初の
6
+ * チャンク到着と同時に一覧を描画する (全件解決を待つ Await ゲートは廃止)。
7
+ * 再訪時はセッションに残る受信済みデータで即座に復元し、鮮度はバックグラウンド
8
+ * 再検証 (SWR) と SSE が担う。アプリ側のルートは本コンポーネントを自前の
9
+ * レイアウトで包み、`config` でヘッダー描画・パス・ラベル名を注入する。
7
10
  */
8
- import { Suspense, useCallback, useEffect, useState } from "react"
9
- import { Await, useRevalidator } from "react-router"
10
- import type { DailyReportItem, DailyReportUser } from "../../shared/types"
11
+ import { useEffect } from "react"
12
+ import type { DailyReportUser } from "../../shared/types"
11
13
  import { type DailyReportClientConfigInput, DailyReportConfigProvider, useDailyReportConfig } from "../config-context"
12
14
  import { DailyReportActionProvider } from "../contexts/daily-report-action-context"
15
+ import { DailyReportErrorProvider } from "../contexts/daily-report-error-context"
16
+ import { ensureDailyReportIdsStreamSession, reloadDocument, retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
13
17
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
18
+ import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
14
19
  import { DailyReportResolvedContent } from "./daily-report-resolved-content"
15
20
 
16
21
  /**
17
- * Synchronizes resolved daily report ids into the action context provider state.
18
- * Also renders the resolved content after synchronization trigger is set.
22
+ * Error display when the ids stream terminally failed before any data arrived.
23
+ * データ未受信のまま ID ストリームが力尽きたときのエラー表示コンポーネント。
19
24
  *
20
- * 解決済みの日報 ID を Action Context 側へ同期し、解決済みコンテンツを描画するコンポーネント。
21
- */
22
- const DailyReportResolvedSync = ({ dailyReportItems, userId, onResolved }: { dailyReportItems: DailyReportItem[]; userId?: string | null; onResolved: (items: DailyReportItem[]) => void }) => {
23
- /**
24
- * Notifies parent when resolved items change.
25
- * Dependencies:
26
- * - dailyReportItems: latest resolved list from Await.
27
- * - onResolved: parent synchronization callback.
28
- * Cleanup:
29
- * - None (pure synchronization side effect).
30
- *
31
- * 解決済み items が変化したときに親へ通知します。
32
- * 依存配列:
33
- * - dailyReportItems: Await で解決された最新リスト
34
- * - onResolved: 親への同期コールバック
35
- * クリーンアップ:
36
- * - なし(同期のみの副作用)
37
- */
38
- useEffect(() => {
39
- onResolved(dailyReportItems)
40
- }, [dailyReportItems, onResolved])
41
-
42
- return <DailyReportResolvedContent dailyReportItems={dailyReportItems} userId={userId} />
43
- }
44
-
45
- /**
46
- * Error display component for daily report data fetch failures.
47
- * 日報データの取得失敗時に表示するエラーコンポーネント。
25
+ * 再試行はストリームセッションの retry へ直結する (受信済みカーソルから再開)。
48
26
  */
49
27
  export const DailyReportLoadError = () => {
50
28
  const { title, renderHeader } = useDailyReportConfig()
51
- const { revalidate } = useRevalidator()
52
29
  return (
53
30
  <>
54
31
  {renderHeader({ title, annotation: <span className="font-bold text-red-500">Error</span> })}
@@ -56,7 +33,11 @@ export const DailyReportLoadError = () => {
56
33
  <div className="mx-auto flex h-full w-full max-w-6xl flex-1 flex-col gap-6">
57
34
  <div className="flex h-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-red-200 bg-white p-4 text-slate-500 shadow-sm dark:border-red-900 dark:bg-slate-900/70 dark:text-slate-400">
58
35
  <p className="font-medium text-red-600">日報データの読み込みに失敗しました。</p>
59
- <button type="button" onClick={() => revalidate()} className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
36
+ <button
37
+ type="button"
38
+ data-testid="daily-report-load-error-retry"
39
+ onClick={() => retryDailyReportIdsStream()}
40
+ className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
60
41
  再読み込み
61
42
  </button>
62
43
  </div>
@@ -67,8 +48,8 @@ export const DailyReportLoadError = () => {
67
48
  }
68
49
 
69
50
  /**
70
- * Suspense fallback while daily report identifiers resolve.
71
- * 日報 ID 解決中のサスペンスフォールバック。
51
+ * Loading screen shown only until the first stream chunk arrives.
52
+ * 最初のストリームチャンクが届くまでの間だけ表示するローディング画面。
72
53
  */
73
54
  export const DailyReportLoading = () => {
74
55
  const { title, renderHeader } = useDailyReportConfig()
@@ -77,7 +58,8 @@ export const DailyReportLoading = () => {
77
58
  {renderHeader({
78
59
  title,
79
60
  annotation: (
80
- <div className="flex flex-wrap gap-2 leading-none">
61
+ <div className="flex flex-wrap items-center gap-2 leading-none">
62
+ <DailyReportIdsStreamStatus />
81
63
  <span>総件数: 読み込み中...</span>
82
64
  <span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
83
65
  </div>
@@ -97,42 +79,53 @@ export type DailyReportPageProps = {
97
79
  user?: DailyReportUser
98
80
  /** 難読化済みの内部ユーザー ID (未登録ユーザーは null)。 */
99
81
  userId?: string | null
100
- /** clientLoader で defer した日報 ID 一覧 (Promise) または解決済み配列。 */
101
- dailyReportIds?: Promise<DailyReportItem[]> | DailyReportItem[] | null
102
82
  /** クライアント設定の部分上書き (ヘッダー描画・パス・ラベル名・フィールドラベルなど)。 */
103
83
  config?: DailyReportClientConfigInput
104
84
  }
105
85
 
106
86
  /**
107
- * Daily report page content rendering provider, suspense boundary, and resolved views.
108
- * プロバイダー・サスペンス境界・解決済みビューを描画する日報ページ本体。
87
+ * Stream-driven page body: session bootstrap, phase gating, and provider wiring.
88
+ * ストリーム駆動のページ本体。セッション確立・局面ゲーティング・プロバイダー結線を担う。
109
89
  */
110
- const DailyReportPageInner = ({ user, userId, dailyReportIds }: Omit<DailyReportPageProps, "config">) => {
111
- const [resolvedInitialItems, setResolvedInitialItems] = useState<DailyReportItem[]>([])
90
+ const DailyReportPageInner = ({ user, userId }: Omit<DailyReportPageProps, "config">) => {
91
+ const { apiBasePath } = useDailyReportConfig()
92
+ const stream = useDailyReportIdsStream()
112
93
 
113
- const handleResolvedItems = useCallback((items: DailyReportItem[]) => {
114
- setResolvedInitialItems(items)
115
- }, [])
94
+ /**
95
+ * ストリームセッション確立の保険。
96
+ * 通常は clientLoader が先に確立するが、clientLoader を経ないマウントや
97
+ * ユーザー切替 (userKey 不一致でセッション再生成) をここで拾う。
98
+ * 依存配列: apiBasePath / userId (いずれかの変化で再確認)。
99
+ * クリーンアップ: なし (セッションはモジュール常駐で、アンマウント後も生存させる)。
100
+ */
101
+ useEffect(() => {
102
+ ensureDailyReportIdsStreamSession({ apiBasePath, userKey: userId ?? null })
103
+ }, [apiBasePath, userId])
104
+
105
+ /**
106
+ * 認証切れ (ストリームが 401/403) はドキュメントのフルリロードへ倒し、
107
+ * サーバー loader のログインリダイレクトに委譲する。
108
+ * 依存配列: stream.phase。クリーンアップ: なし。
109
+ */
110
+ useEffect(() => {
111
+ if (stream.phase === "auth-required") {
112
+ reloadDocument()
113
+ }
114
+ }, [stream.phase])
116
115
 
116
+ // 1 件でも届いたら (または空リストで完走したら) 本体を描画する
117
+ const showContent = stream.items.length > 0 || stream.phase === "complete"
117
118
  return (
118
- <DailyReportActionProvider user={user} initialItems={resolvedInitialItems} userId={userId}>
119
- <Suspense fallback={<DailyReportLoading />}>
120
- {/* Resolve で非同期取得済み ID を確定表示 */}
121
- {dailyReportIds ? (
122
- <Await resolve={dailyReportIds} errorElement={<DailyReportLoadError />}>
123
- {(resolvedItems) => {
124
- if (!resolvedItems) {
125
- throw new Error("Daily report ids resolve must not be null")
126
- }
127
- // 非 null を保証して以降の処理で利用
128
- return <DailyReportResolvedSync dailyReportItems={resolvedItems as DailyReportItem[]} userId={userId} onResolved={handleResolvedItems} />
129
- }}
130
- </Await>
131
- ) : (
132
- <DailyReportLoading />
133
- )}
134
- </Suspense>
135
- </DailyReportActionProvider>
119
+ // key でユーザーごとにプロバイダーをリマウントする。リロード無しのユーザー切替
120
+ // (再ログイン・成り代わり) で items / removedIds / 保留キュー等の状態が旧ユーザーの
121
+ // まま残ると、render-phase マージが旧ユーザーの行を新ユーザーの一覧へ持ち越す
122
+ // エラーサーフェスはユーザー切替のリマウント境界より外側 (バナーは切替を跨いで消えてよい)。
123
+ // アクションプロバイダーはこの notifyError を購読して失敗を可視化する
124
+ <DailyReportErrorProvider>
125
+ <DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
126
+ {showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
127
+ </DailyReportActionProvider>
128
+ </DailyReportErrorProvider>
136
129
  )
137
130
  }
138
131
 
@@ -2,35 +2,41 @@ import { Plus, RefreshCw, RotateCw } from "lucide-react"
2
2
  import type { ReactNode } from "react"
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
4
4
  import { useSearchParams } from "react-router"
5
- import type { DailyReportItem } from "../../shared/types"
5
+ import { resolveTodayBusinessDateKey } from "../../shared/business-date"
6
6
  import { useDailyReportConfig } from "../config-context"
7
7
  import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
8
+ import { useAnimatedNumber } from "../hooks/use-animated-number"
8
9
  import { Button } from "../ui/button"
9
10
  import { Label } from "../ui/label"
10
11
  import { Switch } from "../ui/switch"
11
12
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
12
13
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
13
14
  import { DailyReportDetailList } from "./daily-report-detail-list"
15
+ import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
14
16
  import { DailyReportList } from "./daily-report-list"
15
17
 
16
18
  const tabTriggerClass = "min-w-24 p-2 font-medium text-slate-600 text-xs transition-colors data-[state=active]:bg-white data-[state=active]:text-slate-900 sm:min-w-36 sm:text-sm dark:text-slate-300 dark:data-[state=active]:bg-slate-800 dark:data-[state=active]:text-slate-100"
17
19
 
18
20
  type DailyReportResolvedContentProps = {
19
- dailyReportItems: DailyReportItem[]
20
21
  userId?: string | null
21
22
  }
22
23
 
23
24
  /**
24
- * Displays fetched daily reports with layout and tabbed virtual scroll views.
25
- * レイアウトとタブ付き仮想スクロールで取得済み日報を表示するコンポーネント。
25
+ * Displays streamed daily reports with layout and tabbed virtual scroll views.
26
+ * レイアウトとタブ付き仮想スクロールでストリーム受信済み日報を表示するコンポーネント。
27
+ *
28
+ * アイテムは Action Context (ストリームチャンクをマージ済み) から読む。
26
29
  */
27
- export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, userId }: DailyReportResolvedContentProps) => {
30
+ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedContentProps) => {
28
31
  // 自動既読設定
29
32
  const [autoMarkRead, setAutoMarkRead] = useState(false)
30
33
  const { title, renderHeader, showDevControls } = useDailyReportConfig()
31
34
  const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
32
35
  const [searchParams] = useSearchParams()
33
36
 
37
+ // 総件数の表示値 (チャンク到着ごとの跳びをイーズアウトで滑らかにする)
38
+ const animatedItemCount = useAnimatedNumber(dailyReportItems.length)
39
+
34
40
  // 各タブのスクロール位置をアンマウント後も保持する Ref
35
41
  const listScrollOffsetRef = useRef<number>(0)
36
42
  const detailListScrollOffsetRef = useRef<number>(0)
@@ -49,6 +55,24 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
49
55
  return dailyReportItems[0].reportHubId
50
56
  })
51
57
 
58
+ // 初回の自動選択を一度きりにするフラグ (ユーザーの明示的な選択解除を上書きしない)
59
+ const hasAutoSelectedRef = useRef(false)
60
+
61
+ /**
62
+ * 最初のアイテム到着時に先頭日報を一度だけ自動選択する副作用。
63
+ * useState 初期化子はマウント時点で context の items がまだ空のため実質機能せず、
64
+ * ストリーミングでは最初のチャンク到着後に選択を確定させる必要がある。
65
+ * モバイルのオーバーレイは isMobileDetailOpen (明示タップ) でのみ開くため、
66
+ * プログラム選択でオーバーレイが開くことはない。
67
+ * 依存配列: dailyReportItems (チャンク到着ごと)。クリーンアップ: なし。
68
+ */
69
+ useEffect(() => {
70
+ if (!hasAutoSelectedRef.current && dailyReportItems.length > 0) {
71
+ hasAutoSelectedRef.current = true
72
+ setSelectedItemId((prev) => prev ?? dailyReportItems[0].reportHubId)
73
+ }
74
+ }, [dailyReportItems])
75
+
52
76
  const handleReload = async () => {
53
77
  await refetchData()
54
78
  }
@@ -59,8 +83,9 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
59
83
 
60
84
  const handleCreateReport = async () => {
61
85
  const businessDateParam = searchParams.get("businessDate")
62
- const today = new Date().toISOString().split("T")[0]
63
- const dateToUse = businessDateParam || today
86
+ // 「本日」はローカル暦日で決める。toISOString() の UTC 日付だと JST 深夜〜午前帯に
87
+ // 前日の営業日へ新規日報が登録されてしまう
88
+ const dateToUse = businessDateParam || resolveTodayBusinessDateKey()
64
89
  const newId = await createReport(dateToUse)
65
90
  setSelectedItemId(newId)
66
91
  }
@@ -123,13 +148,14 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
123
148
  {renderHeader({
124
149
  title,
125
150
  annotation: (
126
- <div className="flex flex-wrap gap-2 leading-1">
127
- <span>総件数: {dailyReportItems.length.toLocaleString()} 件</span>
151
+ <div className="flex flex-wrap items-center gap-2 leading-1">
152
+ <DailyReportIdsStreamStatus />
153
+ <span className="tabular-nums">総件数: {animatedItemCount.toLocaleString()} 件</span>
128
154
  <span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
129
155
  </div>
130
156
  ),
131
157
  })}
132
- <div className="h-full bg-slate-50 dark:bg-slate-500">
158
+ <div className="h-full bg-slate-50 dark:bg-slate-950">
133
159
  <div className="flex h-full w-full max-w-6xl flex-1 flex-col p-1">
134
160
  <Tabs defaultValue="list" className="flex h-full flex-1 flex-col">
135
161
  <div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 p-1 transition-colors dark:bg-slate-900/70 dark:text-slate-200">
@@ -39,7 +39,7 @@ import { CONTENT_DRAG_POINTER_INPUTS, ITEM_VERTICAL_PADDING, TAP_SCROLL_CIRCLE_O
39
39
  type CapturedVirtualScrollProps = {
40
40
  testId?: string
41
41
  itemCount: number
42
- behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown }
42
+ behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown; enableKeyboardNavigation?: unknown }
43
43
  scrollBarOptions?: { tapScrollCircleOptions?: unknown; renderThumbOverlay?: (props: Record<string, unknown>) => ReactNode }
44
44
  getItemKey?: unknown
45
45
  getItemHeight?: unknown
@@ -48,6 +48,7 @@ type CapturedVirtualScrollProps = {
48
48
  onRangeChange?: unknown
49
49
  onScroll?: unknown
50
50
  contentInsets?: unknown
51
+ contentProps?: Record<string, unknown>
51
52
  }
52
53
 
53
54
  const captured: CapturedVirtualScrollProps[] = []
@@ -84,10 +85,13 @@ vi.mock("@aiquants/virtualscroll", async () => {
84
85
  onRangeChange: props.onRangeChange,
85
86
  onScroll: props.onScroll,
86
87
  contentInsets: props.contentInsets,
88
+ contentProps: props.contentProps,
87
89
  })
88
90
  useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
91
+ // 本物は contentProps をコンテンツ要素 (行だけを所有する list) へ透過する。
92
+ // ロール階層 (list → listitem) を DOM で検査できるよう代役でも同じ口へ流す
89
93
  return (
90
- <div data-testid={props.testId}>
94
+ <div data-testid={props.testId} {...props.contentProps}>
91
95
  {Array.from({ length: props.itemCount }, (_, index) => (
92
96
  // biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
93
97
  <div key={index}>{props.children(props.getItem(index), index)}</div>
@@ -338,3 +342,34 @@ describe("日報ビューの描画と結線", () => {
338
342
  expect(detailViews).toEqual(REPORT_DETAIL_VIEWS.map((view) => view.file).sort())
339
343
  })
340
344
  })
345
+
346
+ describe("日報ビューのアクセシビリティ (仮想化リストのセマンティクス)", () => {
347
+ it.each(VIEW_CASES)("%s は行だけを所有する list をコンテンツ要素へ置く", (_file, view) => {
348
+ // role="list" を「スクロールバーと兄弟の」コンテンツ要素へ置く (contentProps 経由)。
349
+ // ペイン以上の階層に置くと ScrollBar の role="scrollbar" が不許可の所有子になる
350
+ renderView(view.render())
351
+ const props = optionsOf(view.testId)
352
+ expect(props.contentProps?.role).toBe("list")
353
+ expect(typeof props.contentProps?.["aria-label"]).toBe("string")
354
+ expect((props.contentProps?.["aria-label"] as string).length).toBeGreaterThan(0)
355
+ })
356
+
357
+ it.each(VIEW_CASES)("%s は enableKeyboardNavigation を false にする (タブ順汚染と focus 奪取の防止)", (_file, view) => {
358
+ // 既定 true は行ラッパーへ tabIndex=-1 を付けてタブ順を汚し、pointerdown で
359
+ // 行へフォーカスを移す (★/既読タップで詳細ペインが黙って切り替わる不具合の原因)。
360
+ // false にすることで list/listitem のロール階層も有効になる
361
+ renderView(view.render())
362
+ expect(optionsOf(view.testId).behaviorOptions?.enableKeyboardNavigation).toBe(false)
363
+ })
364
+
365
+ it.each(VIEW_CASES)("%s は各行へ role=listitem と aria-posinset/aria-setsize を付ける", (_file, view) => {
366
+ // 仮想化で描画されるのは窓分だけなので、絶対位置 (posinset) と全件数 (setsize) を
367
+ // 各行に載せないと SR は「全 N 件中 M 番目」を読めない
368
+ const { container } = renderView(view.render())
369
+ const listItems = container.querySelectorAll('[role="listitem"]')
370
+ expect(listItems.length).toBe(1)
371
+ const row = listItems[0]
372
+ expect(row.getAttribute("aria-posinset")).toBe("1")
373
+ expect(row.getAttribute("aria-setsize")).toBe("1")
374
+ })
375
+ })
@@ -43,6 +43,19 @@ export type DailyReportFieldLabels = {
43
43
  relationsEmpty: string
44
44
  }
45
45
 
46
+ /**
47
+ * Mutation-failure notification payload (for a host-supplied error surface).
48
+ * ミューテーション失敗の通知情報 (ホストが独自の通知 UI を出すための口)。
49
+ */
50
+ export type DailyReportErrorInfo = {
51
+ /** 失敗した操作の種別 (create / update / publish / delete / addComment / deleteComment / toggleStar / toggleRead)。 */
52
+ operation: string
53
+ /** 表示用のメッセージ (パッケージ内蔵バナー用)。 */
54
+ message: string
55
+ /** 元例外 (診断用)。 */
56
+ cause?: unknown
57
+ }
58
+
46
59
  /** データソースごとのバッジ表示設定。 */
47
60
  export type SourceTypeConfig = {
48
61
  label: string
@@ -69,6 +82,11 @@ export type DailyReportClientConfig = {
69
82
  renderHeader: (props: DailyReportHeaderProps) => ReactNode
70
83
  /** 開発向けコントロール (SSE 購読トグル等) を表示するか。 */
71
84
  showDevControls: boolean
85
+ /**
86
+ * ミューテーション失敗時の通知ポート。未指定ならパッケージ内蔵の role="alert"
87
+ * バナーで表示する。ホストが独自トースト等を持つ場合はここへ委譲する。
88
+ */
89
+ onError?: (info: DailyReportErrorInfo) => void
72
90
  }
73
91
 
74
92
  /**