В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.
Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).
Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|