رفتن به محتوای اصلی

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
< 600pxcompact (گوشی)BottomNav (نوار پایین) + Fab شناورDrawer off-canvas، sheet modal‌ها
600–1023pxmedium (تبلت، iPad portrait)NavigationRail (rail لبهٔ start، 80dp)Drawer off-canvas، sheet modal‌ها
1024–1199pxexpandedCalendarSidebar دائمی (w-64)اکشن‌های header، modal‌های مرکزی
1200–1599pxlargeCalendarSidebar دائمیمحتوا محدود به max-w-[1600px]
>= 1600pxextra-largeCalendarSidebar دائمیمحتوا محدود به 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 داخل Drawer render می‌شود که از راست (سمتِ 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 هرگز بریده نشود.

safe-area، اهداف لمسی، و a11y

  • ابزارهای CSS در src/index.css: safe-top/right/bottom/left، safe-pt/pb/px، h-dvh/min-h-dvh/max-dvh، scroll-touch، touch-target (حداقل 44×44px).
  • viewport در index.html از viewport-fit=cover استفاده می‌کند.
  • body دارای overflow-x: hidden، tap-highlight خاموش، و scrolling با مومنتوم است.
  • یک outline سراسریِ :focus-visible برای کاربرانِ کیبورد وجود دارد.
  • دکمه‌های فقط‌آیکون aria-label دارند؛ آیتم‌های BottomNav از aria-current استفاده می‌کنند؛ ردیف‌های calendar-list role="checkbox" دارند و با کیبورد هم فعال می‌شوند.

RTL

اپ dir="rtl" است: drawer از راست (start) می‌آید، فلش‌ها جهت را رعایت می‌کنند، و gutterهای زمان right-0 هستند. از left/right سخت‌کدشده که RTL را می‌شکند پرهیز کنید — به‌جایش layout منطقی (logical properties) را ترجیح دهید.

راستی‌آزمایی

# از apps/calendar-web (یا turbo --filter=calendar-web)
npm run build # tsc -b && vite build
npm run test # vitest run

تست‌های seamِ feature-slice موجود باید سبز بمانند. تست‌های UI جدید باید window.matchMedia را mock کنند، چون jsdom خودش آن را فراهم نمی‌کند.