نافذة منبثقة (Dialog)

استخدم النافذة المنبثقة لمهمة مركّزة تحتاج إلى مدخلات من المستخدم، مثل تعديل الملف الشخصي أو اختيار خطة، دون مغادرة الصفحة. أما لطلب تأكيد إجراء واحد أو إلغائه، مثل حذف مشروع، فاستخدم حوار التنبيه.

التثبيت

bash
npx @smicolon/fasla-ui@latest add dialog

قبل إضافة أي مكوّن، شغّل الأمر init مرة واحدة في مشروعك. التفاصيل في صفحة التثبيت.

مكوّن نافذة منبثقة مبني على مكتبة Base UI، لذلك يحتاج إلى الحزمة @base-ui/react، ويستخدم مكوّن زر لزرّي الإغلاق والرجوع. يثبّت الأمر add كليهما مع المكوّن، وكذلك أداة shadcn.

معاينة

اضغط الزر لفتح النافذة. ينتقل التركيز إلى أول حقل، ويبقى التنقل بمفتاح Tab داخلها. ويغلقها مفتاح Escape أو زر الإغلاق أو زر الإلغاء أو الضغط على الطبقة الخلفية.

الأحجام

تحدد الخاصية size على DialogContent عرض النافذة: القيمة lg هي الافتراضية وعرضها 512px، والقيمة xl عرضها 672px، والقيمة 2xl عرضها 1148px. وارتفاع كل الأحجام 634px مهما كان محتواها. وعلى الشاشة الأصغر تبقى النافذة على بُعد 16px من كل حافة. اختر الحجم حسب المحتوى: lg لنموذج قصير، و 2xl لجدول أو محرّر. اضغط زرًّا لفتح النافذة بحجمه.

الرأس

يضم المكوّن DialogHeader العنوان ووصفًا اختياريًا تحته بمسافة 4px، وتحتهما خط فاصل. ويبقى كل منهما في سطر واحد، وينتهي بنقاط ثلاث إذا طال، لذلك اجعلهما قصيرين. ويقع زر الإغلاق في نهاية سطر العنوان، وتخفيه الخاصية showCloseButton على DialogContent. ولخطوة ضمن سلسلة خطوات، مرّر الخاصية onBack إلى DialogHeader: يظهر زر الرجوع قبل العنوان. يشير سهمه إلى بداية السطر، فينعكس في الصفحات العربية.

الإجراءات

ضع الأزرار في المكوّن DialogFooter، وضع زر الإلغاء داخل DialogClose ليغلق النافذة. في الصف، وهو الترتيب الافتراضي، اكتب زر الإلغاء ثم الإجراء الرئيسي، فيقعان في نهاية الصف وبينهما 8px. وإذا كتبت قبلهما إجراءً ثالثًا، مثل «تخطٍّ»، فإنه ينتقل إلى بداية الصف. أما القيمة layout="stacked" فتجعل كل إجراء بعرض النافذة، واحدًا تحت الآخر، لذلك اكتب الإجراء الرئيسي أولًا. وللإجراء الذي لا يمكن التراجع عنه، أعطِ الزر الرئيسي variant="destructive".

المحتوى الطويل

ارتفاع النافذة 634px مهما كان محتواها. وحين يكون المحتوى أطول، يبقى الرأس والتذييل في مكانيهما، ويتمرّر ما بينهما فقط، فمرّر داخل النافذة أدناه لتكمل القراءة. وعلى الشاشة الأقصر تبقى النافذة على بُعد 16px من الأعلى والأسفل، وتتمرّر بالطريقة نفسها. وأي حقل تصل إليه بمفتاح Tab يظهر كاملًا بعيدًا عن الرأس والتذييل.

