UI واکنشگرا / Mobile-First (apps/calendar-web)
اپ وب mobile-first نوشته شده: روی گوشی و تبلت مثل یک اپِ تقویمِ بومی رفتار میکند، و روی صفحههای بزرگ یک layout کاملِ دسکتاپ نگه میدارد. این صفحه قراردادهایی را که کدبیس رویشان بنا شده توضیح میدهد.
قراردادِ breakpoint
shell در سراسر کلاسهای اندازهٔ پنجرهٔ MD3 تطبیق مییابد. breakpointها در
tailwind.config.js بهصورت md3 (600)، lg (1024)، xl (1200)، 2xl
(1600) تعریف شدهاند. برخلافِ عادتِ رایج، breakpointهای پیشفرضِ Tailwind یعنی
sm/md برای منطقِ موبایل استفاده نمیشوند — md3 همان لبهٔ
compact/medium است.
| بازه | کلاس | ناوبری | رفتارِ shell |
|---|---|---|---|
< 600px | compact (گوشی) | BottomNav (نوار پایین) + Fab شناور | Drawer off-canvas، sheet modalها |
| 600–1023px | medium (تبلت، iPad portrait) | NavigationRail (rail لبهٔ start، 80dp) | Drawer off-canvas، sheet modalها |
| 1024–1199px | expanded | CalendarSidebar دائمی (w-64) | اکشنهای header، modalهای مرکزی |
| 1200–1599px | large | CalendarSidebar دائمی | محتوا محدود به max-w-[1600px] |
>= 1600px | extra-large | CalendarSidebar دائمی | محتوا محدود به max-w-[1600px] |
breakpointهای md3 (600px) و lg (1024px) دگرگونیِ bottom-bar → rail →
drawer را هدایت میکنند. منطقِ موبایلِ مبتنی بر md: را دوباره معرفی نکنید —
برای لبهٔ compact/medium از md3: استفاده کنید.
hookِ useIsMobile()
src/hooks/useMediaQuery.ts + src/hooks/useIsMobile.ts.
const isMobile = useIsMobile() // وقتی viewport < lg (1024px) باشد true
SSR-safe است: تا mount شدن در مرورگر false برمیگرداند، و از
window.matchMedia('(max-width: 1023.98px)') پشتیبانی میکند. یک تست واحد
هم با matchMedia mock شده دارد (useIsMobile.test.ts).
نکتهای که راحت غافلگیر میکند: چون این hook تا mount شدن false برمیگرداند،
اگر به first paintِ درست نیاز دارید، داخلِ initializerِ یک useState از آن
استفاده نکنید. در عوض window.matchMedia(...) را همانجا بهصورت همگام
بخوانید — منطقِ initial-view در CalendarFaPage.tsx را ببینید.
hookِ useWindowSizeClass()
src/hooks/useWindowSizeClass.ts کلاسهای اندازهٔ MD3 و chromeِ ناوبریِ
مشتقشده را نمایان میکند:
const size = useWindowSizeClass() // 'compact' | 'medium' | 'expanded' | 'large' | 'extra-large'
const nav = useNavigationVariant() // 'bottom-bar' | 'rail' | 'drawer'
همینجا هم SSR-safe است: تا mount مقدارِ 'compact' برمیگرداند، پس داخلِ
initializerِ useState نخوانیدش. CalendarFaPage از useNavigationVariant()
استفاده میکند تا تصمیم بگیرد بینِ باز کردنِ drawerِ off-canvas
('bottom-bar' / 'rail') یا toggle کردنِ sidebar دائمی ('drawer'). خودِ
chrome بهصورت CSS-driven است (کلاسهای دیدِ md3: / lg:) تا از flash در
اولین paint جلوگیری شود.
app shell
CalendarFaPage.tsx یک ستونِ flex با 100dvh render میکند و این تکهها را
کنار هم میگذارد:
CalendarHeader— نوارِ mobile-first که باsafe-topاز نچ پاک میماند. اکشنهای ثانویه (جستجو / booking / create / dark-mode / view-select) رویlg+inlineاند و روی موبایل در یک منوی overflow (⋮) جمع میشوند. ناوبریِ تاریخ (today / prev / next) و title همیشه در دسترسِ شست باقی میمانند.- sidebar دسکتاپ —
CalendarSidebar(SidebarContentداخل یک asideِw-64) بهصورتhidden lg:block. - drawer off-canvas — روی
< lgهمانSidebarContentداخلDrawerrender میشود که از راست (سمتِ start در RTL) داخل میلغزد. رد کردنِ backdrop، بستن باEscape، body-scroll lock، و یک focus trap با restoreِ focus را دارد. stateِ drawer (isMobileDrawerOpen) از flagِ sidebar دسکتاپ جدا است و پیشفرض بسته. BottomNav— نوارِ تبِ پایینِmd3:hiddenکه فقط روی compact نشان داده میشود: چهار نمای تقویم (Month / Week / Day / Agenda) بهعلاوهٔ یک آیتمِ وظایف که یک<Link to="/tasks">است و به بخشِ مستقلِ Tasks میرود (برخلافِ چهار تایِ دیگر که view را inline عوض میکنند).aria-currentدارد، آگاهِ safe-area (safe-pb) است، و آیتمِ فعال یک pillِsecondary-containerنشان میدهد.NavigationRail— railِ 80dp لبهٔ start باmd3:flex lg:hidden، برای ردهٔ medium (600–1023)، با یک دکمهٔ createِprimary-containerدر بالا و همان مقاصدِBottomNav(چهار نمای تقویم + لینکِ وظایف).Fab— دکمهٔ create شناورِmd3:hiddenکه فقط روی compact است و بالای bottom nav قرار میگیرد (روی medium، دکمهٔ create داخل rail است).
منطقهٔ scroll اصلی روی موبایل padding پایین میگیرد تا محتوا از bottom nav پاک
بماند: pb-[calc(4rem+env(safe-area-inset-bottom))] lg:pb-0.
بخشِ وظایف (/tasks)
Tasks یک بخشِ مستقل است (نه یک نمای تقویم) و در مسیرِ /tasks با
TasksPage render میشود — جدا از CalendarFaPage. ورودیِ آن در هر سه
breakpoint در chromeِ همیشه-visible دیده میشود: آیتمِ وظایف در BottomNav
(compact) و NavigationRail (medium)، و یک لینکِ برجسته در بالای
CalendarSidebar/drawer (expanded+). صفحه هدری با بازگشت به تقویم، تبهای
بخشبندیشده (صندوق ورودی / امروز / آینده / تکمیلشده)، افزودنِ سریع، و یک
TaskDetailSheet (مبتنی بر Modal) برای ویرایش دارد. دادهاش از همان مخزنِ
Dexie میآید و از طریقِ /api/sync همگام میشود.
نماهای تقویم
MonthView— grid هفتستونی با خانههای کوچکتر روی گوشی. موبایل نقطهها + شمارشهای «+N» نشان میدهد؛lg+chipهای کامل. header روزِ هفته sticky است (تکحرفی روی موبایل). ضربه زدن روی یک روز در موبایل به Day view میرود (onSelectDay)؛ دسکتاپ create-on-tap را نگه میدارد.WeekView— ۷ ستونِ روز بهصورت tileهای85vwباsnap-startداخل یک scroll افقی روی موبایل (snap رویlg+غیرفعال است، جایی که ستونهاflex-1برابر هستند). یک containerِ scroll مشترک، gutter ساعت راsticky right-0و header هر ستون راsticky top-0نگه میدارد، تا header و بدنه بدون نیاز به scroll-sync دستی با JS تراز بمانند.DayView— یک ستونِ time-grid؛ عرضِ gutter واکنشگرا و فضای bottom-nav.AgendaView— نمای پیشفرضِ موبایل. spacing صیقلشده، ردیفهای>=44px، منوی بازهٔ تاریخ با click-toggle (قبلاً فقط hover بود که روی تاچ میشکست)، فضای safe-area پایین.
نمای پیشفرض هنگام اولین load هم به همین قواعد گره خورده: CalendarFaPage
متغیر view را روی < lg به 'agenda' و روی >= lg به 'month' مقداردهی
اولیه میکند، و برای اینکه first paint درست باشد این کار بهصورت همگام از
matchMedia خوانده میشود. تغییراتِ دستیِ view بعداً هرگز override نمیشوند.
modalها (bottom sheet)
Modal.tsx یک chrome قابلِاستفادهٔ مجدد است (portal شده به document.body):
< lg: bottom sheet بالاآمده، گوشهٔ بالا گرد، affordance با drag-handle،max-h-[92dvh]، header sticky + بدنهٔ قابلِscroll + footer sticky، padding safe-area.lg+: dialog مرکزی،max-w-lg،max-h-[90vh].
در هر دو حالت backdrop dismiss، Escape، body-scroll lock، focus trap،
restoreِ focus، و role="dialog"/aria-modal="true" فراهم است.
EventModal و modalِ Booking هر دو از طریق همین Modal render میشوند.
JalaliDatePicker هم popoverِ تقویمش را به document.body portal میکند و
fixed موقعیت میدهد (راستچین به trigger، و وقتی نزدیکِ پایین است بالاتر
flip میشود) تا داخلِ یک sheetِ در حال scroll هرگز بریده نشود.