import { useState } from "react"; import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, useSheetResponsiveMode, } from "@godxjp/ui/feedback"; import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { FormField, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@godxjp/ui/data-entry"; import { Flex, PageContainer } from "@godxjp/ui/layout"; import { SlidersHorizontal } from "lucide-react"; /** Every branch of SheetHeader's `tone`, in the order the vocabulary declares them. */ const headerTones = [ "default", "success", "warning", "destructive", "info", "muted", "neutral", ] as const; /** Order lines behind the non-modal sheet — they stay editable while it is open. */ const initialLines = [ { id: "coffee", name: "ブレンドコーヒー", unitPrice: 480, quantity: 2 }, { id: "sandwich", name: "ミックスサンド", unitPrice: 650, quantity: 1 }, { id: "cake", name: "チーズケーキ", unitPrice: 520, quantity: 1 }, ]; const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }); /** * Sheet · side-panel drawer (Radix Dialog). Slides in from an edge. Compose * Sheet > SheetTrigger (asChild) > SheetContent(side) > SheetHeader > * SheetTitle (a11y required) > scrollable body > SheetFooter. * * SheetFooter is a PINNED action bar: it sticks to the bottom * via mt-auto, draws a full-bleed top border, and right-aligns its actions with * the PRIMARY button rightmost. A destructive / clear / reset action goes * far-LEFT · give THAT button className="me-auto". Never stack footer buttons * full-width or center them. See cardinal rule #41 "Drawer & dialog footer layout". * * Fields are real FormField + Input + Select primitives · never faked with * styled /
. Composed only from real @godxjp/ui components. */ export default function Demo() { // Card 1 · pre-opened so the pinned footer + clear-left/apply-right layout is visible at rest. const [filterOpen, setFilterOpen] = useState(true); const [account, setAccount] = useState("all"); const [status, setStatus] = useState("all"); const [source, setSource] = useState("all"); const [minAmount, setMinAmount] = useState(""); const [maxAmount, setMaxAmount] = useState(""); // Card 2 · quick-edit form: Cancel + Save, both right-aligned (primary rightmost). const [editOpen, setEditOpen] = useState(false); const [companyName, setCompanyName] = useState("株式会社サンプル"); const [registration, setRegistration] = useState("T1234567890123"); // Card 3 · edit with a destructive action: 削除 far-left (mr-auto), Cancel + Save right. const [recordOpen, setRecordOpen] = useState(false); const [memo, setMemo] = useState("4月度 経費精算"); // Card 4 · responsive detail panel. The SAME hook SheetContent uses, so the label below always // reports the presentation the panel will actually render — resize the frame to 390 to see it flip. const [detailOpen, setDetailOpen] = useState(false); const presentation = useSheetResponsiveMode("auto"); // Card 5 · SheetHeader tone band. One panel, re-opened per branch, so only one overlay is ever // mounted and the focus trap stays honest. const [toneOpen, setToneOpen] = useState(false); const [headerTone, setHeaderTone] = useState<(typeof headerTones)[number]>("default"); // Card 6 · non-modal sheet: the order lines behind it stay editable while it is open. const [summaryOpen, setSummaryOpen] = useState(false); const [lines, setLines] = useState(initialLines); const total = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0); const changeQuantity = (id: string, delta: number) => { setLines((current) => current.map((line) => line.id === id ? { ...line, quantity: Math.max(0, line.quantity + delta) } : line, ), ); }; return ( Advanced filter (pre-opened, side=right) The defining footer pattern: actions are end-aligned, the primary 適用 is last, and the clear/reset action クリア moves to the leading edge via className="me-auto". The footer is pinned to the bottom with a full-bleed top border; the body scrolls between a fixed header and footer (header inset == footer inset). Fields are real FormField + Select + Input. {/* data-axe-open: check:frame-axe presses this before its overlay scan. */} 3 条件} /> setMinAmount(e.target.value)} /> setMaxAmount(e.target.value)} /> Quick-edit form (Cancel + Save, right-aligned) No destructive action, so both buttons sit on the right: キャンセル (secondary) to the left of 保存 (primary). Close programmatically on submit success. 取引先編集 · 株式会社サンプル 取引先の基本情報を編集します。 setCompanyName(e.target.value)} /> setRegistration(e.target.value)} /> Edit with destructive action (削除 far-left) A destructive / clear / reset action goes to the leading edge via className="me-auto" with variant="destructive", while キャンセル + 保存 stay grouped at the end. This is the same me-auto slot the filter's クリア uses. 仕訳編集 · #2024-0412 摘要を編集します。削除は元に戻せません。 setMemo(e.target.value)} /> Responsive detail panel (responsive="auto" · desktop side / mobile bottom) ONE Sheet covers both viewports: above{" "} --sheet-responsive-breakpoint-width (48rem / 768px) it is the desktop side panel, at or below it the mobile bottom sheet capped by{" "} --sheet-bottom-max-height. The breakpoint comes from a theme token. Never re-derive it with a page-local useMediaQuery. Focus trap, Escape and focus restoration are identical in both presentations because it is the same Radix Dialog. 現在の表示: {presentation === "bottom" ? "bottom sheet (mobile)" : "side panel"} 支払済み} /> レコードのdetail surfaceはdesktopでは右side panel、mobileではbottom sheetとして表示されます。 幅は side panel のときだけ適用され、bottom sheet では全幅になります(widthはbottomでは無視されます)。 SheetHeader tone band (every branch) SheetHeader carries the same header contract as DialogHeader: title, subtitle, an extra slot and tone. tone tints only the band and leaves the text at the foreground colour. default paints no band; muted and neutral share the same quiet band on purpose, so a caller can name intent without adding colour. Close the pre-opened filter panel above with Escape, then pick a branch. {headerTones.map((tone) => ( ))} {headerTone}} /> 帯の色だけが tone に追従し、見出しと本文の文字色は変わりません。帯は本文と同じ左右インセットで全幅に広がります。 extra スロットは帯の右端に固定され、閉じるボタンとは重なりません。 Non-modal sheet: the list behind stays editable modal={false} on Sheet renders a non-modal panel, a pattern WAI-ARIA APG allows. The page behind keeps working: no scrim, no scroll lock, nothing hidden from assistive tech, and a press outside does not close the panel. Open the order summary (side="left", so the quantity controls stay uncovered), then change a quantity below. The total inside the panel follows. Focus moves into the panel on open, Tab can leave it, Escape closes it while focus is inside, and focus returns to the trigger. {lines.map((line) => ( {line.name} {line.quantity} ))} 合計 {yen.format(total)} ); }