import { useState } from "react"; import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Thumbnail, } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { AppShell, Flex, Masonry, PageContainer, Sidebar, type SidebarSectionProp, Topbar, type MasonryItemProp, } from "@godxjp/ui/layout"; import { Bot, Images, Inbox, MessageSquare, Settings } from "lucide-react"; import coverTerrain from "../assets/cover-terrain.svg"; import shotPortrait from "../assets/shot-portrait.svg"; /** * Masonry — Ant Design `Masonry` (6.0.0). A real inbox of mixed-height notes, not a tidy row of * equal cards: the tidy demo is the one that hides every defect this layout actually has. * * Every card on this page is an EDGE: * — a one-line note beside a twelve-line one, in the same run; * — an IMAGE tile whose height is declared so the columns do not jump when it decodes; * — a CJK tile of real Japanese prose at --line-height-body 1.7, which is taller per character * than the Latin beside it; * — the single-column case, which is what every narrow viewport collapses to; * — a tile PINNED to the first column, which is where reading order and visual order part company; * — a responsive `columns` map, driven live by the viewport. * * Composed only from real @godxjp/ui components. */ const sections: SidebarSectionProp[] = [ { label: "受信", items: [ { id: "inbox", label: "メモ", icon: Inbox }, { id: "gallery", label: "素材", icon: Images }, { id: "chat", label: "やりとり", icon: MessageSquare }, { id: "agents", label: "エージェント", icon: Bot }, ], }, { label: "管理", items: [{ id: "settings", label: "設定", icon: Settings }] }, ]; /** A one-line note. The shortest thing this layout ever has to place. */ function ShortNote({ title, body }: { title: string; body: string }) { return ( {title} {body} ); } const LONG_BODY = [ "The rule the team keeps re-deriving: a masonry has two orders and they do not agree.", "DOM order is the order of `items`, always, because every tile is absolutely positioned and", "nothing reorders the markup. Visual order is the packing: tile four lands under whichever of", "the first three columns is shortest, so it can paint ABOVE a tile that precedes it in the", "source. That is the form, and the consequence is one line long: order the items by importance,", "never by height.", "", "It matters most for the keyboard. Tab visits this card before the short one beside it even", "when this card's top edge sits lower on the screen, and a screen reader reads them in the same", "sequence. The alternative implementation, CSS `column-count`, is worse on exactly that axis:", "it fills the first column to the bottom before starting the second, so in a thirty-tile feed", "the second tile in the DOM paints at the bottom-left corner of the viewport.", ].join(" "); const JA_BODY = "日本語の本文は 1 文字あたりの高さが欧文より大きい。全角の仮名と漢字が em ボックスを埋めるので、" + "--line-height-body の 1.7 がそのまま行の高さに効く。同じ文字数でも、欧文の隣に置くと列の高さが" + "先に伸びるのはそのためで、段組みの詰め方はこの差をそのまま受け取る。短い注記と長い注記を同じ列に" + "並べたときに、どちらが先に読まれるかは見た目ではなく items の順序が決める。ここを取り違えると、" + "画面では上にあるカードが、読み上げでは最後に来る。"; export default function MasonryDoc() { const [columns, setColumns] = useState(3); const [placed, setPlaced] = useState([]); const feed: MasonryItemProp[] = [ { key: "one-line", children: , }, { key: "image", // The declared height is what keeps the columns still while the image decodes: without it // every tile below this one jumps the moment the bytes land. height: 268, children: ( cover-terrain.svg · 480×270 ), }, { key: "long", children: ( Reading order vs visual order Twelve lines, beside a one-line card. {LONG_BODY} ), }, { key: "ja", children: ( 行の高さは言語で変わる {JA_BODY} ), }, { key: "badge", children: ( 下書き 保留 期限切れ ), }, { key: "pinned", // PINNED. It stays fifth in the reading order whatever the packing does with it. column: 0, children: ( column: 0 最初の列に固定。読み上げの順番は五番目のまま動かない。 ), }, { key: "portrait", height: 232, children: ( ), }, { key: "two-line", children: ( ), }, { key: "shortest", children: , }, ]; return ( {}} product={{ name: "CoreDesk", role: "メモ", color: "hsl(var(--primary))" }} /> } topbar={} > {/* THE REAL SCREEN. Responsive columns, a mixed feed, and the layout report. */} columns · {columns} · fresh {[1, 2, 3, 4].map((count) => ( ))} {placed.length === 0 ? "onLayoutChange 待ち" : `onLayoutChange · 列ごとの枚数 ${placed.join(" / ")}`} {/* `fresh` is ON here, and the reason is a measurement, not a preference: at 390px the long English card re-wraps by one line AFTER the pass that measured it, and with the container observer alone (antd's default) nothing notices — the container reported 2472.2px while its own content ended at 2489.7px. `fresh` observes each tile, so the two agree. A feed with images and a live column count is exactly what it is for. */} { const perColumn = new Array(columns).fill(0); for (const entry of layout) perColumn[entry.column] += 1; setPlaced(perColumn); }} /> {/* THE NARROW CASE, on purpose and at full width: one column is what every phone gets, and it is the arrangement a three-column demo never shows. */} 単一列 · columns={1} 狭い画面はここに収束する。段組みが消えても読み上げの順番は同じ。DOM の順序は items の順序のまま。 {/* THE VIEWPORT MAP. `columns` takes the same step names Flex direction does. */} columns={"{ base: 1, sm: 2, md: 3, xl: 4 }"} sm 40rem · md 48rem · lg 64rem · xl 80rem。Ant Design の `xs` はここでは `base`、`xxl` はない。 {/* NO GAP AT ALL — Ant Design's own default, which is what a token-less masonry looks like. Worth seeing once so the `gap` step is a decision rather than a habit. */} gap 省略 · Ant Design の既定は 0 ); }