إتاحة الوصول (accessibility)

  • تحمل النافذة الدور dialog. يسمّيها عنوانها ويصفها وصفها، فيقرأ قارئ الشاشة الاثنين عند فتحها.
  • عند فتح النافذة ينتقل التركيز إلى أول عنصر تفاعلي فيها، مثل أول حقل. يبقى التنقل بمفتاح Tab وبمفتاحي Shift+Tab داخل النافذة، ويعود التركيز إلى العنصر المُشغِّل عند إغلاقها.
  • يغلق النافذة مفتاح Escape، وزر الإغلاق، وأي زر داخل DialogClose، والضغط على الطبقة الخلفية.
  • اسم زر الإغلاق «إغلاق» واسم زر الرجوع «رجوع» في الصفحات العربية، ويأخذان اسميهما الإنجليزيين في الصفحات الإنجليزية. وأيقونتاهما مخفيتان عن قارئ الشاشة.
  • ما دامت النافذة مفتوحة، لا يمكن الوصول إلى الصفحة خلفها بلوحة المفاتيح ولا بمؤشر الفأرة، ولا تتمرّر.
  • تظهر النافذة تدريجيًا وهي تكبر من 98%. وعند تفعيل تقليل الحركة تظهر تدريجيًا فقط.
  • تأخذ النافذة اتجاه الصفحة ولغتها وخطها، مع أنها تُعرض في نهاية العنصر body. في الصفحات العربية ينعكس ترتيب النافذة كله، وينعكس سهم الرجوع. ولا يحتاج الاتجاه إلى خاصية.

الخصائص (props)

Dialog

الخاصيةالنوعالافتراضيالوصف
openbooleanلا يوجديفتح النافذة أو يغلقها عند التحكّم بالقيمة.
defaultOpenbooleanfalseيفتح النافذة عند العرض الأول.
onOpenChange(open, details) => voidلا يوجدتُستدعى عند فتح النافذة أو إغلاقها.
modalboolean | "trap-focus"trueما دامت النافذة مفتوحة، يبقى التركيز داخلها ولا تتمرّر الصفحة. مرّر false ليخرج التركيز من النافذة وتتمرّر الصفحة، أو "trap-focus" ليبقى التركيز داخلها دون إيقاف التمرير.
childrenReactNodeلا يوجدالعنصر المُشغِّل ومحتوى النافذة.

DialogTrigger

الخاصيةالنوعالافتراضيالوصف
renderReactElementلا يوجديعرض عنصرك مكان الزر الافتراضي، مثل مكوّن زر.
disabledbooleanfalseيمنع هذا العنصر المُشغِّل من فتح النافذة.
childrenReactNodeلا يوجدنص العنصر المُشغِّل.
classNamestringلا يوجدأصناف CSS إضافية للعنصر المُشغِّل.

DialogContent

الخاصيةالنوعالافتراضيالوصف
size"lg" | "xl" | "2xl""lg"عرض النافذة: lg بعرض 512px، و xl بعرض 672px، و 2xl بعرض 1148px.
showCloseButtonbooleantrueيعرض زر الإغلاق في نهاية سطر العنوان.
closeLabelstring"Close"اسم زر الإغلاق لقارئ الشاشة. الافتراضي «إغلاق» في الصفحات العربية، و Close في الإنجليزية.
containerHTMLElementلا يوجدالعنصر الذي تُعرض فيه النافذة، والقيمة الافتراضية هي body.
childrenReactNodeلا يوجدالرأس، ثم المحتوى، ثم التذييل.
classNamestringلا يوجدأصناف CSS إضافية لصندوق النافذة.

DialogHeader

الخاصيةالنوعالافتراضيالوصف
onBack(event) => voidلا يوجديعرض زر الرجوع قبل العنوان، وتُستدعى عند الضغط عليه.
backLabelstring"Back"اسم زر الرجوع لقارئ الشاشة. الافتراضي «رجوع» في الصفحات العربية، و Back في الإنجليزية.
childrenReactNodeلا يوجدالعنوان والوصف.
classNamestringلا يوجدأصناف CSS إضافية للرأس.

DialogTitle

الخاصيةالنوعالافتراضيالوصف
childrenReactNodeلا يوجدالغرض من النافذة.
classNamestringلا يوجدأصناف CSS إضافية للعنوان.

DialogDescription

الخاصيةالنوعالافتراضيالوصف
childrenReactNodeلا يوجدسطر يوضّح ما يفعله المستخدم في النافذة.
classNamestringلا يوجدأصناف CSS إضافية للوصف.

