Навигация, активная ссылка и поиск в URL
Оглавление курса · Скачать все состояния проекта.
Учебная задача: Передать поисковую строку через адрес и выделить текущий раздел.
Входное состояние: Рабочие страницы имеют общую шапку. Выходное состояние: Активная навигация и поиск, который сохраняется в параметрах URL. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-05 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.
Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.
Диспетчер открывает список, вводит слово «рабочее» и хочет вернуться к той же выборке позже. Если хранить поиск только во внутреннем состоянии поля, адрес не объяснит, какой результат показан. Перенесём запрос в параметр q и построим список из этого значения. Одновременно выделим текущий раздел в общей навигации. Две задачи используют URL, но одна читает строку запроса, а другая путь страницы.
Link обеспечивает переход между страницами приложения и поддерживает механизмы навигации Next. Обычная ссылка тоже имеет значение и может приводить к загрузке документа, но для рабочих переходов используем договор маршрутизатора. Визуальное состояние активной ссылки выделяем в отдельный клиентский Nav. Основная компоновка остаётся серверной; ей не нужно становиться клиентской только ради чтения текущего пути.
Путь и строка запроса различаются
usePathname() даёт pathname. Для /tickets/1?q=printer он относится к /tickets/1, без поискового значения. Наше условие считает раздел активным, если путь равен /tickets или начинается с /tickets/. Дополнительная косая черта защищает от случайного совпадения с названием вроде /tickets-extra. Проверка простого начала строки без разделителя дала бы слишком широкий результат.
Активной ссылке добавляется aria-current="page". Стиль подчёркивает её визуально, а атрибут сообщает смысл другим способам восприятия. Не используем только цвет как единственное обозначение. Список ссылок имеет метку навигации. Это небольшое решение помогает пользователю различать общую шапку и основной список данных, даже когда обе области содержат ссылки.
Страница списка принимает searchParams как Promise и дожидается его. Значение q может отсутствовать, быть строкой или массивом при повторении параметра. Выбираем только строковый вариант, убираем внешние пробелы и ограничиваем длину восемьюдесятью символами. Такой разбор делает договор явным. Не обращаемся к toLowerCase у любого непроверенного значения, которое могло прийти из URL.
Поиск на текущем шаге выполняется по вымышленному массиву: заголовок и запрос приводятся к нижнему регистру с русской локалью, затем проверяется включение. Это не полнотекстовый поиск базы и не обещание учитывать все языковые формы. Пустой запрос сохраняет весь учебный набор. Если совпадений нет, страница показывает соответствующее сообщение, а не изображает сбой сервера.
Форма поиска выполняет переход
Компонент Form с action="/tickets" использует строковый адрес. Поле с именем q попадает в строку запроса при отправке, поэтому результат можно сохранить или передать как ссылку. Это отличается от формы с серверной функцией действия, которая появится при создании заявки. Один компонент поддерживает разные сценарии, поэтому смотрим прежде всего на тип action и смысл отправки.
defaultValue заполняет поле значением из текущего URL. После перехода серверная страница строит новый набор и соответствующее исходное значение. Не смешиваем этот договор с полностью управляемым клиентским полем без необходимости. Для простого поиска важнее, чтобы URL, показанная выборка и текст поля соответствовали друг другу. Если позже потребуется поиск при каждом символе, понадобится отдельное решение о частоте переходов.
useRouter применяют для программных переходов, например после успешного сохранения. push добавляет запись в историю, replace заменяет текущую, back возвращает назад. Для обычного перехода по известной ссылке Link яснее описывает намерение. Не передавайте непроверенный произвольный ввод пользователя в навигационные методы: адрес назначения тоже относится к границе доверия.
Ожидаемые состояния поиска
Для запроса «рабочее» ожидается первая заявка, для пустого запроса обе, для неизвестного слова сообщение об отсутствии совпадений. Повторённый q в нашем договоре не становится случайным объединённым поиском: массив преобразуется в пустой вариант. Эти результаты следуют из явно написанного разбора. Форма поиска не изменяет сами заявки и не требует полномочия на их редактирование.
При открытой карточке ссылка «Заявки» остаётся активной, потому что путь находится внутри этого раздела. При переходе в руководство активным становится другой пункт. Если компоновка не применяется к целевой странице, сама рабочая навигация там не появится. Так мы сохраняем различие между состоянием ссылки и структурой маршрутов, которую рассмотрели раньше.
Для самостоятельного разбора составьте адрес списка с поиском и объясните, где читается значение, где фильтруется массив и где строятся ссылки карточек. Затем сравните адресную строку после обычного перехода и после программного replace. Такое чтение показывает, как URL становится частью состояния интерфейса, сохраняя серверную страницу простой и понятной.
Проверьте смысл границ
Значение поиска видно в адресе и может попадать в историю или быть переслано другому человеку. Поэтому этот договор подходит обычному тексту фильтра, но не секретам входа или токенам. Полезность URL для возвращения к выборке не означает, что в него следует переносить любые данные формы. Создание заявки дальше будет отдельной серверной отправкой.
Обратите внимание на отсутствие эффекта, который повторяет поиск после каждого рендера. Серверная страница получает q как свой вход, а отправка формы делает переход с новым входом. Такая короткая цепочка проще объясняется и не создаёт лишних циклов обновления. При расширении интерфейса сохраняйте один понятный источник значения.
Полные файлы этого шага
Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.
components/nav.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function Nav() {
const path = usePathname();
return <nav aria-label="Рабочие разделы">{[{ href: "/tickets", title: "Заявки" }, { href: "/guide", title: "Руководство" }].map(item => <Link key={item.href} href={item.href} aria-current={path === item.href || path.startsWith(item.href + "/") ? "page" : undefined}>{item.title} </Link>)}</nav>;
}
app/(work)/layout.tsx
import Link from "next/link";
import type { ReactNode } from "react";
import { Nav } from "@/components/nav";
export default function WorkLayout({ children }: { children: ReactNode }) {
return <><header><Link href="/">Смена</Link><Nav /></header>{children}</>;
}
app/(work)/tickets/page.tsx
import Link from "next/link";
import Form from "next/form";
import { sampleTickets } from "@/lib/tickets";
export default async function Tickets({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = (typeof raw === "string" ? raw : "").trim().slice(0, 80);
const rows = sampleTickets.filter(item => item.title.toLocaleLowerCase("ru").includes(q.toLocaleLowerCase("ru")));
return <main><h1>Заявки</h1><Form action="/tickets"><label htmlFor="q">Поиск по заголовку</label><input id="q" name="q" defaultValue={q} /><button>Найти</button></Form>{rows.map(ticket => <article key={ticket.id}><Link href={`/tickets/${ticket.id}`}>{ticket.title}</Link><p>{ticket.status}</p></article>)}{!rows.length && <p>Подходящих заявок нет.</p>}</main>;
}
Документация и исходники
Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.