diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 0000000000..92b5706884 --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,12 @@ +--- +"flowbite-react": patch +--- + +Support multiple dropdowns inside a single `` + +### Changes + +- [x] pair each `` with its own `` by declaration order, instead of always resolving the first dropdown of the navbar +- [x] close the other dropdowns of a mega menu when one is opened +- [x] export `MegaMenuContext`, `useMegaMenuContext` and `MegaMenuContextValue` +- [x] stop calling hooks conditionally in `` diff --git a/apps/storybook/src/MegaMenu.stories.tsx b/apps/storybook/src/MegaMenu.stories.tsx index 9af7bddbd3..1cdfe89aa3 100644 --- a/apps/storybook/src/MegaMenu.stories.tsx +++ b/apps/storybook/src/MegaMenu.stories.tsx @@ -3,6 +3,7 @@ import { Button, MegaMenu, MegaMenuDropdown, + MegaMenuDropdownToggle, NavbarBrand, NavbarCollapse, NavbarLink, @@ -106,3 +107,64 @@ const Template: StoryFn = (args) => ( export const Default = Template.bind({}); Default.args = {}; + +const MultipleDropdownsTemplate: StoryFn = (args) => ( + + + + Flowbite + + + + Home + + Company + + + Marketing + + Contact + + + + + + + + +); + +export const MultipleDropdowns = MultipleDropdownsTemplate.bind({}); +MultipleDropdowns.args = {}; diff --git a/apps/web/content/docs/components/mega-menu.mdx b/apps/web/content/docs/components/mega-menu.mdx index 69ad142fa2..72d4ab404f 100644 --- a/apps/web/content/docs/components/mega-menu.mdx +++ b/apps/web/content/docs/components/mega-menu.mdx @@ -23,6 +23,14 @@ This example of a mega menu dropdown can be used to also show an icon near the t +## Multiple dropdowns + +Use this example to show more than one mega menu dropdown inside the same navbar. Pair each `` with a `` by declaring them in the same order: the first toggle controls the first dropdown, the second toggle the second one, and so on. The toggles and the dropdowns do not need to be siblings, so the toggles can stay inside `` while the dropdowns span the full width of the navbar. + +Opening a dropdown closes any other dropdown of the same mega menu. Add a `hidden` class to a `` to have it start closed, and a `w-full` class to have it span its own row instead of sitting next to the navbar links. + + + ## Full width dropdown Use this example to show a mega menu dropdown that spans the entire width of the document page. diff --git a/apps/web/examples/megaMenu/index.ts b/apps/web/examples/megaMenu/index.ts index 42c98f2afb..c42dac975e 100644 --- a/apps/web/examples/megaMenu/index.ts +++ b/apps/web/examples/megaMenu/index.ts @@ -2,4 +2,5 @@ export { fullWidth } from "./megaMenu.fullWidth"; export { fullWidthCTA } from "./megaMenu.fullWidthCTA"; export { fullWidthImage } from "./megaMenu.fullWidthImage"; export { icons } from "./megaMenu.icons"; +export { multipleDropdowns } from "./megaMenu.multipleDropdowns"; export { root } from "./megaMenu.root"; diff --git a/apps/web/examples/megaMenu/megaMenu.multipleDropdowns.tsx b/apps/web/examples/megaMenu/megaMenu.multipleDropdowns.tsx new file mode 100644 index 0000000000..1cc76ca40e --- /dev/null +++ b/apps/web/examples/megaMenu/megaMenu.multipleDropdowns.tsx @@ -0,0 +1,179 @@ +import { + MegaMenu, + MegaMenuDropdown, + MegaMenuDropdownToggle, + NavbarBrand, + NavbarCollapse, + NavbarLink, + NavbarToggle, +} from "flowbite-react"; +import { HiChevronDown } from "react-icons/hi"; +import type { CodeData } from "~/components/code-demo"; + +const code = ` +import { + MegaMenu, + MegaMenuDropdown, + MegaMenuDropdownToggle, + NavbarBrand, + NavbarCollapse, + NavbarLink, + NavbarToggle, +} from "flowbite-react"; +import { HiChevronDown } from "react-icons/hi"; + +function Component() { + return ( + + + + Flowbite + + + + Home + + + Company + + + + + + Marketing + + + + Contact + + + + + + + + + ); +} +`; + +function Component() { + return ( + + + + Flowbite + + + + Home + + + Company + + + + + + Marketing + + + + Contact + + + + + + + + + ); +} + +export const multipleDropdowns: CodeData = { + type: "single", + code: { + fileName: "index", + language: "tsx", + code, + }, + githubSlug: "megaMenu/megaMenu.multipleDropdowns.tsx", + component: , +}; diff --git a/packages/ui/src/components/MegaMenu/MegaMenu.test.tsx b/packages/ui/src/components/MegaMenu/MegaMenu.test.tsx index 72f3270e2b..89b12279b7 100644 --- a/packages/ui/src/components/MegaMenu/MegaMenu.test.tsx +++ b/packages/ui/src/components/MegaMenu/MegaMenu.test.tsx @@ -29,6 +29,71 @@ describe("Components / MegaMenu", () => { expect(dropdown).not.toHaveClass("hidden"); }); + it("should pair each with its own ", async () => { + render(); + + const [companyToggle, marketingToggle] = screen.getAllByRole("button"); + const [companyDropdown, marketingDropdown] = screen.getAllByRole("menu"); + + expect(companyToggle.getAttribute("aria-controls")).toEqual(companyDropdown.id); + expect(marketingToggle.getAttribute("aria-controls")).toEqual(marketingDropdown.id); + + expect(companyDropdown.getAttribute("aria-labelledby")).toEqual(companyToggle.id); + expect(marketingDropdown.getAttribute("aria-labelledby")).toEqual(marketingToggle.id); + }); + + it("should hide/show only its own on click ", async () => { + const user = userEvent.setup(); + render(); + + const [companyToggle, marketingToggle] = screen.getAllByRole("button"); + const [companyDropdown, marketingDropdown] = screen.getAllByRole("menu"); + + expect(companyDropdown).not.toHaveClass("hidden"); + expect(marketingDropdown).not.toHaveClass("hidden"); + + await user.click(marketingToggle); + + expect(marketingDropdown).toHaveClass("hidden"); + expect(marketingToggle).toHaveAttribute("aria-expanded", "false"); + expect(companyDropdown).not.toHaveClass("hidden"); + expect(companyToggle).toHaveAttribute("aria-expanded", "true"); + + await user.click(marketingToggle); + + expect(marketingDropdown).not.toHaveClass("hidden"); + expect(marketingToggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("should close the other when one is opened", async () => { + const user = userEvent.setup(); + render(); + + const [companyToggle, marketingToggle] = screen.getAllByRole("button"); + const [companyDropdown, marketingDropdown] = screen.getAllByRole("menu"); + + // both panels are declared without `hidden`, so they start open + await user.click(companyToggle); + await user.click(marketingToggle); + + expect(companyDropdown).toHaveClass("hidden"); + expect(marketingDropdown).toHaveClass("hidden"); + + await user.click(companyToggle); + + expect(companyDropdown).not.toHaveClass("hidden"); + expect(companyToggle).toHaveAttribute("aria-expanded", "true"); + expect(marketingDropdown).toHaveClass("hidden"); + expect(marketingToggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(marketingToggle); + + expect(marketingDropdown).not.toHaveClass("hidden"); + expect(marketingToggle).toHaveAttribute("aria-expanded", "true"); + expect(companyDropdown).toHaveClass("hidden"); + expect(companyToggle).toHaveAttribute("aria-expanded", "false"); + }); + it("should hide/show on click toggle", async () => { const user = userEvent.setup(); render(); @@ -63,6 +128,21 @@ function MegaMenuTest() { ); } +function MultipleMegaMenuTest() { + return ( + + Company + +

