import { forwardRef, useState } from "react"; import type { AnchorHTMLAttributes, CSSProperties } from "react"; import { Flex, PageContainer } from "@godxjp/ui/layout"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, StatCard, Badge, DataTable, Descriptions, ListRow, } from "@godxjp/ui/data-display"; import type { ColumnDef } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { SearchInput } from "@godxjp/ui/data-entry"; import { ResponsiveGrid } from "@godxjp/ui/layout"; import { Pagination, Toolbar, ToolbarGroup } from "@godxjp/ui/navigation"; import { Plus, Download, Filter, Search } from "lucide-react"; /** * Router Link stub passed to `linkComponent` · proves breadcrumb segments can render * through a router primitive (react-router / next/link) instead of a plain . * PageContainer forwards both `href` and `to`; a real Link reads `to`. */ /** * `--page-toolbar-background` · the toolbar band's GROUND, quiet (`transparent`) by default so the * band looks exactly as it did before the knob existed. A service normally declares these once in * its theme.css (`:root`, or a scoped `[data-tenant]`); they are scoped to one demo frame here so * the default band can sit beside the painted one. NEVER put `className="bg-card"` on the strip at * the call site: that is hand-laid page chrome, and it cannot be re-themed per tenant. * * The inset comes with it, and it is the band's ONLY breathing room: the band sits FLUSH against * the header above and the body below, because chrome is attached · a painted, ruled band adrift * between two 16px voids divides nothing. `--page-toolbar-pad-block` still stays `0` by default on * purpose · a transparent band is not a surface and has no inside to breathe, and under `fill` * every pixel of it is taken from the transcript. A band that is PAINTED does have an inside, so * the theme that paints it also sets its inset here rather than reaching for a `py-*` utility on * the strip. */ const TOOLBAR_CHROME_TOKENS = { "--page-toolbar-background": "hsl(var(--card))", "--page-toolbar-pad-block": "var(--space-2)", "--page-toolbar-divider": "1px solid hsl(var(--border))", } as CSSProperties; /** * `--page-header-min-block-size-chrome` · クロム帯の「高さ」。文書のヘッダーは中身なりの * 高さで正しい · 見出しは見出しの高さだからです。クロムは逆で、家具には「ものが中央に収まる帯」が * 要ります。既定は `auto`(=rule #44 の最も静かな状態)なので、このトークンを置くまで DOM も * 見え方も従来どおり · 文書ページはそもそもこの規則に一致しません。 * * 帯の高さ軸の持ち主は `--app-shell-bar-height` です(`--centered-shell-bar-height` も既にそれを * 読んでいます)。だからサービス側の一行はこれ · * `--page-header-min-block-size-chrome: var(--app-shell-bar-height)` · で、ページのクロム帯と * AppShell のトップバーが同じ帯に乗ります。min であって height ではないのは、`lg` のボタンや * 折り返した日本語のチャンネル名がはみ出さずに収まる必要があるためです。 */ const CHROME_BAND_TOKENS = { "--page-header-min-block-size-chrome": "var(--app-shell-bar-height)", } as CSSProperties; const RouterLink = forwardRef< HTMLAnchorElement, AnchorHTMLAttributes & { to?: string } >(function RouterLink({ to, href, children, ...props }, ref) { return ( {children} ); }); type JournalEntry = { id: string; date: string; desc: string; amount: string; status: "承認済" | "保留中"; }; const journalEntries: JournalEntry[] = [ { id: "JE-0041", date: "2026-05-31", desc: "売上計上", amount: "¥840,000", status: "承認済" }, { id: "JE-0040", date: "2026-05-30", desc: "仕入計上", amount: "¥320,000", status: "承認済" }, { id: "JE-0039", date: "2026-05-29", desc: "給与仕訳", amount: "¥1,200,000", status: "保留中" }, ]; const journalColumns: ColumnDef[] = [ { key: "id", header: "伝票番号", width: "w-28", render: (r) => {r.id} }, { key: "date", header: "日付", width: "w-32" }, { key: "desc", header: "摘要" }, { key: "amount", header: "金額", align: "right", render: (r) => {r.amount}, }, { key: "status", header: "ステータス", align: "right", render: (r) => {r.status}, }, ]; /** * PageContainer · mandatory page shell. * Covers: title/subtitle/status/extra/footer/breadcrumb/linkComponent + variant * default/narrow/flush/ghost + density compact/default/comfortable + PageContainer.Inset. * The embedded header is the CANONICAL DXS PageHeader — status/meta band included. * Each example is standalone (no AppShell) so the variant behaviour is visible in * isolation. Composed only from real @godxjp/ui components. */ export default function Demo() { const [activeVariant, setActiveVariant] = useState<"default" | "narrow" | "flush" | "ghost">( "default", ); const [activeDensity, setActiveDensity] = useState<"compact" | "default" | "comfortable">( "default", ); const [memberQuery, setMemberQuery] = useState(""); const variants = [ { key: "default", label: "default" }, { key: "narrow", label: "narrow" }, { key: "flush", label: "flush" }, { key: "ghost", label: "ghost" }, ] as const; /** The record pager that must sit AFTER the header's action cluster (`extra.end`). */ const [recordPage, setRecordPage] = useState(12); const densities = [ { key: "compact", label: "compact" }, { key: "default", label: "default" }, { key: "comfortable", label: "comfortable" }, ] as const; return ( {/* ── 1. Default variant · dashboard-style + linkComponent (router Link) ── */} } > {/* ── 1a. `extra` as { start, end } · 本人確認 → 操作 → ページャが最後 ── */} {/* `extra` はノード 1 つでも、start / end の 2 スロットでも受ける(Tabs.extra と同じ形)。 レコード画面の並びは「誰の何か → 操作の束 → ページャ」で、ページャは操作の“あと”に 来る。1 スロットしかなかったころは、この最後の一つを本文の中に第二のヘッダ帯として 置くしかなく、アプリがヘッダを 2 種類抱えることになっていた。両方とも同じ .ui-page-header-extra の直下に並ぶので、DOM 順がそのまま読み上げ順になる。 */} ), end: ( ), }} > ページャはヘッダの最後に置く extra にノードを 1 つ渡したときは start に入るので、既存のページは 1 文字も変わらない。end を足したときだけ、その後ろにもう 1 スロットが増える。名前は論理方向(start / end)なので dir="rtl" でそのまま入れ替わる。本文の中にもう一段ヘッダ帯を作る必要はない。 {/* ── 1b. Canonical page-header contract · status/meta band ── */} {/* PageContainer's embedded header IS the DXS PageHeader: breadcrumbs + title + subtitle + status/meta + actions + responsive overflow on ONE renderer. The `status` band shares the title line at --page-header-status-gap and wraps UNDER the long JA title at 390px — nothing clips, no consumer CSS. */} 有効 本番環境 } breadcrumb={[ { label: "ホーム", to: "/" }, { label: "組織", to: "/organizations" }, { label: "株式会社ファムジア" }, ]} breadcrumbLabel="組織プロファイルのパンくず" extra={ } > ステータスバンドの契約 status はタイトル行に載り、コンパクト幅ではタイトルの下に折り返します。バッジを h1 の隣に手置きしないでください。余白は --page-header-status-gap が所有します。 読み込み中は title/subtitle にスケルトンを、拒否 (403) / 不明 (404) / 障害 (5xx) はページごと ErrorSurface に置き換えます。 {/* ── 2. Narrow variant · settings form ── */} } stickyFooter footerReveal="onScroll" > 法人情報 登録されている法人の基本情報です。 株式会社サンプル 1234567890123 3月 {/* ── 3. Flush variant · full-bleed list ── */} } > {/* flush strips body padding so the DataTable runs full-bleed, edge to edge. */} r.id} /> {/* PageContainer.Inset · escape hatch that re-applies the page inset inside a full-bleed body, so a footnote keeps the header's left/right alignment. */} 承認済みの仕訳のみ表示しています。保留中の仕訳は別途承認が必要です。 {/* ── 4. Ghost variant + breadcrumb depth ── ghost は「クロムをどこまで静かにするか」を答える variant です。下余白は落とします (padding-bottom: 0 · これが本当の静かさ)が、罫線は打ち消しません · サービスが --page-header-divider を明示的に立てたなら ghost でも引かれます。帯(toolbar)の --page-toolbar-divider と同じ規則で、ページのクロム 3 本が一つの契約に揃います。 */} 取引中} > 基本情報 山田 太郎 東京都千代田区 ¥5,000,000 {/* ── 4b. density axis · 3 段すべてを静止状態で並べる ── density は variant と直交する間隔スケール(ui-density-*)で、既定値は "default"。 既定も明示値として扱えるため、3 つを同じ本文で並べると段差がそのまま読めます。 */} コンパクト 大量データの一覧 デフォルト 一般的な業務画面 コンフォータブル 詳細・設定画面 {/* ── 5. Variant + density switcher · interactive (both axes are orthogonal) ── */} {variants.map((v) => ( ))} {densities.map((d) => ( ))} } > ページ本文コンテンツ。variant はヘッダー余白・幅制御を、density は行間・間隔スケール(ui-density-*)を切り替えます。 {/* ── 6. fill · body height behaviour on a tall shell ── Each PageContainer sits in a fixed-height framed box that stands in for the viewport-tall app shell, so the two behaviours are visible side by side. */} {/* Default: top-packed. Short content does NOT stretch · the space below is just neutral page background, never a jarring void. */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
短いコンテンツでも下に空白の「穴」は出ません。
{/* fill: body grows to fill the shell; the composer footer pins to the bottom. */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
} stickyFooter > ボディがシェルの残り高さを占有し、フッター(コンポーザー)が下部に固定されます。
{/* ── 7. headerLayout · 390px でヘッダー extra をタイトル行に残す ── stack (既定) は 640px 未満で extra を subtitle の下の全幅行に落とす。 responsive-inline は --page-header-extra-measure (11rem) の測度で title 帯の横に残す。640px 以上では両者は同一。 幅は完全にトークン所有 — このページに px 指定もメディアクエリもありません。 */} } > 入力中の絞り込み語: {memberQuery === "" ? "(なし)" : memberQuery} } > extra の測度はトークン --page-header-extra-measure。タイトルと subtitle は残りの幅で折り返します。 {/* ── 8. measure · ヘッダーとボディを 1 つの測度で束ねる ── measure は variant(クローム)と直交する第 3 の軸。variant="narrow" は .ui-page-body しか絞らないため、ヘッダーのアクションはページ端に取り残される。 measure はヘッダーとボディの両方を同じトークン測度で絞るので、extra の終端が ボディ面の終端と一致する。既定 measure="default" は一切のルールに一致しない。 幅はすべて --page-measure-* が所有 — このページに px 指定はありません。 */} すべて既読にする } > 承認} /> 要対応} /> {/* 通知フィードの正準構成: ghost(静かなヘッダー律動)× medium(720px の共有測度)× responsive-inline(390px でもアクションをタイトル行に残す)。3 つは独立した prop。 */} すべて既読にする } > 承認} /> 招待} /> 要対応} /> {/* measure="narrow" — variant="narrow" と同じ 624px の面だが、ヘッダーも一緒に絞られる。 */} 招待を送る } > 測度はトークン --page-measure-narrow(42rem 外寸)。ページ余白は測度の内側にあるため、 可視面は 624px になります。390px ではどのトークンも効かず、面は流動的なままです。 {/* ── 9. toolbar · ヘッダーと本文の間に固定されるクロム帯 ── fill のとき本文(.ui-page-body)がスクロール領域になるので、帯はその SIBLING として スクロール領域の外に置かれる(flex: none)。呼び出し側で position: sticky を手置き しないための唯一の正規スロット。ページ余白と measure はヘッダー/本文と共有され、 帯の内側余白と下罫線はトークン所有(--page-toolbar-pad-block / --page-toolbar-divider)。 帯は上下の帯に密着します(.ui-page-container の段間はこの帯だけ打ち消される)· クロムは くっつくものであり、空白に浮く「3 つ目のセクション」ではないからです。呼吸する場所は 帯の内側(--page-toolbar-pad-block)だけ。 */} {/* fill · 帯は固定、トランスクリプトだけがその下でスクロールする(帯の下に潜り込まない)。 */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
接続中 } footer={ } stickyFooter > {[ "月次締めの締切は 8/31 です。", "経費精算の申請が 3 件あります。", "監査資料を共有しました。", "請求書 INV-2041 を承認しました。", "来週の定例は水曜 10:00 です。", ].map((message) => ( {message} ))}
{/* variant="flush" · 帯も本文と同じく全幅になるため、中身は PageContainer.Inset で ヘッダーと同じ左右余白に戻す。DataTable は全幅のまま。 */} } > r.id} />
{/* ── 10. headerScale · 先頭行は「文書の見出し」か、それとも「クロム」か ── headerScale="document"(既定)は従来どおり h1 = --page-title-font-size(20px)+ 720px 未満での縮小ステップ。headerScale="chrome" は先頭行が画面そのものの家具(チャットの チャンネル名、メールの件名、IDE のタブ)である場合で、h1 は本文と同じ型段 (--page-title-font-size-chrome = --heading-h3 / 14px)になり、720px 未満でも戻りません。 要素は h1 のまま — 見出しレベルは下げないので、スクリーンリーダーの見出し階層は不変。 同じ属性でページ上端の余白も落ちます · 既定の --space-page-active-y(24px · 720px 未満は 16px)は「文書の見出しの上の余白」であって、チャンネル名やタブは枠そのものだからです。 上端だけ --page-pad-block-start-chrome(既定 0)に切り替わり、下端は従来どおり stickyFooter の担当。設計がクロムに余白を求めるならテーマでこのトークンを一度だけ 調整します(呼び出し側の padding や負マージンは不可)。 同じ事実の帰結はあと 2 つ。subtitle は --page-subtitle-font-size-chrome (--font-size-2xs / 約 11px)に落ちます · クロムの下の一行はチャンネルの目的や メールの抜粋であって文書のリードではなく、既定の --font-size-base のままだと chrome のタイトルと同じ型段になって階層が立たないからです。行送りは --line-height-body のままなので、折り返す日本語の一行も従来の律動を保ちます。 そして extra は行が「行」である 640px 以上で帯の中央に揃います(align-self: center)· 先頭寄せは背の高い h1 の一行目にアクションを並べる文書の作法であって、 揃える見出しを持たない帯には合いません。実測で 8.65px のずれでした。 variant="ghost"(罫線と下余白を落とす)と組み合わせると静かなクロムヘッダーになります。 */} PDF } > --page-title-font-size(h1 · 20px) --page-title-font-size-compact(h2 · 18px) --space-page-active-y(24px)· chrome では --page-pad-block-start-chrome(0) --page-subtitle-font-size(14px)· chrome では --page-subtitle-font-size-chrome(約 11px) 行の先頭寄せ(flex-start)· chrome では帯の中央(align-self: center) 中身なり · chrome では --page-header-min-block-size-chrome(既定 auto · テーマで --app-shell-bar-height を読ませると 48px 固定) {/* チャット面の正準構成: chrome(型段)× ghost(クロムの重さ)× fill × toolbar × stickyFooter。チャンネル名は「今いる場所の名前」であって記事の見出しではない。 footer の上罫線も --page-footer-divider で消せます · 入力欄が自前で枠を持つ Card の 場合、全幅の罫線がその真上に重なって 2 本目の線になるためです。既定は「引く」のまま (フォームの保存/取消バーはこの線で本文と分かれる)。 */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
接続中} extra={ } toolbar={ } footer={ } stickyFooter > {[ "チャンネル名は本文と同じ 14px · 見出しではなく面のラベルとして読ませます。", "その下の一行はさらに 2 段小さい約 11px · 名前と説明の階層が立ちます。", "検索アイコンは帯の中央 · 文書の先頭寄せではありません。", "h1 のままなので、見出し階層は document と同じ。", "帯の高さが下がったぶんは、そのまま会話の高さになります。", "縮めたいだけの理由で通常の文書ページに使わないこと。", ].map((message) => ( {message} ))}
{/* ── 11. --page-toolbar-background · 帯の地の色もトークン ── 既定は transparent(=トークン導入前と完全に同じ見え方)。設計が「帯だけカード面に 乗せる」と言うときは、呼び出し側に className="bg-card" を書くのではなく、テーマで --page-toolbar-background を一度だけ宣言します。手置きのページクロムは禁止であり、 テナントごとの再テーマも効かなくなるためです。帯を塗るなら内側の余白も一緒に (--page-toolbar-pad-block)· 既定の 0 は「透明な帯には内側がない」ことに由来します。 */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
} > {[ "帯はページの地の色のまま · header と body の間に浮いて見えます。", "トークンを足すまで DOM もスタイルも従来どおりです。", ].map((message) => ( {message} ))}
{/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
接続中 } > {[ "帯がカード面に乗り、本文から分離して読めます。", "塗った帯には内側の余白が要るので pad-block も同じテーマで指定します。", "呼び出し側で背景色や上下の余白を足す必要はありません · どちらもトークンの仕事です。", ].map((message) => ( {message} ))}
{/* ── 12. --page-header-min-block-size-chrome · クロム帯に「高さ」を与える ── 既定(左)は auto · 帯は中身なりの高さになるので、副題や extra の有無で帯の縦中心が 動きます(Chromium 実測 · extra ありで 42.02px、なしで 40.38px)。動く中心には何も 揃えられません。テーマで一行(右)· --page-header-min-block-size-chrome を --app-shell-bar-height にすると帯は 48px に固定され、タイトル列も extra も帯の中心 (y=24)に載ります。min なので、より背の高い extra は今までどおり収まります。 */} {/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
{/* ui-audit-disable-next-line no-hand-rolled-surface — a bounded viewport so the page-level scroll behaviour is visible in a docs page — a frame around the demo, not product chrome */}
); }