From d3a127e533d702e5ac9ae438c3b0daab5091bd7c Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Tue, 14 Oct 2025 22:23:58 +0200 Subject: [PATCH 1/7] Migrate the Navbar to Base UI --- src/components/navbar/navbar.tsx | 236 +++++++++++++++---------------- src/globals.css | 12 -- tailwind.config.ts | 5 + 3 files changed, 119 insertions(+), 134 deletions(-) diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 536b69d279..f3ef24c704 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -1,11 +1,17 @@ -import { MenuItem, Menu, MenuButton, MenuItems } from "@headlessui/react" +import { NavigationMenu } from "@base-ui-components/react/navigation-menu" + import clsx from "clsx" // eslint-disable-next-line no-restricted-imports -- since we don't need newWindow prop import NextLink from "next/link" import { Button } from "nextra/components" import { useFSRoute } from "nextra/hooks" import type * as normalizePages from "nextra/normalize-pages" -import { Fragment, useState, type ReactElement, type ReactNode } from "react" +import { + useCallback, + useEffect, + type ReactElement, + type ReactNode, +} from "react" import { useMenu, useThemeConfig } from "nextra-theme-docs" import { Anchor } from "@/app/conf/_design-system/anchor" import { renderComponent } from "@/components/utils" @@ -18,77 +24,53 @@ export interface NavBarProps { items: Item[] } -const classes = { - link: "typography-menu flex items-center text-neu-900 px-3 py-1 nextra-focus [text-box:trim-both_cap_alphabetic] leading-none hover:underline underline-offset-2", -} +const linkClasses = + "typography-menu flex items-center text-neu-900 px-3 py-1 nextra-focus [text-box:trim-both_cap_alphabetic] leading-none hover:underline underline-offset-2" function NavbarMenu({ menu, children, - onSubmenuOpen, }: { menu: normalizePages.MenuItem children: ReactNode - onSubmenuOpen: (open: boolean) => void }): ReactElement { const routes = Object.fromEntries( (menu.children || []).map(route => [route.name, route]), ) return ( - - - {({ focus, open }) => { - // I'm sorry, I know this is so cursed. - // I need to migrate out of HeadlessUI to something with change handlers. - onSubmenuOpen(open) - - return ( - - ) - }} - - - // eslint-disable-next-line tailwindcss/no-custom-classname - clsx( - "gql-navbar-menu-items", - "motion-reduce:transition-none", - "focus-visible:outline-none", - open ? "opacity-100" : "opacity-0", - "nextra-scrollbar overflow-visible transition-opacity", - "z-20 rounded-md py-1 text-sm", - // headlessui adds max-height as style, use !important to override - "!max-h-[min(calc(100vh-5rem),256px)]", - ) - } - anchor={{ to: "top start", gap: 21, padding: 16, offset: -8 }} + + + {children} + + {Object.entries(menu.items || {}).map(([key, item]) => ( - - - - {item.title} - - - + ( + + + {item.title} + + + )} + /> ))} - - + + ) } @@ -97,7 +79,21 @@ export function Navbar({ items }: NavBarProps): ReactElement { const activeRoute = useFSRoute() const { menu, setMenu } = useMenu() - const [submenuOpen, setSubmenuOpen] = useState(false) + const handleNavigationMenuChange = useCallback((value: unknown) => { + if (typeof document === "undefined") { + return + } + document.body.style.overflow = value != null ? "hidden" : "auto" + }, []) + + useEffect(() => { + if (typeof document === "undefined") { + return + } + return () => { + document.body.style.overflow = "auto" + } + }, []) return (
)}
-
- {items.map(pageOrMenu => { - if (pageOrMenu.display === "hidden") return null +
} + > + + {items.map(pageOrMenu => { + if (pageOrMenu.display === "hidden") return null - if (pageOrMenu.type === "menu") { - const menu = pageOrMenu as normalizePages.MenuItem - return ( - { - if (typeof window !== "undefined") { - if (open) { - document.body.style.overflow = "hidden" - } else { - document.body.style.overflow = "auto" - } - } - setSubmenuOpen(open) - }} - > - {menu.title} - - ) - } - const page = pageOrMenu as normalizePages.PageItem - let href = page.href || page.route || "#" + if (pageOrMenu.type === "menu") { + const menu = pageOrMenu as normalizePages.MenuItem + return ( + + {menu.title} + + ) + } + const page = pageOrMenu as normalizePages.PageItem + let href = page.href || page.route || "#" - // If it's a directory - if (page.children) { - href = - (page.withIndexPage ? page.route : page.firstChildRoute) || href - } + // If it's a directory + if (page.children) { + href = + (page.withIndexPage ? page.route : page.firstChildRoute) || + href + } - const isActive = - page.route === activeRoute || - activeRoute.startsWith(page.route + "/") + const isActive = + page.route === activeRoute || + activeRoute.startsWith(page.route + "/") - return ( - - {page.title} - - ) - })} -
+ return ( + + + {page.title} + + + ) + })} + + + + + + + + + +
{process.env.NEXTRA_SEARCH && renderComponent(themeConfig.search.component, { @@ -218,7 +222,6 @@ export function Navbar({ items }: NavBarProps): ReactElement { )} -
) } @@ -266,14 +269,3 @@ export function NavbarPlaceholder({ /> ) } - -function SubmenuBackdrop({ className }: { className: string }) { - return ( -
- ) -} diff --git a/src/globals.css b/src/globals.css index a59e0b6d45..a77c99ad1d 100644 --- a/src/globals.css +++ b/src/globals.css @@ -64,18 +64,6 @@ footer { } } -div[role="menu"][data-headlessui-state] { - @apply left-0 right-auto; -} - -div[id^="headlessui-menu-items"] { - @apply rounded-none; - - > a { - @apply py-3.5; - } -} - /* should be fixed in Nextra 4 */ ._max-w-\[90rem\] { @apply container; diff --git a/tailwind.config.ts b/tailwind.config.ts index 25a9b33abd..53925964d1 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -81,6 +81,8 @@ const config: Config = { "show-overflow": "show-overflow var(--animation-duration, 12s) var(--animation-delay, 1s) var(--animation-direction, forwards) ease infinite", "fade-in": "fade-in var(--animation-duration, 200ms) ease-out forwards", + "fade-out": + "fade-out var(--animation-duration, 200ms) ease-out forwards", }, keyframes: { scroll: { @@ -108,6 +110,9 @@ const config: Config = { from: { opacity: "0" }, to: { opacity: "1" }, }, + "fade-out": { + to: { opacity: "0" }, + }, }, }, }, From 085efc60db7a3f657fed8fc46342994de3397add Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Tue, 14 Oct 2025 23:10:06 +0200 Subject: [PATCH 2/7] Install Base UI --- package.json | 1 + pnpm-lock.yaml | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/package.json b/package.json index 2bc6135aee..1a92a39961 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "validate:snippets": "node scripts/validate-snippets.js" }, "dependencies": { + "@base-ui-components/react": "1.0.0-beta.4", "@codemirror/autocomplete": "^6.18.6", "@codemirror/commands": "^6.3.3", "@codemirror/language": "^6.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2458c6023..136820c02d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: .: dependencies: + '@base-ui-components/react': + specifier: 1.0.0-beta.4 + version: 1.0.0-beta.4(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@codemirror/autocomplete': specifier: ^6.18.6 version: 6.18.7 @@ -883,6 +886,27 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@base-ui-components/react@1.0.0-beta.4': + resolution: {integrity: sha512-sPYKj26gbFHD2ZsrMYqQshXnMuomBodzPn+d0dDxWieTj232XCQ9QGt9fU9l5SDGC9hi8s24lDlg9FXPSI7T8A==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui-components/utils@0.1.2': + resolution: {integrity: sha512-aEitDGpMsYO2qnSpYOwZNykn9Rzn2ioyEVk2fyDRH7t+TIHVKpp9CeV7SPTq43M9mMSDxQ+7UeZJVkrj2dCVIQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -5174,6 +5198,9 @@ packages: remove-trailing-separator@1.1.0: resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -6826,6 +6853,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@base-ui-components/react@1.0.0-beta.4(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@base-ui-components/utils': 0.1.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + tabbable: 6.2.0 + use-sync-external-store: 1.5.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + + '@base-ui-components/utils@0.1.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.5.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + '@braintree/sanitize-url@7.1.1': {} '@chevrotain/cst-dts-gen@11.0.3': @@ -11909,6 +11961,8 @@ snapshots: remove-trailing-separator@1.1.0: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} From 38d0aeb552dc7ac194097260623e0b36162f0ff0 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Tue, 14 Oct 2025 23:12:05 +0200 Subject: [PATCH 3/7] Recover commented out code --- src/components/navbar/navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index f3ef24c704..92f6c8eb1a 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -122,7 +122,7 @@ export function Navbar({ items }: NavBarProps): ReactElement { )}
} > From 3c07ac232d28d19283b808dc8680afdcd6ba3517 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Thu, 16 Oct 2025 18:48:23 +0200 Subject: [PATCH 4/7] Clean up --- src/components/navbar/navbar.tsx | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 92f6c8eb1a..c015afbb99 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -79,21 +79,13 @@ export function Navbar({ items }: NavBarProps): ReactElement { const activeRoute = useFSRoute() const { menu, setMenu } = useMenu() - const handleNavigationMenuChange = useCallback((value: unknown) => { - if (typeof document === "undefined") { - return - } - document.body.style.overflow = value != null ? "hidden" : "auto" - }, []) - useEffect(() => { - if (typeof document === "undefined") { - return - } - return () => { + useEffect( + () => () => { document.body.style.overflow = "auto" - } - }, []) + }, + [], + ) return (
{ + document.body.style.overflow = value != null ? "hidden" : "auto" + }} className="-mx-2 flex overflow-x-auto px-2 py-1.5 xl:absolute xl:left-1/2 xl:-translate-x-1/2" - render={props =>
} > {items.map(pageOrMenu => { From b48f5ea75757c55627a443f4e39faf8ee66cd2dc Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Thu, 16 Oct 2025 19:18:14 +0200 Subject: [PATCH 5/7] Configure Playwright with env vars. --- playwright.config.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index de0ed7f7d4..3baf5eab74 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from "@playwright/test" +const JSON_REPORT = process.env.JSON_REPORT === "1" +const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || 3000 + /** * @see https://playwright.dev/docs/test-configuration */ @@ -10,9 +13,11 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: JSON_REPORT + ? [["list"], ["json", { outputFile: "./test/test-results.json" }]] + : [["html"]], use: { - baseURL: "http://localhost:3000", + baseURL: `http://localhost:${DEV_SERVER_PORT}`, trace: "on-first-retry", }, @@ -25,7 +30,7 @@ export default defineConfig({ webServer: { command: "pnpm dev", - url: "http://localhost:3000", + url: `http://localhost:${DEV_SERVER_PORT}`, reuseExistingServer: !process.env.CI, }, }) From 6daab1d5ae42387c60c79dc09df6548e4dcb7f39 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Thu, 16 Oct 2025 19:18:48 +0200 Subject: [PATCH 6/7] Gitignore test-results.json --- test/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 test/.gitignore diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000000..b04b1adcdd --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +test-results.json From ad01f475c9c0bec21799a36a4f770906ad0fa781 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Thu, 16 Oct 2025 20:15:07 +0200 Subject: [PATCH 7/7] Add tests for pages that rely on Headless UI --- test/e2e/conf-calendar-menu.spec.ts | 48 ++++++++++++ test/e2e/conf-filters.spec.ts | 116 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/e2e/conf-calendar-menu.spec.ts create mode 100644 test/e2e/conf-filters.spec.ts diff --git a/test/e2e/conf-calendar-menu.spec.ts b/test/e2e/conf-calendar-menu.spec.ts new file mode 100644 index 0000000000..1f70be97f7 --- /dev/null +++ b/test/e2e/conf-calendar-menu.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test" + +test("opens and dismisses calendar menu, all links match expectation", async ({ + page, +}) => { + await page.goto("/conf/2025/schedule") + await page.waitForLoadState("networkidle") + + const calendarButton = page + .locator('button:has-text("Add to calendar")') + .first() + + if ((await calendarButton.count()) === 0) { + test.skip() + } + + await calendarButton.scrollIntoViewIfNeeded() + await expect(calendarButton).toBeVisible() + + // Test aria-expanded attribute + await expect(calendarButton).toHaveAttribute("aria-expanded", "false") + + // Test opening menu + const menuItems = page.locator('[role="menu"]') + await expect(menuItems).not.toBeVisible() + + await calendarButton.click() + await expect(menuItems).toBeVisible() + await expect(calendarButton).toHaveAttribute("aria-expanded", "true") + + // Test menu contains correct links + const icsLink = menuItems.locator('a:has-text("ICS")') + const googleLink = menuItems.locator('a:has-text("Google")') + const outlookLink = menuItems.locator('a:has-text("Outlook")') + + await expect(icsLink).toBeVisible() + await expect(googleLink).toBeVisible() + await expect(outlookLink).toBeVisible() + + await expect(icsLink).toHaveAttribute("href", /data:text\/calendar/) + await expect(googleLink).toHaveAttribute("href", /google\.com/) + await expect(outlookLink).toHaveAttribute("href", /outlook\./) + + // Test closing menu by clicking outside + await page.mouse.click(10, 10) + await expect(menuItems).not.toBeVisible() + await expect(calendarButton).toHaveAttribute("aria-expanded", "false") +}) diff --git a/test/e2e/conf-filters.spec.ts b/test/e2e/conf-filters.spec.ts new file mode 100644 index 0000000000..dd748a3fed --- /dev/null +++ b/test/e2e/conf-filters.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test" + +test("conference schedule filter combinations and results", async ({ + page, +}) => { + await page.goto("/conf/2025/schedule") + await page.waitForLoadState("networkidle") + + const comboboxOptions = page.locator('[role="listbox"]') + + const sessionFormatCombobox = page.getByRole("combobox", { + name: "Session Format", + }) + await expect(sessionFormatCombobox).toBeVisible() + + await sessionFormatCombobox.click() + await expect(comboboxOptions).toBeVisible() + + const graphqlProductionOption = comboboxOptions.locator( + '[role="option"]:has-text("GraphQL in Production")', + ) + await expect(graphqlProductionOption).toBeVisible() + await graphqlProductionOption.click() + + await page.keyboard.press("Escape") + await expect(comboboxOptions).not.toBeVisible() + + const talkCategoryCombobox = page.getByRole("combobox", { + name: "Talk Category", + }) + await expect(talkCategoryCombobox).toBeVisible() + + await talkCategoryCombobox.click() + await expect(comboboxOptions).toBeVisible() + + const securityOption = comboboxOptions.locator( + '[role="option"]:has-text("Security")', + ) + await expect(securityOption).toBeVisible() + await securityOption.click() + + await page.keyboard.press("Escape") + await expect(comboboxOptions).not.toBeVisible() + + const audienceCombobox = page.getByRole("combobox", { name: "Audience" }) + await expect(audienceCombobox).toBeVisible() + + await audienceCombobox.click() + await expect(comboboxOptions).toBeVisible() + + const allAudienceOptions = comboboxOptions.locator('[role="option"]') + const audienceOptionCount = await allAudienceOptions.count() + + expect(audienceOptionCount).toBeGreaterThan(0) + + const intermediateOption = comboboxOptions.locator( + '[role="option"]:has-text("Intermediate")', + ) + await expect(intermediateOption).toBeVisible() + await expect(intermediateOption).not.toHaveAttribute("aria-disabled", "true") + + const beginnerOption = comboboxOptions.locator( + '[role="option"]:has-text("Beginner")', + ) + await expect(beginnerOption).toBeVisible() + await expect(beginnerOption).toHaveAttribute("aria-disabled", "true") + + await page.keyboard.press("Escape") + await expect(comboboxOptions).not.toBeVisible() + + await page.keyboard.press("Escape") + + const sessionLink = page.locator( + 'a[aria-label*="Unlocking Federation Security at Scale in Booking"]', + ) + await expect(sessionLink).toBeVisible() +}) + +test("talk category multi-selection and escape key functionality", async ({ + page, +}) => { + await page.goto("/conf/2025/schedule") + await page.waitForLoadState("networkidle") + + const comboboxOptions = page.locator('[role="listbox"]') + + const talkCategoryCombobox = page.getByRole("combobox", { + name: "Talk Category", + }) + await expect(talkCategoryCombobox).toBeVisible() + + await talkCategoryCombobox.click() + await expect(comboboxOptions).toBeVisible() + + const backendOption = comboboxOptions.locator( + '[role="option"]:has-text("Backend")', + ) + const scalingOption = comboboxOptions.locator( + '[role="option"]:has-text("Scaling")', + ) + + await expect(backendOption).toBeVisible() + await expect(scalingOption).toBeVisible() + + await backendOption.click() + + await talkCategoryCombobox.click() + await expect(comboboxOptions).toBeVisible() + await scalingOption.click() + + await talkCategoryCombobox.click() + await expect(comboboxOptions).toBeVisible() + + await page.keyboard.press("Escape") + await expect(comboboxOptions).not.toBeVisible() +})