diff --git a/package-lock.json b/package-lock.json index 520ddc19..0e5d8cbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@commitlint/cli": "16.1.0", "@commitlint/config-conventional": "16.0.0", + "@floating-ui/react-dom": "0.4.3", "@fontsource/roboto": "4.5.1", "@playwright/test": "1.18.0", "@sentry/react": "6.16.1", @@ -2183,6 +2184,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@floating-ui/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.3.1.tgz", + "integrity": "sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==", + "dev": true + }, + "node_modules/@floating-ui/dom": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.1.10.tgz", + "integrity": "sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==", + "dev": true, + "dependencies": { + "@floating-ui/core": "^0.3.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.4.3.tgz", + "integrity": "sha512-ZL88ryd9p6sFh9jIC/+05JZoNsogcq6U09cygQjiy757QtQqxIVLQwFag+BAWWYqpNEMO0S60fkqmh8KIAV4oA==", + "dev": true, + "dependencies": { + "@floating-ui/dom": "^0.1.10", + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@fontsource/roboto": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-4.5.1.tgz", @@ -12986,6 +13016,20 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz", + "integrity": "sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==", + "dev": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15154,6 +15198,31 @@ } } }, + "@floating-ui/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.3.1.tgz", + "integrity": "sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==", + "dev": true + }, + "@floating-ui/dom": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.1.10.tgz", + "integrity": "sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==", + "dev": true, + "requires": { + "@floating-ui/core": "^0.3.0" + } + }, + "@floating-ui/react-dom": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.4.3.tgz", + "integrity": "sha512-ZL88ryd9p6sFh9jIC/+05JZoNsogcq6U09cygQjiy757QtQqxIVLQwFag+BAWWYqpNEMO0S60fkqmh8KIAV4oA==", + "dev": true, + "requires": { + "@floating-ui/dom": "^0.1.10", + "use-isomorphic-layout-effect": "^1.1.1" + } + }, "@fontsource/roboto": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-4.5.1.tgz", @@ -23139,6 +23208,13 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "use-isomorphic-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz", + "integrity": "sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==", + "dev": true, + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 16cfdc33..058a888c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@floating-ui/react-dom": "0.4.3", "react-icons": "4.3.1" }, "devDependencies": { diff --git a/src/docs/pages/examples/button-menu/state.tsx b/src/docs/pages/examples/button-menu/state.tsx index 2abb5aaa..3f041617 100644 --- a/src/docs/pages/examples/button-menu/state.tsx +++ b/src/docs/pages/examples/button-menu/state.tsx @@ -35,8 +35,11 @@ export function InputMenuState({ setState((state) => ({ ...state, appearance })) } options={[ + { label: "Elevated", value: "elevated" }, { label: "Filled", value: "filled" }, + { label: "Tonal", value: "tonal" }, { label: "Outlined", value: "outlined" }, + { label: "Text", value: "text" }, ]} /> section > h1, .card > section > h2 { + margin: 0; transition-duration: var(--nk-transition-duration) font-size linear; } diff --git a/src/lib/components/menu/button.tsx b/src/lib/components/menu/button.tsx index 7bcd8993..15063925 100644 --- a/src/lib/components/menu/button.tsx +++ b/src/lib/components/menu/button.tsx @@ -1,5 +1,4 @@ import { Button } from "ninjakit"; -import { ForwardedRef, forwardRef } from "react"; import { MdArrowDropDown, MdArrowDropUp } from "react-icons/md"; import type { ButtonProps } from "../button"; @@ -7,33 +6,27 @@ import { Options, useMenu } from "."; import { Menu } from "./menu"; import styles from "./menu.module.css"; -declare module "react" { - function forwardRef( - render: (props: P, ref: React.Ref) => React.ReactElement | null - ): (props: P & React.RefAttributes) => React.ReactElement | null; -} - -export const ButtonMenu = forwardRef(function ButtonMenu( - { - className: override, - id, - onChange, - options, - ...props - }: Omit & - ButtonProps & { - id: string; - onChange: (value: T) => void; - options: Options; - }, - ref: ForwardedRef -) { +export function ButtonMenu({ + className: override, + id, + onChange, + options, + ...props +}: Omit & + ButtonProps & { + id: string; + onChange: (value: T) => void; + options: Options; + }) { const { className, expanded, handleClickControl, handleKeyDownControl, menuId, + refControl, + refMenu, + style, setExpanded, } = useMenu({ id, override }); @@ -48,17 +41,19 @@ export const ButtonMenu = forwardRef(function ButtonMenu( id={id} onClick={handleClickControl} onKeyDown={handleKeyDownControl} - ref={ref} + ref={refControl} trailingIcon={expanded ? : } /> {expanded && ( - menuId={menuId} onChange={onChange} options={options} + ref={refMenu} setExpanded={setExpanded} + style={style} /> )} ); -}); +} diff --git a/src/lib/components/menu/index.ts b/src/lib/components/menu/index.ts index 64c3565a..ef6ac16d 100644 --- a/src/lib/components/menu/index.ts +++ b/src/lib/components/menu/index.ts @@ -1,4 +1,10 @@ -import { KeyboardEventHandler, ReactNode, useState } from "react"; +import { + flip, + getScrollParents, + shift, + useFloating, +} from "@floating-ui/react-dom"; +import { KeyboardEventHandler, ReactNode, useEffect, useState } from "react"; import { firstHTMLElementChild } from "../../util"; import styles from "./menu.module.css"; @@ -34,6 +40,31 @@ export function useMenu({ override, ].join(" "); const [expanded, setExpanded] = useState(false); + const { x, y, reference, floating, refs, strategy, update } = useFloating({ + middleware: [flip(), shift()], + }); + useEffect(() => { + if (!refs.reference.current || !refs.floating.current) { + return; + } + + const parents = [ + ...getScrollParents(refs.reference.current), + ...getScrollParents(refs.floating.current), + ]; + + parents.forEach((parent) => { + parent.addEventListener("scroll", update); + parent.addEventListener("resize", update); + }); + + return () => { + parents.forEach((parent) => { + parent.removeEventListener("scroll", update); + parent.removeEventListener("resize", update); + }); + }; + }, [refs.reference, refs.floating, update]); const handleClickControl = () => setExpanded(!expanded); const handleKeyDownControl: KeyboardEventHandler = (event) => { const element = input @@ -72,7 +103,14 @@ export function useMenu({ expanded, handleClickControl, handleKeyDownControl, + style: { + left: x ?? "", + position: strategy, + top: y ?? "", + }, menuId, + refControl: reference, + refMenu: floating, setExpanded, }; } diff --git a/src/lib/components/menu/input.tsx b/src/lib/components/menu/input.tsx index a1ce5f32..86830b96 100644 --- a/src/lib/components/menu/input.tsx +++ b/src/lib/components/menu/input.tsx @@ -1,5 +1,4 @@ import { TextInput } from "ninjakit"; -import { ForwardedRef, forwardRef } from "react"; import { MdArrowDropDown, MdArrowDropUp } from "react-icons/md"; import type { InputProps } from "../input"; @@ -7,35 +6,29 @@ import { Options, useMenu } from "."; import { Menu } from "./menu"; import styles from "./menu.module.css"; -declare module "react" { - function forwardRef( - render: (props: P, ref: React.Ref) => React.ReactElement | null - ): (props: P & React.RefAttributes) => React.ReactElement | null; -} - -export const InputMenu = forwardRef(function InputMenu( - { - className: override, - flex, - id, - onChange, - options, - readOnly = true, - ...props - }: Omit & - InputProps & { - onChange: (value: T) => void; - options: Options; - }, - ref: ForwardedRef -) { +export function InputMenu({ + className: override, + flex, + id, + onChange, + options, + readOnly = true, + ...props +}: Omit & + InputProps & { + onChange: (value: T) => void; + options: Options; + }) { const { className, expanded, handleClickControl, handleKeyDownControl, menuId, + refControl, + refMenu, setExpanded, + style, } = useMenu({ flex, id, input: true, override }); return ( @@ -52,18 +45,20 @@ export const InputMenu = forwardRef(function InputMenu( onClickTrailingIcon={handleClickControl} onKeyDown={handleKeyDownControl} readOnly={readOnly} - ref={ref} + ref={refControl} trailingIcon={expanded ? : } /> {expanded && ( - input menuId={menuId} onChange={onChange} options={options} + ref={refMenu} setExpanded={setExpanded} + style={style} /> )} ); -}); +} diff --git a/src/lib/components/menu/menu.tsx b/src/lib/components/menu/menu.tsx index 4b3d3e5c..eba35fb0 100644 --- a/src/lib/components/menu/menu.tsx +++ b/src/lib/components/menu/menu.tsx @@ -1,4 +1,6 @@ -import type { +import { + ForwardedRef, + forwardRef, KeyboardEventHandler, MouseEvent, MouseEventHandler, @@ -9,19 +11,29 @@ import { nextHTMLElementSibling, previousHTMLElementSibling } from "../../util"; import { Options } from "."; import styles from "./menu.module.css"; -export function Menu({ - input, - menuId, - onChange, - options, - setExpanded, -}: { - input?: boolean; - menuId: string; - onChange?: (value: T) => void; - options: Options; - setExpanded: (expanded: boolean) => void; -}) { +declare module "react" { + function forwardRef( + render: (props: P, ref: React.Ref) => React.ReactElement | null + ): (props: P & React.RefAttributes) => React.ReactElement | null; +} + +export const Menu = forwardRef(function Menu( + { + input, + menuId, + onChange, + options, + setExpanded, + ...props + }: { + input?: boolean; + menuId: string; + onChange?: (value: T) => void; + options: Options; + setExpanded: (expanded: boolean) => void; + } & Omit, + ref: ForwardedRef +) { const handleClickMenu: MouseEventHandler = (event) => { const { previousElementSibling } = event.currentTarget; const element = input @@ -82,10 +94,12 @@ export function Menu({ return ( ); -} +});