import * as React from "react"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ScrollArea, ScrollBar, } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { Flex, PageContainer, ResponsiveGrid } from "@godxjp/ui/layout"; /** * ScrollArea — a native scrolling box. ALWAYS give it an explicit height/max-height * (vertical) or width (horizontal), or nothing ever overflows and no scrollbar appears. * `orientation` decides which axes may scroll. By default the PLATFORM draws the bar, * styled from the --scroll-area-* tokens — and on macOS/iPadOS with the system default * "Show scroll bars: when scrolling" that bar is an OVERLAY: it exists only while the * reader is already scrolling, so a wide area does not look scrollable at rest (gh#798). * `scrollbar="always"` forces a classic bar that occupies layout, from the same tokens. * Composed only from real @godxjp/ui components. */ const entries = Array.from( { length: 18 }, (_, i) => `仕訳 #2024-${String(312 - i).padStart(4, "0")}`, ); const shortEntries = entries.slice(0, 3); /** * The live-stream screen. A deterministic clock: the demo never reads `Date.now()`, so the * frame is stable, and every timestamp still goes through `Intl.DateTimeFormat` (IANA tz, 24h). */ const STREAM_EPOCH = Date.UTC(2026, 2, 3, 0, 30, 0); const STREAM_STEP_MS = 45_000; const timeFormatter = new Intl.DateTimeFormat("ja-JP", { hour: "2-digit", minute: "2-digit", timeZone: "Asia/Tokyo", }); type StreamPost = { id: number; author: string; body: string }; const AUTHORS = ["佐藤 千尋", "山本 直樹", "Nguyễn Minh", "経理ボット"]; const makePost = (id: number): StreamPost => ({ id, author: AUTHORS[((id % AUTHORS.length) + AUTHORS.length) % AUTHORS.length], body: id % 4 === 0 ? `月次締めのバッチが完了しました(#${String(1000 + id)})。` : `伝票 #2026-${String(1000 + id)} を確認しました。`, }); const INITIAL_POSTS = Array.from({ length: 24 }, (_, index) => makePost(index + 1)); const columns = [ "勘定科目", "借方", "貸方", "摘要", "部門", "プロジェクト", "取引先", "登録者", "承認者", ]; export default function Demo() { const [posts, setPosts] = React.useState(INITIAL_POSTS); const [oldestLoaded, setOldestLoaded] = React.useState(1); const [newestSeen, setNewestSeen] = React.useState(INITIAL_POSTS.length); const [anchored, setAnchored] = React.useState(true); const streamViewport = React.useRef(null); const receiveNewPost = () => { const id = newestSeen + 1; setNewestSeen(id); setPosts((current) => [...current, makePost(id)]); }; const loadOlderPage = () => { const from = oldestLoaded - 10; setOldestLoaded(from); setPosts((current) => [ ...Array.from({ length: 10 }, (_, index) => makePost(from + index)), ...current, ]); }; // The keyboard route back to the newest item. Anchoring must never be the ONLY way there, so the // affordance is a real Button, not a scroll gesture. const jumpToNewest = () => { const viewport = streamViewport.current; if (viewport) viewport.scrollTop = viewport.scrollHeight; }; return ( ライブ配信ログ(anchor="bottom") チャットや監査ログのように増え続けるストリーム。最下部にいる間だけ新着に追従し、履歴を読むために少しでも上へスクロールしたら二度と勝手に動きません(WCAG 3.2.5)。「過去を読み込む」で上に挿入しても、いま読んでいる行は動きません。 {posts.map((post) => ( {post.author} {timeFormatter.format( new Date(STREAM_EPOCH + post.id * STREAM_STEP_MS), )} {post.body} ))} {/* The stream itself carries no aria-live — a live region on a scroll container re-announces on every reflow. The follow state belongs in its own small region. */} {anchored ? "最新に追従中" : "履歴を閲覧中(追従は停止)"} anchorOffset(追従とみなす帯の広さ) 既定は --scroll-area-anchor-offset(3rem)。0 を渡すと「完全に最下部」でなければ 追従しません。行の高さが大きいサービスは theme 側でこのトークンを上げます。 {entries.map((e) => ( {e} ))} scrollbar="always" — 「スクロールバーが見えない」の答え 既定の auto はプラットフォームに任せます。macOS / iPadOS のシステム既定「スクロール中にのみ表示」では、それは オーバーレイのバー です。つまり、すでにスクロールしている間しか存在しません。止まっている状態では、 この面がスクロールできることが一切わかりません(gh#798)。 下の2つは中身も高さも同一で、違うのは scrollbar だけです。 scrollbar="auto"(既定)— macOS では静止時にバーが出ない {entries.map((e) => (
{e}
))}
scrollbar="always" — レイアウトを占める古典的なバー {entries.map((e) => (
{e}
))}
固定高さのリスト(縦スクロール) ラッパーに h-56 を指定すると、その高さがスクロール領域のビューポートになります。 {/* THE HEIGHT GOES ON THE SCROLLAREA, not on an ancestor. It was on the outer Card, and neither CardContent nor ScrollArea inherits a height from it — so the ScrollArea grew to its content, `scrollHeight === clientHeight`, and the demo for a scrolling component did not scroll. Measured on the published site before this change. The page header says this rule outright; the example broke it. */} {entries.map((e) => (
{e}
))}
ScrollArea orientation="horizontal" {columns.map((column) => ( {column} ))} ScrollBar は何も描画しない(非推奨) v22 まではこの要素を置くことが軸を開く方法だったが、スクロールがブラウザ本来のものに なったので ScrollBar は何も描画しない。下のカードには ScrollBar が置いてあるが、横に動くのは orientation="both" のおかげで、ScrollBar のおかげではない。残っている <ScrollBar> は消して、軸は orientation で宣言する。 {columns.map((column) => ( {column} ))} 両方向スクロール(orientation="both") 縦にも横にも溢れうる面は orientation="both"。以前は縦の ScrollArea に ScrollBar を足して横軸を開けていたが、スクロールはブラウザ本来のものになったので、 軸を決めるのは orientation だけになった。 {columns.map((column) => ( {column} ))} スクロール領域に名前を付ける(label) 溢れている間、ビューポートは Tab で止まる(キーボードだけで中身を送るために必要)。その停止点は名前を持つ。既定は 「スクロール可能な領域」の翻訳で、画面がその領域を呼べるときは label で「監査ログ」と名乗る。ロールはランドマークの region ではなく group にしてある。同じ名前のランドマークが一画面に並ぶと axe landmark-unique に触れるため。溢れていなければ停止点も role も名前も出ない(gh#821)。 {entries.map((e) => (
{e}
))}
内容が収まる場合(バー非表示) 中身が高さに収まるときはスクロールバーは表示されません。 {/* Same h-56 as above, DELIBERATELY not overflowing: `shortEntries` fits, so no bar appears. This is the control case — it is only meaningful next to one that does overflow, which is why the demo above had to be fixed first. */} {shortEntries.map((e) => (
{e}
))}
); }