Files
vidconf/frontend/src/components/calendar/ConferenceCalendar.tsx
Max Ronzhin f3556d7d89
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
fix(calendar): убрать «г.» из подписей дней в сетке
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.

Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).

Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
2026-07-27 01:32:02 +03:00

354 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import type { DatesSetArg, EventContentArg, EventInput, EventMountArg } from '@fullcalendar/core'
import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru'
import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
/*
* Стабильные (модульные, не пересоздаваемые на каждый рендер) значения для
* пропсов FullCalendar. `@fullcalendar/react` при любом изменении identity
* пропса-опции (массива/объекта/функции) дёргает внутренний `resetOptions`,
* который заново вызывает `datesSet` — если тот, в свою очередь, меняет
* состояние компонента без проверки «а изменилось ли значение», получается
* бесконечный цикл setState → рендер → новые identity пропсов → resetOptions
* → datesSet → setState (баг гейта, 2026-07-16: /calendar рендерился пустым
* экраном с «Maximum update depth exceeded» в консоли). Поэтому: массивы
* `plugins`/`hiddenDays` и функция `renderEventContent` — константы модуля
* (не зависят от пропсов/состояния), а `events`/колбэки, зависящие от
* пропсов, — обёрнуты в `useMemo`/`useCallback` ниже.
*/
const CALENDAR_PLUGINS = [dayGridPlugin, interactionPlugin]
const HIDDEN_DAYS_BUSINESS_WEEK: number[] = [0, 6]
const HIDDEN_DAYS_NONE: number[] = []
/**
* Хранилище отписок focusin/focusout по DOM-элементу чипа:
* ховер-карточка по клавиатурному фокусу) — элементы событий FullCalendar
* рендерятся вне React-дерева, поэтому слушатели вешаются нативно в
* `eventDidMount`/снимаются в `eventWillUnmount`; `WeakMap` не мешает сборке
* мусора при повторном рендере событий.
*/
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
/**
* Актуальный колбэк редактирования из последнего рендера
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
* функцией на всё время жизни модуля (см. комментарий выше про
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
* переменную эффектом при каждом изменении пропса.
*/
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
interface ConferenceCalendarProps {
view: CalendarViewMode
onViewChange: (view: CalendarViewMode) => void
occurrences: OccurrenceOut[]
isLoading: boolean
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
myConferenceById: Map<string, ConferenceOut>
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
onRangeChange: (fromIso: string, toIso: string) => void
onOccurrenceClick: (occurrence: OccurrenceOut) => void
onEditClick: (conference: ConferenceOut) => void
}
/**
* Не зависит от пропсов/состояния компонента — модульная константа-функция
* (см. комментарий выше про стабильность identity). В чипе — только название
* (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
* дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
* статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
*/
function renderEventContent(arg: EventContentArg) {
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
return (
<div className="chip-row">
<span className="chip-title">
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
<span className="chip-title-text">{arg.event.title}</span>
</span>
{ownedConference && (
<button
type="button"
className="chip-edit"
aria-label="Редактировать конференцию"
onClick={(e) => {
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
// открыл бы информационное окно вместо формы редактирования.
e.stopPropagation()
latestOnEditClick(ownedConference)
}}
>
<Pencil aria-hidden="true" />
</button>
)}
</div>
)
}
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Подписи дней в самой сетке, из которых надо убрать «г.» (см.
* `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
* подпись колонки дня.
*/
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'
/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr)
if (!value) continue
const cleaned = value.replace(YEAR_SUFFIX_RE, '')
if (cleaned !== value) el.setAttribute(attr, cleaned)
}
}
/**
* Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
*
* Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
* дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
* accessibility-дереве и как всплывающая подсказка. Формат этой подписи
* библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
* форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
* результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
* `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
* поэтому правим уже отрендеренный атрибут.
*
* Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
* при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
* уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
* подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
* живой проверке: после переключения вида ровно строка переиспользованных
* ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
* Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
* реально меняется, поэтому вызванная нашей же правкой итерация ничего не
* пишет и цепочка обрывается.
*/
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = rootRef.current
if (!root) return
const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
clean()
const observer = new MutationObserver(clean)
observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
return () => observer.disconnect()
}, [rootRef])
}
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
* реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
* структурно сами, только нужные поля).
*/
interface TitleFormatArg {
start: { marker: Date }
end?: { marker: Date }
}
/**
* Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
* ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
* FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
* периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
* достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
* месяц/год и красиво отформатировал переход через месяц/год.
*/
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
return (arg: TitleFormatArg): string => {
const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
return raw.replace(YEAR_SUFFIX_RE, '')
}
}
const CALENDAR_VIEWS = {
dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
}
/**
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
* События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
* у владельца), статус/время/номер — в информационном окне по клику
* (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
* (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
* не занятость переговорной по часам (переговорных комнат в модели нет).
*/
export function ConferenceCalendar({
view,
onViewChange,
occurrences,
isLoading,
myConferenceById,
onRangeChange,
onOccurrenceClick,
onEditClick,
}: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null)
const gridRef = useRef<HTMLDivElement | null>(null)
useStripYearSuffix(gridRef)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
useEffect(() => {
latestOnEditClick = onEditClick
}, [onEditClick])
// Ховер-карточка конференции — состояние держим внутри
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
// оставались стабильными по identity между рендерами (см. комментарий в
// начале файла про resetOptions/бесконечный цикл при нестабильных пропсах).
const [hoverTarget, setHoverTarget] = useState<{ occurrence: OccurrenceOut; anchorEl: HTMLElement } | null>(null)
const handleEventMouseEnter = useCallback((arg: EventHoveringArg) => {
setHoverTarget({ occurrence: arg.event.extendedProps.occurrence as OccurrenceOut, anchorEl: arg.el })
}, [])
const handleEventMouseLeave = useCallback((arg: EventHoveringArg) => {
setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
}, [])
// Клавиатурная доступность ховер-карточки: чипы FullCalendar рендерятся
// самой библиотекой вне React-дерева, поэтому фокус отслеживаем нативными
// слушателями focusin/focusout (не React onFocus/onBlur).
const handleEventDidMount = useCallback((arg: EventMountArg) => {
arg.el.tabIndex = 0
const occurrence = arg.event.extendedProps.occurrence as OccurrenceOut
const handleFocusIn = () => setHoverTarget({ occurrence, anchorEl: arg.el })
const handleFocusOut = () => setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
arg.el.addEventListener('focusin', handleFocusIn)
arg.el.addEventListener('focusout', handleFocusOut)
eventFocusCleanups.set(arg.el, () => {
arg.el.removeEventListener('focusin', handleFocusIn)
arg.el.removeEventListener('focusout', handleFocusOut)
})
}, [])
const handleEventWillUnmount = useCallback((arg: EventMountArg) => {
eventFocusCleanups.get(arg.el)?.()
eventFocusCleanups.delete(arg.el)
}, [])
useEffect(() => {
calendarRef.current?.getApi().changeView(view)
}, [view])
function goPrev() {
calendarRef.current?.getApi().prev()
}
function goNext() {
calendarRef.current?.getApi().next()
}
function goToday() {
calendarRef.current?.getApi().today()
}
// Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
// создавался бы новый массив-`events` той же длины/содержимого, и
// FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
// модульных констант выше).
const events: EventInput[] = useMemo(
() =>
occurrences.map((occ) => {
const owned = myConferenceById.get(occ.conference_id)
return {
id: `${occ.conference_id}:${occ.starts_at}`,
title: occ.title ?? 'Конференция без названия',
start: occ.starts_at,
end: occ.ends_at,
classNames: ['conf-chip'],
extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
}
}),
[occurrences, myConferenceById],
)
const handleEventClick = useCallback(
(info: EventClickArg) => {
onOccurrenceClick(info.event.extendedProps.occurrence as OccurrenceOut)
},
[onOccurrenceClick],
)
const handleDatesSet = useCallback(
(arg: DatesSetArg) => {
const api = calendarRef.current?.getApi()
if (api && titleRef.current) titleRef.current.textContent = api.view.title
onRangeChange(arg.start.toISOString(), arg.end.toISOString())
},
[onRangeChange],
)
return (
<section className="week-card">
<div className="week-toolbar">
<div className="date-nav">
<button type="button" className="date-nav-arrow" aria-label="Предыдущий период" onClick={goPrev}>
<ChevronLeft className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<span ref={titleRef} />
<button type="button" className="date-nav-arrow" aria-label="Следующий период" onClick={goNext}>
<ChevronRight className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="btn btn-secondary" onClick={goToday}>
Сегодня
</button>
</div>
<div className="view-toggle" role="group" aria-label="Вид календаря">
<button type="button" className={view === 'dayGridWeek' ? 'is-active' : ''} onClick={() => onViewChange('dayGridWeek')}>
Неделя
</button>
<button type="button" className={view === 'dayGridMonth' ? 'is-active' : ''} onClick={() => onViewChange('dayGridMonth')}>
Месяц
</button>
</div>
</div>
<div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
<FullCalendar
ref={calendarRef}
plugins={CALENDAR_PLUGINS}
initialView={view}
headerToolbar={false}
locale={ruLocale}
timeZone="local"
firstDay={1}
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
height="auto"
dayMaxEvents={false}
views={CALENDAR_VIEWS}
events={events}
eventContent={renderEventContent}
eventClick={handleEventClick}
datesSet={handleDatesSet}
eventMouseEnter={handleEventMouseEnter}
eventMouseLeave={handleEventMouseLeave}
eventDidMount={handleEventDidMount}
eventWillUnmount={handleEventWillUnmount}
/>
</div>
<ConferenceHoverCard conferenceId={hoverTarget?.occurrence.conference_id ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
</section>
)
}