Перейти к содержанию

Параллельная область и карточка в диалоге

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Сохранить список под модальной карточкой и оставить канонический адрес заявки.

Входное состояние: Список и самостоятельная карточка уже существуют. Выходное состояние: Внутренний переход может открыть диалог; прямой адрес остаётся полной карточкой. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-07 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.

Диспетчер просматривает список и хочет быстро прочитать описание, сохранив место в списке. При этом ссылку на заявку нужно открывать самостоятельно в новой вкладке. Совместим эти сценарии: обычная карточка остаётся канонической страницей, а клиентский переход из рабочей области может перехватить тот же адрес и показать диалог поверх списка. URL продолжает обозначать заявку, а способ отображения зависит от контекста навигации.

Для дополнительной области создаём слот @modal. Это параллельный маршрут, который передаётся корневой компоновке отдельным prop modal. Основная область остаётся children. Имя слота не добавляется в URL, поэтому /@modal/tickets/1 не является адресом диалога. Слот описывает одновременно присутствующую часть интерфейса, а не отдельную предметную страницу для посетителя.

Два состояния одного адреса

Внутри слота находится (.)tickets/[id]. Обозначение (.) указывает перехват на уровне сегментов маршрута. Физические каталоги групп и слотов не считаются обычными сегментами URL. Поэтому вычислять относительный перехват по простому числу вложенных папок ошибочно. Смотрите на дерево публичных сегментов и каноническую страницу /tickets/[id], с которой связан альтернативный интерфейс.

При внутреннем переходе маршрутизатор может сохранить список в основной области и подставить карточку в слот. При прямом открытии ссылки или полном обновлении ожидается самостоятельная страница карточки. Это не два разных идентификатора заявки. Обе реализации читают один учебный объект; после появления базы обе должны использовать одинаковый серверный поиск и правила доступа.

default.tsx возвращает null. Он задаёт запасной вариант, когда при полной загрузке маршрутизатор не может восстановить активное состояние слота по текущему URL. Отсутствие подходящего default может привести к состоянию отсутствия всей страницы. Поэтому «пустая область» является осмысленным интерфейсом: на канонической карточке модальный слой не требуется.

При клиентской навигации параллельные слоты могут сохранять предыдущее активное состояние, даже если новый путь не соответствует новой странице этого слота. Чтобы старый диалог не оставался поверх следующего экрана, добавлена catch-all страница, которая возвращает null. Она обслуживает очистку модальной области при других адресах. Это отличается от default полной загрузки, хотя результат обоих файлов визуально пустой.

Клиентская оболочка диалога

TicketDialog использует нативный элемент dialog. После монтирования эффект вызывает showModal(), а очистка закрывает элемент. Браузер получает настоящую модальную область с фоном и обработкой клавиатурного закрытия. Заголовок и описание передаются как children из серверной страницы. Клиентскому компоненту не требуется самостоятельно запрашивать заявку или импортировать серверный модуль данных.

Кнопка закрытия вызывает router.back(). Обработчик cancel перехватывает стандартное закрытие по Escape и тоже возвращает историю маршрута. Так состояние адреса меняется вместе с интерфейсом. Если только скрыть dialog в локальном состоянии, адрес остался бы адресом открытой карточки, а кнопка «Назад» получила бы неожиданный смысл. История браузера является частью договора этого сценария.

Область получает доступное название «Карточка заявки». Кнопка имеет понятный текст, а не только декоративный крест. Нативная модальность помогает с фокусом, но реальный пользовательский путь всё равно требует визуальной проверки в выбранной среде. В этой работе диалог не открывался на запущенном приложении, поэтому его ожидаемое поведение описано по конструкции и документации.

Что параллельный маршрут не решает

Слот можно использовать для независимых панелей и условного содержимого. Однако условное включение панели по роли не заменяет проверку данных. Если закрытые сведения уже переданы клиенту, отсутствие видимой панели не делает их недоступными. В нашем примере данные пока вымышленные; при переходе к личным заявкам серверный поиск должен ограничить объект до создания children для диалога.

Также диалог не является отдельной копией состояния базы. Если одна версия карточки получает новые сведения, а другая использует старый массив, интерфейсы разойдутся. Поэтому позже переведём и каноническую страницу, и перехватывающий маршрут на одну функцию findTicket. Общее чтение здесь важнее похожего JSX: одинаковое оформление не гарантирует одинаковые полномочия или источник.

Для самостоятельного разбора сравните мягкий переход по Link, прямое открытие адреса и обновление страницы с диалогом. Подпишите, какой файл отвечает за основной контент, какой за слот и какой за запасной пустой результат. Затем объясните, почему путь (.) выбирается по сегментам, а не по количеству директорий. Эта связь поможет использовать маршруты как состояние интерфейса, сохраняя полноценные адреса ресурсов.

Проверьте смысл границ

Обычный запрос полной карточки не зависит от того, существует ли у посетителя прежняя страница списка в памяти. Это делает адрес пригодным для новой вкладки и внешней ссылки. Модальный вариант использует сохранённый контекст и историю перехода. Если смешать эти предпосылки, кнопка закрытия окажется единственным способом покинуть якобы самостоятельную страницу.

В учебном диалоге описания передаются серверными children. После добавления статуса и удаления основная карточка станет богаче, а модальный просмотр останется кратким чтением. Такой выбор допустим, пока он явно сохраняет один объект и одно правило доступа. Разные объёмы интерфейса не должны создавать разные полномочия.

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

app/layout.tsx

import "./globals.css";
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = { title: { default: "Смена", template: "%s | Смена" }, description: "Учебная диспетчерская мастерской" };
export default function RootLayout({ children, modal }: { children: ReactNode; modal: ReactNode }) {
  return <html lang="ru"><body>{children}{modal}</body></html>;
}

components/ticket-dialog.tsx

"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
export function TicketDialog({ children }: { children: ReactNode }) {
  const dialog = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => { dialog.current?.showModal(); return () => dialog.current?.close(); }, []);
  return <dialog ref={dialog} aria-label="Карточка заявки" onCancel={event => { event.preventDefault(); router.back(); }}><button onClick={() => router.back()}>Закрыть карточку</button>{children}</dialog>;
}

app/@modal/default.tsx

export default function DefaultModal() { return null; }

app/@modal/[...rest]/page.tsx

export default function ClearModal() { return null; }

app/@modal/(.)tickets/[id]/page.tsx

import { notFound } from "next/navigation";
import { sampleTickets } from "@/lib/tickets";
import { TicketDialog } from "@/components/ticket-dialog";
export default async function ModalPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const ticket = sampleTickets.find(item => String(item.id) === id);
  if (!ticket) notFound();
  return <TicketDialog><h2>{ticket.title}</h2><p>{ticket.body}</p></TicketDialog>;
}

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.