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/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,
},
})
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: {}
diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx
index 536b69d279..c015afbb99 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 (
-
+
+
)
}
@@ -97,7 +79,13 @@ export function Navbar({ items }: NavBarProps): ReactElement {
const activeRoute = useFSRoute()
const { menu, setMenu } = useMenu()
- const [submenuOpen, setSubmenuOpen] = useState(false)
+
+ useEffect(
+ () => () => {
+ document.body.style.overflow = "auto"
+ },
+ [],
+ )
return (
)}
-
- {items.map(pageOrMenu => {
- if (pageOrMenu.display === "hidden") return null
+
{
+ 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"
+ >
+
+ {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 +215,6 @@ export function Navbar({ items }: NavBarProps): ReactElement {
)}
-
)
}
@@ -266,14 +262,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" },
+ },
},
},
},
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
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()
+})