حوار التنبيه (Alert Dialog)

التثبيت

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

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

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

معاينة

اضغط الزر لفتح الحوار. ينقلك مفتاح Tab بين أزراره، ويغلقه مفتاح Escape.

المحاذاة

تحدد الخاصية align ترتيب الحوار. القيمة edge هي الافتراضية، وفيها يبدأ العنوان والوصف من بداية السطر، وتقع الإجراءات في نهاية الصف. أما القيمة center فتضع النص والإجراءات في المنتصف. استخدمها مع الوصف القصير فقط، لأن قراءة الأسطر المتوسّطة تصعب كلما طال النص.

الإجراء الخطر

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

الأيقونة

تضيف الخاصية icon أيقونة تنبيه إلى العنوان، بحجم 24px وبلون العنوان نفسه. مع القيمة edge تظهر بجانب العنوان في منتصف سطره الأول، ويبدأ الوصف وأي محتوى إضافي من حيث يبدأ العنوان، لا من الأيقونة. ومع القيمة center تظهر فوق العنوان. ولعرض أيقونة أخرى، مرّر أي أيقونة بدل القيمة true، مثل أيقونة تسجيل الخروج أدناه. وتظهر بحجم أيقونة التنبيه ولونها وموضعها.

المحتوى الإضافي

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

انتظار الإجراء

يغلق الإجراء الرئيسي الحوار بعد تنفيذ الدالة onClick. وحين يستغرق الإجراء وقتًا، استدعِ event.preventBaseUIHandler() داخل onClick ليبقى الحوار مفتوحًا، وفعّل الخاصية loading على زر الإجراء أثناء تنفيذ الطلب، ثم أغلق الحوار بالخاصية open عند انتهائه.

النص الطويل

يلتفّ العنوان والوصف الطويلان داخل الحوار الذي لا يزيد عرضه على 512px. وحين يصبح الحوار أطول من النافذة، يتمرّر محتواه في داخله. وعلى الهاتف يبقى على بُعد 16px من كل حافة للشاشة.

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

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

الخصائص (props)

AlertDialog

الخاصيةالنوعالافتراضيالوصف
openbooleanلا يوجديفتح الحوار أو يغلقه عند التحكّم بالقيمة.
defaultOpenbooleanfalseيفتح الحوار عند العرض الأول.
onOpenChange(open, details) => voidلا يوجدتُستدعى عند فتح الحوار أو إغلاقه.
onOpenChangeComplete(open) => voidلا يوجدتُستدعى بعد اكتمال فتح الحوار أو إغلاقه.
actionsRefRefObjectلا يوجدمرجع فيه الدالة close() لإغلاق الحوار من الشيفرة.
handleAlertDialogHandleلا يوجديربط الحوار بعناصر مُشغِّلة في مكان آخر من الصفحة. أنشئه بالدالة AlertDialog.createHandle() من الحزمة @base-ui/react/alert-dialog.
triggerIdstring | nullلا يوجدالعنصر المُشغِّل الذي يتبعه الحوار من بين عدة عناصر، حين تتحكم بالخاصية open.
defaultTriggerIdstring | nullلا يوجدالعنصر المُشغِّل الذي يتبعه الحوار عند العرض الأول، مع الخاصية defaultOpen.
childrenReactNodeلا يوجدالعنصر المُشغِّل ومحتوى الحوار.

AlertDialogTrigger

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

AlertDialogContent

الخاصيةالنوعالافتراضيالوصف
align"edge" | "center""edge"تبدأ القيمة edge النص من بداية السطر وتضع الإجراءات في نهايته. وتضع القيمة center الاثنين في المنتصف.
destructivebooleanfalseيحوّل الإجراء الرئيسي إلى زر الإجراءات الخطرة.
iconboolean | ReactNodefalseالقيمة true لأيقونة التنبيه، أو أي أيقونة أخرى. وفي الحالتين تظهر بحجم 24px وبلون العنوان.
containerHTMLElementلا يوجدالعنصر الذي يُعرض فيه الحوار، والقيمة الافتراضية هي body.
initialFocusboolean | RefObjectلا يوجدالعنصر الذي يأخذ التركيز عند فتح الحوار. الافتراضي هو أول عنصر تفاعلي.
finalFocusboolean | RefObjectلا يوجدالعنصر الذي يأخذ التركيز عند إغلاق الحوار. الافتراضي هو العنصر المُشغِّل.
childrenReactNodeلا يوجدرأس الحوار، ثم أي محتوى إضافي، ثم التذييل.
classNamestringلا يوجدأصناف CSS إضافية لصندوق الحوار.

AlertDialogHeader

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

AlertDialogTitle

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

AlertDialogDescription

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

AlertDialogFooter

