diff --git a/examples/composite-menus/index.tsx b/examples/composite-menus/index.tsx new file mode 100644 index 0000000000..f01ed92921 --- /dev/null +++ b/examples/composite-menus/index.tsx @@ -0,0 +1,60 @@ +import * as Ariakit from "@ariakit/react"; +import "./style.css"; + +function App() { + const store = Ariakit.useMenuStore(); + return ( + <> + + + + Button A1} /> + + Menu A2 + } + /> + + Hello + + + Button A3} /> + + + + Button B1} /> + + + Menu B2} + /> + + Hello + + + + Button B3} /> + + + + Button C1} /> + + + Menu C2} + /> + + Hello + + + + Button C3} /> + + + + + ); +} + +export default App; diff --git a/examples/composite-menus/style.css b/examples/composite-menus/style.css new file mode 100644 index 0000000000..3f668dfc50 --- /dev/null +++ b/examples/composite-menus/style.css @@ -0,0 +1,175 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgb(255 255 255 / 87%); + background-color: #242424; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #fff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +.menu { + position: relative; + z-index: 50; + display: flex; + max-height: var(--popover-available-height); + min-width: 180px; + flex-direction: column; + overflow: auto; + overscroll-behavior: contain; + border-radius: 0.5rem; + border-width: 1px; + border-style: solid; + border-color: hsl(204deg 20% 88%); + background-color: white; + padding: 0.5rem; + color: black; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 10%), + 0 4px 6px -4px rgb(0 0 0 / 10%); + outline: none !important; +} + +.menu:where(.dark, .dark *) { + border-color: hsl(204deg 4% 24%); + background-color: hsl(204deg 4% 16%); + color: white; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 25%), + 0 4px 6px -4px rgb(0 0 0 / 10%); +} + +.menu-item { + display: flex; + cursor: default; + scroll-margin: 0.5rem; + align-items: center; + gap: 0.5rem; + border-radius: 0.25rem; + padding: 0.5rem; + outline: none !important; +} + +.menu-item[aria-disabled="true"] { + opacity: 0.25; +} + +.menu-item[data-active-item] { + background-color: hsl(204deg 100% 40%); + color: white; +} + +.menu-item:active, +.menu-item[data-active] { + background-color: hsl(204deg 100% 32%); + padding-top: 9px; + padding-bottom: 7px; +} diff --git a/examples/composite-menus/test-browser.ts b/examples/composite-menus/test-browser.ts new file mode 100644 index 0000000000..89016e02d6 --- /dev/null +++ b/examples/composite-menus/test-browser.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/previews/composite-menus"); +}); + +test("keyboard interactions", async ({ page }) => { + const a1 = page.getByText("Button A1"); + const a2Menu = page.getByText("Menu A2"); + const b2Menu = page.getByText("Menu B2"); + const c2Menu = page.getByText("Menu C2"); + const c3 = page.getByText("Button C3"); + await expect(a1).toBeVisible(); + await expect(a1).not.toBeFocused(); + a1.focus(); + await expect(a1).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(a2Menu).toBeVisible(); + await expect(a1).not.toBeFocused(); + await expect(a2Menu).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(a2Menu).not.toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(a2Menu).toBeFocused(); + await expect(a2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("Enter"); + await expect(a2Menu).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(a2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowUp"); + await expect(a2Menu).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(a2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowDown"); + await expect(a2Menu).toHaveAttribute("aria-expanded", "false"); + await expect(a2Menu).not.toBeFocused(); + await expect(b2Menu).toBeVisible(); + await expect(b2Menu).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(b2Menu).not.toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(b2Menu).toBeFocused(); + await expect(b2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("Enter"); + await expect(b2Menu).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(b2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowUp"); + await expect(b2Menu).toHaveAttribute("aria-expanded", "false"); + await expect(b2Menu).not.toBeFocused(); + await expect(a2Menu).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(a2Menu).not.toBeFocused(); + await expect(b2Menu).toBeFocused(); + await expect(b2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowDown"); + await expect(b2Menu).toHaveAttribute("aria-expanded", "false"); + await expect(b2Menu).not.toBeFocused(); + await expect(c2Menu).toBeVisible(); + await expect(c2Menu).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(c2Menu).not.toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(c2Menu).toBeFocused(); + await expect(c2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("Enter"); + await expect(c2Menu).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(c2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowUp"); + await expect(c2Menu).toHaveAttribute("aria-expanded", "false"); + await expect(c2Menu).not.toBeFocused(); + await expect(b2Menu).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(b2Menu).not.toBeFocused(); + await expect(c2Menu).toBeFocused(); + await expect(c2Menu).toHaveAttribute("aria-expanded", "false"); + await page.keyboard.press("ArrowDown"); + await expect(c2Menu).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(c2Menu).toHaveAttribute("aria-expanded", "false"); + await expect(c2Menu).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(c2Menu).not.toBeFocused(); + await expect(c3).toBeVisible(); + await expect(c3).toBeFocused(); +}); diff --git a/packages/ariakit-react-core/src/composite/composite-item.tsx b/packages/ariakit-react-core/src/composite/composite-item.tsx index 0f531421e6..de26d2b4ab 100644 --- a/packages/ariakit-react-core/src/composite/composite-item.tsx +++ b/packages/ariakit-react-core/src/composite/composite-item.tsx @@ -44,7 +44,7 @@ import type { Props } from "../utils/types.ts"; import { CompositeItemContext, CompositeRowContext, - useCompositeContext, + useCompositeScopedContext, } from "./composite-context.tsx"; import type { CompositeStore } from "./composite-store.ts"; import { @@ -188,7 +188,7 @@ export const useCompositeItem = createHook( "aria-posinset": ariaPosInSetProp, ...props }) { - const context = useCompositeContext(); + const context = useCompositeScopedContext(); store = store || context; const id = useId(props.id); diff --git a/packages/ariakit-react-core/src/composite/composite.tsx b/packages/ariakit-react-core/src/composite/composite.tsx index 9d62e4b4dc..2ce05f627e 100644 --- a/packages/ariakit-react-core/src/composite/composite.tsx +++ b/packages/ariakit-react-core/src/composite/composite.tsx @@ -29,7 +29,7 @@ import { import { createElement, createHook, forwardRef } from "../utils/system.tsx"; import type { Props } from "../utils/types.ts"; import { - CompositeContextProvider, + CompositeScopedContextProvider, useCompositeProviderContext, } from "./composite-context.tsx"; import type { CompositeStore, CompositeStoreItem } from "./composite-store.ts"; @@ -417,9 +417,9 @@ export const useComposite = createHook( props = useWrapElement( props, (element) => ( - + {element} - + ), [store], );