Company content

+
+ Marketing + +

Marketing content

+
+
+ ); +} + function MegaMenuDropdownOnlyTest() { return ( diff --git a/packages/ui/src/components/MegaMenu/MegaMenu.tsx b/packages/ui/src/components/MegaMenu/MegaMenu.tsx index 9cab1de06e..01a1e70284 100644 --- a/packages/ui/src/components/MegaMenu/MegaMenu.tsx +++ b/packages/ui/src/components/MegaMenu/MegaMenu.tsx @@ -1,12 +1,13 @@ "use client"; -import { forwardRef } from "react"; +import { forwardRef, useState } from "react"; import { get } from "../../helpers/get"; import { resolveProps } from "../../helpers/resolve-props"; import { useResolveTheme } from "../../helpers/resolve-theme"; import { useThemeProvider } from "../../theme/provider"; import type { NavbarProps, NavbarTheme } from "../Navbar"; import { Navbar } from "../Navbar"; +import { MegaMenuContext } from "./MegaMenuContext"; import type { MegaMenuDropdownTheme } from "./MegaMenuDropdown"; import type { MegaMenuDropdownToggleTheme } from "./MegaMenuDropdownToggle"; import { megaMenuTheme } from "./theme"; @@ -28,7 +29,13 @@ export const MegaMenu = forwardRef((props, ref) => { const mergedProps = resolveProps(props, provider.props?.megaMenu); - return ; + const [openToggleId, setOpenToggleId] = useState(undefined); + + return ( + + + + ); }); MegaMenu.displayName = "MegaMenu"; diff --git a/packages/ui/src/components/MegaMenu/MegaMenuContext.tsx b/packages/ui/src/components/MegaMenu/MegaMenuContext.tsx new file mode 100644 index 0000000000..7335601610 --- /dev/null +++ b/packages/ui/src/components/MegaMenu/MegaMenuContext.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export interface MegaMenuContextValue { + /** `id` of the toggle whose dropdown is currently open, if any. */ + openToggleId: string | undefined; + setOpenToggleId: (id: string | undefined) => void; +} + +export const MegaMenuContext = createContext(undefined); + +/** + * Unlike the other contexts in this library this one does not throw when missing: + * `` also works inside a plain ``, and losing the + * "opening one dropdown closes the others" behavior is preferable to crashing. + */ +export function useMegaMenuContext(): MegaMenuContextValue | undefined { + return useContext(MegaMenuContext); +} diff --git a/packages/ui/src/components/MegaMenu/MegaMenuDropdown.tsx b/packages/ui/src/components/MegaMenu/MegaMenuDropdown.tsx index 8c37378256..5fa42e0a40 100644 --- a/packages/ui/src/components/MegaMenu/MegaMenuDropdown.tsx +++ b/packages/ui/src/components/MegaMenu/MegaMenuDropdown.tsx @@ -8,6 +8,7 @@ import { twMerge } from "../../helpers/tailwind-merge"; import { useThemeProvider } from "../../theme/provider"; import type { ThemingProps } from "../../types"; import { Dropdown, type DropdownTheme } from "../Dropdown"; +import { findPairedToggle, megaMenuDropdownAttr } from "./helpers"; import { megaMenuTheme } from "./theme"; export interface MegaMenuDropdownTheme { @@ -20,8 +21,6 @@ export interface MegaMenuDropdownProps extends ComponentProps<"div">, ThemingPro } export function MegaMenuDropdown(props: MegaMenuDropdownProps) { - const [labelledBy, setLabelledBy] = useState(undefined); - const provider = useThemeProvider(); const theme = useResolveTheme( [megaMenuTheme.dropdown, provider.theme?.megaMenu?.dropdown, props.theme], @@ -45,23 +44,31 @@ export function MegaMenuDropdown(props: MegaMenuDropdownProps) { ); } + return ( + + {children} + + ); +} + +/** + * Rendered when no `toggle` is given: a panel whose visibility is driven by the + * `` declared at the matching position. + * + * Kept as its own component so that these hooks are never called behind the `toggle` + * branch above, which would break the rules of hooks. + */ +function MegaMenuDropdownPanel({ children, ...restProps }: ComponentProps<"div">) { const id = useId(); const ref = useRef(null); + const [labelledBy, setLabelledBy] = useState(undefined); useEffect(() => { - const toggle = ref.current?.closest("nav")?.querySelector('[aria-haspopup="menu"]'); - setLabelledBy(toggle?.id); + setLabelledBy(findPairedToggle(ref.current)?.id); }, []); return ( -