الخاصيةالنوعالافتراضيالوصف
childrenReactNodeلا يوجدزر الإلغاء، ثم الإجراء الرئيسي.
classNamestringلا يوجدأصناف CSS إضافية لصف الإجراءات.

AlertDialogCancel

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

AlertDialogAction

الخاصيةالنوعالافتراضيالوصف
onClick(event) => voidلا يوجدتنفّذ الإجراء ثم تغلق الحوار. استدعِ event.preventBaseUIHandler() ليبقى مفتوحًا.
loadingbooleanfalseيعرض مؤشر التحميل، ويعطّل الزر، ويُعلن نص الخاصية loadingLabel.
loadingLabelstring"Loading"نص يُعلنه قارئ الشاشة أثناء التحميل.
disabledbooleanfalseيعطّل الزر.
childrenReactNodeلا يوجدنص الإجراء.
classNamestringلا يوجدأصناف CSS إضافية للزر.

الاستخدام

تأكيد الحذف

tsx
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"

export function DeleteProject({ onDelete }: { onDelete: () => void }) {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button variant="outline" tone="destructive" />}>
        حذف المشروع
      </AlertDialogTrigger>
      <AlertDialogContent destructive>
        <AlertDialogHeader>
          <AlertDialogTitle>حذف هذا المشروع؟</AlertDialogTitle>
          <AlertDialogDescription>
            سيُحذف المشروع وجميع ملفاته لكل أعضاء فريقك، ولا يمكن استعادتها بعد ذلك.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>إلغاء</AlertDialogCancel>
          <AlertDialogAction onClick={onDelete}>حذف</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

طلب اسم المشروع

tsx
"use client"

import * as React from "react"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function DeleteProjectByName({ onDelete }: { onDelete: () => void }) {
  const [name, setName] = React.useState("")
  return (
    <AlertDialog onOpenChange={(open) => open && setName("")}>
      <AlertDialogTrigger render={<Button variant="outline" tone="destructive" />}>
        حذف المشروع
      </AlertDialogTrigger>
      <AlertDialogContent destructive icon>
        <AlertDialogHeader>
          <AlertDialogTitle>حذف هذا المشروع؟</AlertDialogTitle>
          <AlertDialogDescription>
            سيُحذف المشروع وجميع ملفاته لكل أعضاء فريقك، ولا يمكن استعادتها بعد ذلك.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <Input
          label="اكتب اسم المشروع للتأكيد"
          placeholder="مدار"
          value={name}
          onChange={(event) => setName(event.target.value)}
        />
        <AlertDialogFooter>
          <AlertDialogCancel>إلغاء</AlertDialogCancel>
          <AlertDialogAction disabled={name !== "مدار"} onClick={onDelete}>
            حذف
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

استخدام أيقونة أخرى

tsx
import { LogOutIcon } from "lucide-react"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"

export function SignOutEverywhere({ onSignOut }: { onSignOut: () => void }) {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button variant="outline" />}>تسجيل الخروج</AlertDialogTrigger>
      <AlertDialogContent icon={<LogOutIcon strokeWidth={1.5} className="[&:dir(rtl)]:-scale-x-100" />}>
        <AlertDialogHeader>
          <AlertDialogTitle>تسجيل الخروج من كل الأجهزة؟</AlertDialogTitle>
          <AlertDialogDescription>
            ستحتاج إلى تسجيل الدخول مجددًا على هاتفك وحاسوبك وأي جهاز آخر تستخدمه.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>إلغاء</AlertDialogCancel>
          <AlertDialogAction onClick={onSignOut}>تسجيل الخروج</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

إبقاء الحوار مفتوحًا أثناء تنفيذ الإجراء

tsx
"use client"

import * as React from "react"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"

export function DeleteProjectAndWait({ onDelete }: { onDelete: () => Promise<void> }) {
  const [open, setOpen] = React.useState(false)
  const [pending, setPending] = React.useState(false)

  return (
    <AlertDialog open={open} onOpenChange={(next) => !pending && setOpen(next)}>
      <AlertDialogTrigger render={<Button variant="outline" tone="destructive" />}>
        حذف المشروع
      </AlertDialogTrigger>
      <AlertDialogContent destructive>
        <AlertDialogHeader>
          <AlertDialogTitle>حذف هذا المشروع؟</AlertDialogTitle>
          <AlertDialogDescription>
            سيُحذف المشروع وجميع ملفاته لكل أعضاء فريقك، ولا يمكن استعادتها بعد ذلك.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>إلغاء</AlertDialogCancel>
          <AlertDialogAction
            loading={pending}
            loadingLabel="جارٍ الحذف"
            onClick={async (event) => {
              event.preventBaseUIHandler()
              setPending(true)
              try {
                await onDelete()
                setOpen(false)
              } finally {
                setPending(false)
              }
            }}
          >
            حذف
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}