@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.
- package/README.md +2 -2
- package/dist/client.d.mts +6 -3
- package/dist/client.d.ts +6 -3
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/server.d.mts +17 -7
- package/dist/server.d.ts +17 -7
- package/dist/server.js +10 -10
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +10 -10
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +6 -4
- package/src/client/components/daily-report-list.tsx +199 -80
- package/src/client/components/daily-report-resize-panels.spec.tsx +305 -0
- package/src/client/hooks/use-responsive-layout.ts +34 -27
- package/src/server/handlers.ts +218 -176
- package/src/server/handlers.unauthorized-cookie.spec.ts +187 -0
- package/src/server/ports.ts +6 -1
- package/src/server/service.spec.ts +1 -1
- package/src/server/test-helpers/handlers-config.ts +16 -3
- package/src/server.spec.ts +7 -7
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resize panels integration in DailyReportList (desktop and mobile layouts).
|
|
3
|
+
* DailyReportList におけるリサイズパネル統合 (デスクトップおよびモバイルレイアウト) のテスト。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
7
|
+
import { type ReactNode, useState } from "react"
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
9
|
+
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
10
|
+
import { DailyReportConfigProvider } from "../config-context"
|
|
11
|
+
|
|
12
|
+
vi.mock("@aiquants/virtualscroll", async () => {
|
|
13
|
+
const { forwardRef: fwd, useImperativeHandle: useHandle } = await import("react")
|
|
14
|
+
return {
|
|
15
|
+
tapScrollCircleSampleVisual: () => null,
|
|
16
|
+
VirtualScroll: fwd((props: { testId?: string; itemCount: number; getItem: (index: number) => unknown; children: (item: unknown, index: number) => ReactNode }, ref: React.Ref<unknown>) => {
|
|
17
|
+
useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
|
|
18
|
+
return (
|
|
19
|
+
<div data-testid={props.testId}>
|
|
20
|
+
{Array.from({ length: props.itemCount }, (_, index) => (
|
|
21
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
|
|
22
|
+
<div key={index}>{props.children(props.getItem(index), index)}</div>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}),
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
vi.mock("../contexts/daily-report-action-context", async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal<typeof import("../contexts/daily-report-action-context")>()
|
|
32
|
+
const noop = async () => {}
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
useDailyReportActionContext: () => ({
|
|
36
|
+
pendingAddCommentIds: { current: new Map() },
|
|
37
|
+
pendingDeleteCommentIds: { current: new Map() },
|
|
38
|
+
pendingReadUpdates: { current: new Map() },
|
|
39
|
+
pendingStarUpdates: { current: new Map() },
|
|
40
|
+
resolvedIdMap: { current: new Map() },
|
|
41
|
+
version: 0,
|
|
42
|
+
items: [],
|
|
43
|
+
user: null,
|
|
44
|
+
isSseEnabled: false,
|
|
45
|
+
toggleSse: () => {},
|
|
46
|
+
addComment: noop,
|
|
47
|
+
deleteComment: noop,
|
|
48
|
+
toggleStar: noop,
|
|
49
|
+
toggleRead: noop,
|
|
50
|
+
createReport: async () => 0,
|
|
51
|
+
updateReport: noop,
|
|
52
|
+
publishReport: noop,
|
|
53
|
+
deleteReport: noop,
|
|
54
|
+
removeStaleItem: () => {},
|
|
55
|
+
refetchData: noop,
|
|
56
|
+
clearCacheAndRefetch: noop,
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const detailRef: { current: DailyReportDetail | null } = { current: null }
|
|
62
|
+
vi.mock("../hooks/use-daily-report", async (importOriginal) => {
|
|
63
|
+
const actual = await importOriginal<typeof import("../hooks/use-daily-report")>()
|
|
64
|
+
return {
|
|
65
|
+
...actual,
|
|
66
|
+
useDailyReportDetail: () => ({ report: detailRef.current, error: null, isLoading: false, isRefetching: false }),
|
|
67
|
+
useDailyReportPrefetch: () => () => {},
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const { DailyReportList } = await import("./daily-report-list")
|
|
72
|
+
|
|
73
|
+
const REPORT_HUB_ID = 100
|
|
74
|
+
const item: DailyReportItem = { reportHubId: REPORT_HUB_ID, businessDate: "2026-09-08" }
|
|
75
|
+
|
|
76
|
+
const buildDetail = (): DailyReportDetail => ({
|
|
77
|
+
reportHubId: REPORT_HUB_ID,
|
|
78
|
+
date: "2026-09-08",
|
|
79
|
+
interviewers: [],
|
|
80
|
+
externalComments: [],
|
|
81
|
+
isRead: false,
|
|
82
|
+
isStarred: false,
|
|
83
|
+
labels: [],
|
|
84
|
+
postedComments: [],
|
|
85
|
+
attachments: [],
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const renderWithConfig = (node: ReactNode) =>
|
|
89
|
+
render(
|
|
90
|
+
<DailyReportConfigProvider
|
|
91
|
+
config={{
|
|
92
|
+
apiBasePath: "/daily_report/api",
|
|
93
|
+
fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" },
|
|
94
|
+
}}>
|
|
95
|
+
{node}
|
|
96
|
+
</DailyReportConfigProvider>,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
describe("DailyReportList / リサイズパネル統合", () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
detailRef.current = buildDetail()
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
"ResizeObserver",
|
|
104
|
+
class {
|
|
105
|
+
observe() {}
|
|
106
|
+
unobserve() {}
|
|
107
|
+
disconnect() {}
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("デスクトップ表示 (isMobileLayout === false)", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.stubGlobal(
|
|
115
|
+
"matchMedia",
|
|
116
|
+
vi.fn((query: string) => ({
|
|
117
|
+
matches: false,
|
|
118
|
+
media: query,
|
|
119
|
+
onchange: null,
|
|
120
|
+
addEventListener: vi.fn(),
|
|
121
|
+
removeEventListener: vi.fn(),
|
|
122
|
+
addListener: vi.fn(),
|
|
123
|
+
removeListener: vi.fn(),
|
|
124
|
+
dispatchEvent: vi.fn(),
|
|
125
|
+
})),
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("PanelGroup が水平方向で描画される", () => {
|
|
130
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
131
|
+
const panelGroup = container.querySelector('[data-panel-group-direction="horizontal"]')
|
|
132
|
+
expect(panelGroup).toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("左側の日報一覧パネル (Panel) が正しく配置される", () => {
|
|
136
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
137
|
+
const listPanel = container.querySelector('[data-panel-id="daily-report-list-panel"]')
|
|
138
|
+
expect(listPanel).toBeInTheDocument()
|
|
139
|
+
expect(listPanel?.querySelector('[data-testid="daily-report-list"]')).toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("右側の詳細ペインパネル (Panel) が正しく配置される", () => {
|
|
143
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
144
|
+
const detailPanel = container.querySelector('[data-panel-id="daily-report-detail-panel"]')
|
|
145
|
+
expect(detailPanel).toBeInTheDocument()
|
|
146
|
+
expect(detailPanel?.querySelector('[data-testid="daily-report-side-pane"]')).toBeInTheDocument()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("リサイズハンドルがアクセシビリティ属性とインジケーターを備えて描画される", () => {
|
|
150
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
151
|
+
const handle = container.querySelector('[data-resize-handle-id="daily-report-list-handle"]')
|
|
152
|
+
expect(handle).toBeInTheDocument()
|
|
153
|
+
expect(handle).toHaveAttribute("aria-label")
|
|
154
|
+
expect(handle?.getAttribute("aria-label")).toContain("Resize")
|
|
155
|
+
|
|
156
|
+
// 視覚インジケーターおよび内部バーの存在検証
|
|
157
|
+
const indicator = handle?.querySelector("[data-resize-handle-indicator]")
|
|
158
|
+
expect(indicator).toBeInTheDocument()
|
|
159
|
+
const indicatorBar = indicator?.querySelector("[data-resize-handle-indicator-bar]")
|
|
160
|
+
expect(indicatorBar).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("リサイズハンドルはキーボード操作可能なボタン要素である", () => {
|
|
164
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
165
|
+
const handle = container.querySelector('[data-resize-handle-id="daily-report-list-handle"]') as HTMLButtonElement
|
|
166
|
+
expect(handle).toBeInTheDocument()
|
|
167
|
+
expect(handle.tagName).toBe("BUTTON")
|
|
168
|
+
expect(handle.type).toBe("button")
|
|
169
|
+
expect(handle.disabled).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("詳細ペインパネルに境界侵食防止用の左パディングクラス (pl-1.5) が付与されている", () => {
|
|
173
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
174
|
+
const detailPanel = container.querySelector('[data-panel-id="daily-report-detail-panel"]')
|
|
175
|
+
expect(detailPanel).toBeInTheDocument()
|
|
176
|
+
expect(detailPanel?.className).toContain("pl-1.5")
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("キーボードリサイズ操作により矢印キー操作が安全に処理される", () => {
|
|
180
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
181
|
+
const handle = container.querySelector('[data-resize-handle-id="daily-report-list-handle"]') as HTMLButtonElement
|
|
182
|
+
expect(handle).toBeInTheDocument()
|
|
183
|
+
|
|
184
|
+
// 左右の矢印キーでリサイズを実行
|
|
185
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" })
|
|
186
|
+
fireEvent.keyDown(handle, { key: "ArrowLeft" })
|
|
187
|
+
expect(handle).toBeInTheDocument()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("ポインタードラッグ操作によるリサイズ処理が安全に完了する", () => {
|
|
191
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
192
|
+
const handle = container.querySelector('[data-resize-handle-id="daily-report-list-handle"]') as HTMLButtonElement
|
|
193
|
+
expect(handle).toBeInTheDocument()
|
|
194
|
+
|
|
195
|
+
fireEvent.pointerDown(handle, { clientX: 360, pointerId: 1 })
|
|
196
|
+
fireEvent.pointerMove(handle, { clientX: 420, pointerId: 1 })
|
|
197
|
+
fireEvent.pointerUp(handle, { clientX: 420, pointerId: 1 })
|
|
198
|
+
expect(handle).toBeInTheDocument()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("データが 0 件のとき、デスクトップ表示で空状態表示と PanelGroup が整合して描画される", () => {
|
|
202
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[]} selectedReportHubId={null} onSelectItem={() => {}} />)
|
|
203
|
+
const panelGroup = container.querySelector('[data-panel-group-direction="horizontal"]')
|
|
204
|
+
expect(panelGroup).toBeInTheDocument()
|
|
205
|
+
expect(screen.getByText("データがありません")).toBeInTheDocument()
|
|
206
|
+
expect(screen.getByText("左側の一覧から日報を選択してください。")).toBeInTheDocument()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('ルート要素に data-testid="daily-report-root" が付与される', () => {
|
|
210
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
211
|
+
expect(container.querySelector('[data-testid="daily-report-root"]')).toBeInTheDocument()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe("コンテナ幅狭小化時のフォールバック (isContainerNarrow === true)", () => {
|
|
216
|
+
let resizeCallback: (entries: { target: Element; contentRect: { width: number } }[]) => void
|
|
217
|
+
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
vi.stubGlobal(
|
|
220
|
+
"matchMedia",
|
|
221
|
+
vi.fn((query: string) => ({
|
|
222
|
+
matches: false, // デスクトップ扱い
|
|
223
|
+
media: query,
|
|
224
|
+
onchange: null,
|
|
225
|
+
addEventListener: vi.fn(),
|
|
226
|
+
removeEventListener: vi.fn(),
|
|
227
|
+
addListener: vi.fn(),
|
|
228
|
+
removeListener: vi.fn(),
|
|
229
|
+
dispatchEvent: vi.fn(),
|
|
230
|
+
})),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
vi.stubGlobal(
|
|
234
|
+
"ResizeObserver",
|
|
235
|
+
class {
|
|
236
|
+
constructor(callback: (entries: { target: Element; contentRect: { width: number } }[]) => void) {
|
|
237
|
+
resizeCallback = callback
|
|
238
|
+
}
|
|
239
|
+
observe(el: Element) {
|
|
240
|
+
// 460px 未満の狭小コンテナ幅を即時シミュレート
|
|
241
|
+
resizeCallback([{ target: el, contentRect: { width: 400 } }])
|
|
242
|
+
}
|
|
243
|
+
unobserve() {}
|
|
244
|
+
disconnect() {}
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("利用可能幅が狭小化した場合は 2 カラムではなく 1 カラム表示へフォールバックする", () => {
|
|
250
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
251
|
+
// 狭小幅時は PanelGroup がアンマウントされ、一覧のみが描画される
|
|
252
|
+
expect(container.querySelector('[data-panel-group-direction="horizontal"]')).toBeNull()
|
|
253
|
+
expect(container.querySelector('[data-resize-handle-id="daily-report-list-handle"]')).toBeNull()
|
|
254
|
+
expect(screen.getByTestId("daily-report-list")).toBeInTheDocument()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe("モバイル表示 (isMobileLayout === true)", () => {
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
vi.stubGlobal(
|
|
261
|
+
"matchMedia",
|
|
262
|
+
vi.fn((query: string) => ({
|
|
263
|
+
matches: true,
|
|
264
|
+
media: query,
|
|
265
|
+
onchange: null,
|
|
266
|
+
addEventListener: vi.fn(),
|
|
267
|
+
removeEventListener: vi.fn(),
|
|
268
|
+
addListener: vi.fn(),
|
|
269
|
+
removeListener: vi.fn(),
|
|
270
|
+
dispatchEvent: vi.fn(),
|
|
271
|
+
})),
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("PanelGroup および PanelResizeHandle は描画されず、一覧が直接描画される", () => {
|
|
276
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
277
|
+
expect(container.querySelector('[data-panel-group-direction="horizontal"]')).toBeNull()
|
|
278
|
+
expect(container.querySelector('[data-resize-handle-id="daily-report-list-handle"]')).toBeNull()
|
|
279
|
+
expect(screen.getByTestId("daily-report-list")).toBeInTheDocument()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("初期表示時にモバイルオーバーレイが自動展開されないこと", () => {
|
|
283
|
+
const { container } = renderWithConfig(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
284
|
+
expect(container.querySelector('aside[role="dialog"]')).toBeNull()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("アイテムタップでモバイルオーバーレイが展開され、--aqdr-overlay-right のインラインスタイルが適用されること", () => {
|
|
288
|
+
const TestComponent = () => {
|
|
289
|
+
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
290
|
+
return <DailyReportList dailyReportItems={[item]} selectedReportHubId={selectedId} onSelectItem={setSelectedId} />
|
|
291
|
+
}
|
|
292
|
+
const { container } = renderWithConfig(<TestComponent />)
|
|
293
|
+
expect(container.querySelector('[data-daily-report-mobile-overlay="true"]')).toBeNull()
|
|
294
|
+
|
|
295
|
+
const card = container.querySelector(`[data-daily-report-button="${REPORT_HUB_ID}"]`)
|
|
296
|
+
expect(card).not.toBeNull()
|
|
297
|
+
if (!card) throw new Error("card not found")
|
|
298
|
+
fireEvent.keyDown(card, { key: "Enter" })
|
|
299
|
+
|
|
300
|
+
const overlayRoot = container.querySelector('[data-daily-report-mobile-overlay="true"]')
|
|
301
|
+
expect(overlayRoot).not.toBeNull()
|
|
302
|
+
expect(overlayRoot).toHaveStyle({ right: "var(--aqdr-overlay-right, 0px)" })
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `ScrollPane` の `pointerDragInputs` で**イベントのポインタ種別ごとに**制御する
|
|
11
11
|
* (`CONTENT_DRAG_POINTER_INPUTS` 参照)。
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { useSyncExternalStore } from "react"
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Viewport width at which the daily-report views switch to the mobile layout.
|
|
@@ -21,36 +21,43 @@ import { useEffect, useState } from "react"
|
|
|
21
21
|
*/
|
|
22
22
|
export const MOBILE_LAYOUT_MEDIA_QUERY = "(max-width: 639px)"
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Subscribes to media query changes.
|
|
26
|
+
* メディアクエリの変更通知を購読する処理。
|
|
27
|
+
*
|
|
28
|
+
* @param callback - Function invoked on media query change. 変更時に呼び出されるコールバック。
|
|
29
|
+
* @returns Cleanup function to remove event listener. イベントリスナーを解除するクリーンアップ関数。
|
|
30
|
+
*/
|
|
31
|
+
const subscribe = (callback: () => void): (() => void) => {
|
|
32
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {}
|
|
33
|
+
const query = window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY)
|
|
34
|
+
query.addEventListener("change", callback)
|
|
35
|
+
return () => query.removeEventListener("change", callback)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the current matching state of the media query on the client.
|
|
40
|
+
* クライアント環境における現在のメディアクエリ適合状態を取得する処理。
|
|
41
|
+
*
|
|
42
|
+
* @returns True if current viewport matches mobile layout. モバイル幅に適合している場合は true。
|
|
43
|
+
*/
|
|
44
|
+
const getSnapshot = (): boolean => (typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY).matches : false)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the fallback state for server-side rendering.
|
|
48
|
+
* サーバーサイドレンダリング時のフォールバック状態を取得する処理。
|
|
49
|
+
*
|
|
50
|
+
* @returns False on server environment. サーバー環境では false。
|
|
51
|
+
*/
|
|
52
|
+
const getServerSnapshot = (): boolean => false
|
|
53
|
+
|
|
24
54
|
/**
|
|
25
55
|
* Reports whether the viewport is narrow enough for the mobile layout.
|
|
26
56
|
* ビューポートがモバイルレイアウトの幅かどうかを返す処理。
|
|
27
57
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
58
|
+
* Synchronizes with window.matchMedia via useSyncExternalStore without hydration delay.
|
|
59
|
+
* useSyncExternalStore を介してハイドレーション遅延なく window.matchMedia と同期する。
|
|
30
60
|
*
|
|
31
61
|
* @returns True on the mobile layout. モバイルレイアウトなら true。
|
|
32
62
|
*/
|
|
33
|
-
export const useIsMobileLayout = (): boolean =>
|
|
34
|
-
const [isMobileLayout, setIsMobileLayout] = useState(false)
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Tracks the viewport width.
|
|
38
|
-
* ビューポート幅を追跡する副作用。
|
|
39
|
-
*
|
|
40
|
-
* 目的: 画面回転・ウィンドウリサイズに追随する。
|
|
41
|
-
* 依存関係: [] - 監視対象は固定の定数なので購読は 1 度だけ張る。
|
|
42
|
-
* クリーンアップ: リスナーを解除する。
|
|
43
|
-
*/
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
|
|
46
|
-
|
|
47
|
-
const query = window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY)
|
|
48
|
-
const sync = () => setIsMobileLayout(query.matches)
|
|
49
|
-
|
|
50
|
-
sync()
|
|
51
|
-
query.addEventListener("change", sync)
|
|
52
|
-
return () => query.removeEventListener("change", sync)
|
|
53
|
-
}, [])
|
|
54
|
-
|
|
55
|
-
return isMobileLayout
|
|
56
|
-
}
|
|
63
|
+
export const useIsMobileLayout = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|