refactor(calendar): форма создания/редактирования конференции — в модальное окно

Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
This commit is contained in:
2026-07-26 23:33:52 +03:00
parent 904db83450
commit 07e5aba9c9
5 changed files with 120 additions and 56 deletions

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react'
/**
* Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`):
* закрытие по Escape и возврат фокуса на элемент, который был активен до
* открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре.
*/
export function useModalDismiss(onClose: () => void) {
const previouslyFocused = useRef<Element | null>(null)
// Всегда актуальный колбэк без пересоздания подписки на keydown при каждом
// рендере (onClose у вызывающих обычно инлайновая функция). Синхронизация —
// отдельным эффектом (не прямым присваиванием в теле рендера, см.
// react-hooks/refs — рефы нельзя трогать во время рендера).
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
previouslyFocused.current = document.activeElement
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previouslyFocused.current instanceof HTMLElement) previouslyFocused.current.focus()
}
}, [])
}

View File

@@ -5,6 +5,7 @@ import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar' import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog' import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences' import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
import { pluralizeConferences } from '@/lib/pluralize' import { pluralizeConferences } from '@/lib/pluralize'
import '@/styles/lobby.css' import '@/styles/lobby.css'
@@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
* Календарь-менеджер запланированных/закреплённых конференций * Календарь-менеджер запланированных/закреплённых конференций
* (см. design/mockups/calendar.html). * (см. design/mockups/calendar.html).
* *
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех, * Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые * сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
* владельца; там же полная модель — recurrence/duration, которых нет в * своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику * редактирование — отдельное действие: карандашик на чипе (только у
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои * владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
* конференции» — согласованное отклонение от макета, вызванное отсутствием * информационном окне.
* в контракте эндпоинта получения одной конференции по id.
*/ */
export function CalendarPage() { export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek') const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null) const [range, setRange] = useState<VisibleRange | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null) const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null) const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
@@ -45,21 +46,18 @@ export function CalendarPage() {
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences }) const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences]) const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
const handleOccurrenceClick = useCallback( const openCreateForm = useCallback(() => {
(occurrence: OccurrenceOut) => { setEditingConference(null)
// `/conferences/my` может содержать и конференции, куда пользователь setIsFormOpen(true)
// только приглашён участником (не организатор) — редактирование }, [])
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений. const openEditForm = useCallback((conference: ConferenceOut) => {
const owned = myConferenceById.get(occurrence.conference_id) setEditingConference(conference)
if (owned?.is_owner) { setIsFormOpen(true)
setEditingConference(owned) setOccurrenceDialogTarget(null)
} else { }, [])
setOccurrenceDialogTarget(occurrence)
} const closeForm = useCallback(() => setIsFormOpen(false), [])
},
[myConferenceById],
)
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet` // Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
// повторно всякий раз, когда меняется identity его пропсов (см. подробный // повторно всякий раз, когда меняется identity его пропсов (см. подробный
@@ -71,6 +69,8 @@ export function CalendarPage() {
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso })) setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
}, []) }, [])
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
return ( return (
<div className="page-shell"> <div className="page-shell">
<ShellTopbar /> <ShellTopbar />
@@ -83,7 +83,7 @@ export function CalendarPage() {
разовую встречу или закрепите постоянную конференцию с повторением разовую встречу или закрепите постоянную конференцию с повторением
</p> </p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}> <button type="button" className="btn btn-primary" onClick={openCreateForm}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" /> <Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Новая конференция Новая конференция
</button> </button>
@@ -95,23 +95,57 @@ export function CalendarPage() {
onViewChange={setView} onViewChange={setView}
occurrences={occurrences ?? EMPTY_OCCURRENCES} occurrences={occurrences ?? EMPTY_OCCURRENCES}
isLoading={occurrencesLoading} isLoading={occurrencesLoading}
myConferenceById={myConferenceById}
onRangeChange={handleRangeChange} onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick} onOccurrenceClick={setOccurrenceDialogTarget}
/> onEditClick={openEditForm}
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/> />
</div> </div>
</main> </main>
{isFormOpen && (
<ConferenceFormModal
conference={editingConference}
onSaved={closeForm}
onDeleted={closeForm}
onClose={closeForm}
/>
)}
{occurrenceDialogTarget && ( {occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} /> <ConferenceOccurrenceDialog
occurrence={occurrenceDialogTarget}
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
onClose={() => setOccurrenceDialogTarget(null)}
onEdit={openEditForm}
/>
)} )}
</div> </div>
) )
} }
interface ConferenceFormModalProps {
conference: ConferenceOut | null
onSaved: () => void
onDeleted: () => void
onClose: () => void
}
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
useModalDismiss(onClose)
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
key={conference?.id ?? 'create'}
conference={conference ?? undefined}
onSaved={onSaved}
onDeleted={onDeleted}
onCancelEdit={onClose}
/>
</div>
</div>
)
}

View File

@@ -7,17 +7,20 @@
* (см. комментарий в ConferenceCalendar.tsx). * (см. комментарий в ConferenceCalendar.tsx).
*/ */
/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */ /* ---------- Календарь во всю ширину контента ---------- */
/* Раньше `.layout-grid` была двухколоночной сеткой (календарь + вечно
висящая сбоку `.create-card`) — форма создания/редактирования переехала в
модалку (`.modal-form-wrap`, см. my-conferences.css), сайдбара больше нет.
`min-width:0` оставлен: `.week-card` внутри — единственный элемент грида, и
без этого грид-трек не сжимается уже своего min-content (внутренняя таблица
FullCalendar с 5 колонками) — классический grid blowout, раздувающий трек и
всю страницу шире вьюпорта (см. тот же приём в lobby.css/.action). */
.layout-grid { .layout-grid {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr;
gap: var(--space-6); gap: var(--space-6);
align-items: start; align-items: start;
} }
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
.layout-grid > * { min-width: 0; } .layout-grid > * { min-width: 0; }
.week-card { .week-card {
@@ -242,17 +245,11 @@
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3 совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
— см. комментарий там). Макет calendar.html — desktop-only — см. комментарий там). Макет calendar.html — desktop-only
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива (`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху, в нём нет.
карточка создания/редактирования — под ним (сохраняем порядок вёрстки). .layout-grid уже одноколоночная на всех ширинах (§2.2) — двухколоночного
960px-брейкпоинта под сайдбар-форму больше нет, форма теперь в модалке
(.modal-form-wrap .create-card в my-conferences.css гасит sticky сама).
*/ */
@media (max-width: 960px) {
/* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар
перестаёт быть sticky (в потоке одной колонки залипание при скролле
страницы выглядит и работает хуже, чем простое следование за контентом). */
.layout-grid { grid-template-columns: 1fr; }
.create-card { position: static; top: auto; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.week-card, .create-card { padding: var(--space-4); } .week-card, .create-card { padding: var(--space-4); }
.week-toolbar { flex-direction: column; align-items: stretch; } .week-toolbar { flex-direction: column; align-items: stretch; }

View File

@@ -52,13 +52,6 @@
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; } .icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); } .icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */
/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из
calendar.css); здесь только гасим её sticky-позиционирование календаря и
ограничиваем высоту внутри модалки. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900) /* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая (тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333 тёмный тултип поверх тёмной страницы); заменена на буквальный #333333

View File

@@ -417,6 +417,14 @@
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); } .modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
.modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); } .modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); }
/* Модалка вокруг ConferenceFormCard (`.create-card`, «Мои конференции» и
календарь) — сама карточка остаётся визуальной панелью
(фон/обводка/радиус/паддинг из calendar.css), здесь только гасим её
sticky-позиционирование из десктопного сайдбара и ограничиваем высоту
внутри модалки, чтобы скроллилась она сама, а не вся страница. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются /* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на (в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
свою строку сверху. */ свою строку сверху. */