مربع حوار
مربع حوار يحجب الصفحة، يتمرّر متنه بين رأس وتذييل ثابتين، بينما تبقى الصفحة خلفه ساكنة.
مربع حوار
client - الحجم لم يُقَس بعدمربع حوار يحجب الصفحة، يتمرّر متنه بين رأس وتذييل ثابتين، بينما تبقى الصفحة خلفه ساكنة.
جرّبه
الشيفرة
import { Button } from 'topclassui/button'
import {
<DialogRoot>
<DialogTrigger render={<Button>Publish</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Publish changes</DialogTitle>
<DialogDescription>Your changes go live right away.</DialogDescription>
</DialogHeader>
<DialogBody>11 files changed since the last publish.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button>Publish now</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>غلّف الأجزاء بنموذج يحمل الصنف contents، فيُرسَل إجراء الخادم من التذييل دون أي معالج في المتصفح.
كل الحالات، بدون تمرير الفأرة
5 حالاتأو استورده من الحزمة
import { DialogRoot, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle, DialogTrigger } from 'topclassui/dialog'ثبّت مرة واحدة:npm i topclassui@beta zod
أمثلة
- لمهمة أو سؤال تنتظره الصفحة: نموذج، تأكيد، نص للموافقة عليه.
- تسعة أجزاء بأسماء مسطّحة ولا يوجد
Dialogوحده:DialogRootوDialogTriggerوDialogPopupوDialogHeaderوDialogTitleوDialogDescriptionوDialogBodyوDialogFooterوDialogClose. DialogBodyيتمرّر ويبقى الرأس والتذييل في مكانهما. على شاشة قصيرة جدًا يتمرّر المربع كله بدلًا من ذلك.sizeعلىDialogPopupهو أقصى عرض يبلغه: 320 أو 360 أو 400 أو 512 أو 640 بكسل.customلا يضبط عرضًا.- للتأكيد، ضع
disablePointerDismissalعلىDialogRoot: الضغط خارجه لم يعد يغلقه. - من صفحة خادم مرّر نصوصًا وقيمًا منطقية وعناصر وإجراء خادم.
onOpenChangeوأي دالة أخرى تحتاج ملفًا فيه'use client'.
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** The docs sample without its form: a header with the built-in x, a body and a footer.
* Each label sits inside the Button passed to render, so a server render keeps it whole. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button>Publish</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Publish changes</DialogTitle>
<DialogDescription>Your changes go live right away.</DialogDescription>
</DialogHeader>
<DialogBody>11 files changed since the last publish.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button>Publish now</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** The drawn confirm: a press outside does not close it, so the answer is one of the two
* buttons, the x or Escape. The consequence is the description, so a screen reader reads it
* with the title when the dialog opens. No AlertDialog exists; this is a Dialog with one prop. */
export default function Demo() {
return (
<DialogRoot disablePointerDismissal>
<DialogTrigger render={<Button variant="outline" tone="danger">Delete project</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Delete this project?</DialogTitle>
</DialogHeader>
<DialogBody>
<DialogDescription>This removes the project and every file in it. This cannot be undone.</DialogDescription>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" size="sm">Cancel</Button>} />
<DialogClose render={<Button tone="danger" size="sm">Delete</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** One form with display contents wraps the three bars, so the footer's submit button belongs
* to it and a Server Action needs no client handler. autoFocus puts the first focus in the field. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Rename</Button>} />
<DialogPopup>
<form className="contents">
<DialogHeader>
<DialogTitle>Rename this project</DialogTitle>
</DialogHeader>
<DialogBody className="grid gap-1">
<label htmlFor="demo-project-name" className="font-medium text-body">Name</label>
<Input id="demo-project-name" name="name" defaultValue="Website redesign" required fullWidth autoFocus />
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogPopup>
</DialogRoot>
)
}
'use client'
import { useActionState, useState } from 'react'
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
interface Attempt { name: string, error: string | null }
/** The one field of the form, as text: a file field would hand over a File. */
function nameIn(data: FormData): string {
const name = data.get('name')
return typeof name === 'string' ? name : ''
}
/** Stands in for a Server Action: it answers with an error message, or with none. */
async function renameProject(data: FormData): Promise<string | null> {
await new Promise((resolve) => { setTimeout(resolve, 100) })
return nameIn(data).trim().toLowerCase() === 'untitled' ? 'That name is taken.' : null
}
/** The form holds its own attempt, so it starts clean each time the dialog opens. React
* empties a form after every action: the name comes back through the state, with the error. */
function RenameForm({ onSaved }: { onSaved: () => void }) {
const [attempt, save, saving] = useActionState(async (_previous: Attempt, data: FormData): Promise<Attempt> => {
const error = await renameProject(data)
if (error === null) onSaved()
return { name: nameIn(data), error }
}, { name: 'Website redesign', error: null })
return (
<form action={save} className="contents">
<DialogHeader>
<DialogTitle>Rename this project</DialogTitle>
</DialogHeader>
<DialogBody className="grid gap-1">
<label htmlFor="demo-rename" className="font-medium text-body">Name</label>
<Input
id="demo-rename"
name="name"
defaultValue={attempt.name}
invalid={attempt.error !== null}
aria-describedby={attempt.error === null ? undefined : 'demo-rename-error'}
required
fullWidth
autoFocus
/>
{attempt.error === null ? null : <p id="demo-rename-error" className="text-danger-on-soft">{attempt.error}</p>}
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit" loading={saving}>Save</Button>
</DialogFooter>
</form>
)
}
/** Closes when the action succeeds and stays open when it fails. `open` and `onOpenChange`
* need a client file: a Server Component page cannot pass a function. Try the name Untitled. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<DialogRoot open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline">Rename and close</Button>} />
<DialogPopup>
<RenameForm onSaved={() => { setOpen(false) }} />
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
const CLAUSES = Array.from({ length: 40 }, (_, index) => index + 1)
/** A body taller than any screen: it scrolls inside the popup, and the header and the
* footer keep their places above and below it (R-10.5-1). */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Read the terms</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Terms of service</DialogTitle>
<DialogDescription>Forty clauses. Scroll to read them all.</DialogDescription>
</DialogHeader>
<DialogBody className="grid gap-3">
{CLAUSES.map((clause) => (
<p key={clause}>
Clause {clause}. You keep the rights to what you publish, and you let us store it and show it
to the people you share it with.
</p>
))}
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Decline</Button>} />
<DialogClose render={<Button>Accept</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
const TITLE = 'Move the quarterly report, its eleven attachments and every comment thread to the shared archive of the finance team'
const WORD = 'a'.repeat(300)
const FILE = 'quarterly-report-final-v2-reviewed-by-legal-and-finance-and-signed-off-by-the-board.pdf'
/** Hostile content: a title of many lines, a word of 300 characters and three long actions.
* The title wraps beside the x, the word breaks inside the body, and a long action wraps onto
* two lines. On a small phone the body scrolls, and is a tab stop for as long as it does. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Move to the archive</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>{TITLE}</DialogTitle>
<DialogDescription>{FILE}</DialogDescription>
</DialogHeader>
<DialogBody>{WORD}</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Keep it where it is</Button>} />
<DialogClose render={<Button variant="outline">Move a copy and keep the original</Button>} />
<DialogClose render={<Button>Move everything to the archive</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
import type { DialogPopupProps } from 'topclassui/dialog'
const WIDTHS: Array<[NonNullable<DialogPopupProps['size']>, string]> = [
['xs', '320 px'],
['sm', '360 px'],
['md', '400 px'],
['lg', '512 px'],
['xl', '640 px'],
]
/** The five widths, and custom, which sets none: its className gives the popup one.
* Each is the widest the popup grows; on a narrower screen every one fills the viewport. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
{WIDTHS.map(([size, width]) => (
<DialogRoot key={size}>
<DialogTrigger render={<Button variant="outline">{size}</Button>} />
<DialogPopup size={size}>
<DialogHeader>
<DialogTitle>Size {size}</DialogTitle>
</DialogHeader>
<DialogBody>This dialog grows to {width} and no wider.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
))}
<DialogRoot>
<DialogTrigger render={<Button variant="outline">custom</Button>} />
<DialogPopup size="custom" className="max-w-3xl">
<DialogHeader>
<DialogTitle>Size custom</DialogTitle>
</DialogHeader>
<DialogBody>This dialog takes its width from the className it was given.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
</div>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** closeButton={false} leaves the x out, so the footer must hold a DialogClose: a touch
* screen reader has no Escape key and needs a control that closes the dialog. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="soft">What is new</Button>} />
<DialogPopup size="sm">
<DialogHeader closeButton={false}>
<DialogTitle>Version 2.4</DialogTitle>
<DialogDescription>Released this week.</DialogDescription>
</DialogHeader>
<DialogBody>Tables can now pin their first column, and every menu takes a link.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Got it</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** modal={false}: no scrim, and the page behind stays in use. It scrolls, its buttons take a
* press, and a screen reader still reads it. Focus is not held: Tab past the last control,
* or a press anywhere outside, closes the dialog. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DialogRoot modal={false}>
<DialogTrigger render={<Button variant="outline">Find and replace</Button>} />
<DialogPopup size="xs">
<DialogHeader>
<DialogTitle>Find and replace</DialogTitle>
</DialogHeader>
<DialogBody>The page stays live behind this panel, so you can keep working in it.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** modal="trap-focus": no scrim, and the pointer keeps the page behind: it scrolls and its
* buttons take a press. The keyboard and a screen reader stay in the dialog: Tab goes round
* inside it and the page is hidden from them until it closes. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DialogRoot modal="trap-focus">
<DialogTrigger render={<Button variant="outline">Inspect</Button>} />
<DialogPopup size="xs">
<DialogHeader>
<DialogTitle>Inspector</DialogTitle>
</DialogHeader>
<DialogBody>Point at anything on the page behind. The keyboard stays in this panel.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { copy } from 'topclassui/icons/copy'
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** A tooltip inside a dialog. One Escape closes one thing: while the tooltip is open, by
* hover or by focus, the key closes it and the dialog stays; the next one closes the dialog. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Share</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Share this project</DialogTitle>
</DialogHeader>
<DialogBody className="flex items-center gap-2">
<Input aria-label="Link" defaultValue="https://example.com/p/website-redesign" readOnly fullWidth />
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="outline" aria-label="Copy link">
<Svg icon={copy} />
</Button>
}
/>
<TooltipPopup>Copy link</TooltipPopup>
</TooltipRoot>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** Arabic, inside TopClassProvider as the root layout would mount it. The popup is portaled
* into the body, so it takes its direction and its language from the provider: the x is on
* the left, the text is right-aligned and the actions end at the left, on an English page too. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<DialogRoot>
<DialogTrigger render={<Button>نشر</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>نشر التغييرات</DialogTitle>
<DialogDescription>تظهر تغييراتك للجميع فورًا.</DialogDescription>
</DialogHeader>
<DialogBody>تغيّر 11 ملفًا منذ آخر نشر.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">إلغاء</Button>} />
<DialogClose render={<Button>انشر الآن</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
</div>
</TopClassProvider>
)
}
الخصائص
DialogRoot
DialogBody
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
DialogClose
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
DialogDescription
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
DialogFooter
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
DialogHeader
DialogPopup
DialogTitle
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
DialogTrigger
لا خصائص خاصة به: يأخذ سمات العنصر الذي يعرضه.
لوحة المفاتيح وإمكانية الوصول
DialogTitleيسمّي مربع الحوار. إن لم يكن له عنوان، مرّرaria-labelإلىDialogPopup.- لا تُزل زر الإغلاق بـ
closeButton={false}إلا إذا وُضعDialogCloseفي التذييل: قارئ الشاشة على شاشة لمس لا يملك مفتاح Escape. - ما دام
DialogBodyيتمرّر فهو محطة Tab، فتصل لوحة المفاتيح إلى نص طويل لا عنصر تحكم فيه.
من اليمين إلى اليسار
ينتقل زر الإغلاق إلى اليسار، ويُحاذى النص إلى اليمين، وتنتهي الإجراءات عند اليسار. يأخذ المربع اتجاهه من أقرب TopClassProvider.
الخادم والمتصفح
مكوّن متصفح: يُعرض على الخادم ثم يُرطَّب في المتصفح.
الملفات: index.tsx, client.tsx.
على الشارة: client - الحجم لم يُقَس بعد.