DialogFooter

الخاصيةالنوعالافتراضيالوصف
layout"horizontal" | "stacked""horizontal"تضع القيمة horizontal الإجراءات في صف عند نهاية السطر. وتجعل القيمة stacked كل إجراء بعرض النافذة، واحدًا تحت الآخر.
showCloseButtonbooleanfalseيضيف زر إغلاق بالنمط Outline يغلق النافذة.
childrenReactNodeلا يوجدالإجراءات: أزرار، وزر الإلغاء داخل DialogClose.
classNamestringلا يوجدأصناف CSS إضافية للتذييل.

DialogClose

الخاصيةالنوعالافتراضيالوصف
renderReactElementلا يوجديعرض عنصرك مكان الزر الافتراضي، مثل مكوّن زر.
onClick(event) => voidلا يوجدتُستدعى قبل إغلاق النافذة.
disabledbooleanfalseيعطّل الزر.
childrenReactNodeلا يوجدنص الزر، مثل «إلغاء».
classNamestringلا يوجدأصناف CSS إضافية للزر.

الاستخدام

تعديل الملف الشخصي

tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function EditProfile() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>تعديل الملف الشخصي</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>تعديل الملف الشخصي</DialogTitle>
          <DialogDescription>
            حدّث اسمك وصورتك.
          </DialogDescription>
        </DialogHeader>
        <Input label="البريد الإلكتروني" type="email" placeholder="اكتب هنا" />
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>إلغاء</DialogClose>
          <Button type="submit">حفظ التغييرات</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

الرجوع خطوة

tsx
"use client"

import * as React from "react"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function NewProject() {
  const [step, setStep] = React.useState(1)

  return (
    <Dialog onOpenChange={(open) => open && setStep(1)}>
      <DialogTrigger render={<Button />}>مشروع جديد</DialogTrigger>
      <DialogContent>
        {step === 1 ? (
          <DialogHeader>
            <DialogTitle>سمِّ مشروعك</DialogTitle>
            <DialogDescription>الخطوة 1 من 3.</DialogDescription>
          </DialogHeader>
        ) : (
          <DialogHeader onBack={() => setStep(1)}>
            <DialogTitle>اختر مساحة العمل</DialogTitle>
            <DialogDescription>الخطوة 2 من 3.</DialogDescription>
          </DialogHeader>
        )}
        {step === 1 ? (
          <Input label="اسم المشروع" placeholder="مدار" />
        ) : (
          <Input label="اسم مساحة العمل" placeholder="فريق التصميم" />
        )}
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>إلغاء</DialogClose>
          {step === 1 ? (
            <Button onClick={() => setStep(2)}>التالي</Button>
          ) : (
            <DialogClose render={<Button />}>إنشاء المشروع</DialogClose>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

الإغلاق بعد الحفظ

tsx
"use client"

import * as React from "react"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function EditProfileAndSave({ onSave }: { onSave: (email: string) => Promise<void> }) {
  const [open, setOpen] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [email, setEmail] = React.useState("")

  return (
    <Dialog open={open} onOpenChange={(next) => !pending && setOpen(next)}>
      <DialogTrigger render={<Button variant="outline" />}>تعديل الملف الشخصي</DialogTrigger>
      <DialogContent>
        <form
          className="contents"
          onSubmit={async (event) => {
            event.preventDefault()
            setPending(true)
            try {
              await onSave(email)
              setOpen(false)
            } finally {
              setPending(false)
            }
          }}
        >
          <DialogHeader>
            <DialogTitle>تعديل الملف الشخصي</DialogTitle>
          </DialogHeader>
          <Input
            label="البريد الإلكتروني"
            type="email"
            value={email}
            onChange={(event) => setEmail(event.target.value)}
          />
          <DialogFooter>
            <DialogClose disabled={pending} render={<Button variant="outline" />}>
              إلغاء
            </DialogClose>
            <Button type="submit" loading={pending} loadingLabel="جارٍ الحفظ">
              حفظ التغييرات
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}