From cb74b0a37c223a70d60ac8a373557bc770e338f7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 15:39:17 -0600 Subject: [PATCH 01/12] useOnScreen: memoize IntersectionObserver, fix types --- app/soapbox/hooks/useOnScreen.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/soapbox/hooks/useOnScreen.ts b/app/soapbox/hooks/useOnScreen.ts index 883fde698..de9aa23b2 100644 --- a/app/soapbox/hooks/useOnScreen.ts +++ b/app/soapbox/hooks/useOnScreen.ts @@ -1,13 +1,17 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; -export const useOnScreen = (ref: React.MutableRefObject) => { - const [isIntersecting, setIntersecting] = React.useState(false); +/** Detect whether a given element is on the screen. */ +// https://stackoverflow.com/a/64892655 +export const useOnScreen = (ref: React.RefObject) => { + const [isIntersecting, setIntersecting] = useState(false); - const observer = new IntersectionObserver( - ([entry]) => setIntersecting(entry.isIntersecting), - ); + const observer = useMemo(() => { + return new IntersectionObserver( + ([entry]) => setIntersecting(entry.isIntersecting), + ); + }, []); - React.useEffect(() => { + useEffect(() => { if (ref.current) { observer.observe(ref.current); } From ab8d162f036eb085ff71fc2a5759c1b5fdd01341 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 15:53:22 -0600 Subject: [PATCH 02/12] Account: useLayoutEffect, refactor function calls, fix types, prevent negative maxWidth --- app/soapbox/components/account.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 2901433ec..7ee623586 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; @@ -117,19 +117,17 @@ const Account = ({ emoji, note, }: IAccount) => { - const overflowRef = React.useRef(null); - const actionRef = React.useRef(null); - // @ts-ignore + const overflowRef = useRef(null); + const actionRef = useRef(null); const isOnScreen = useOnScreen(overflowRef); - const [style, setStyle] = React.useState({ visibility: 'hidden' }); + const [style, setStyle] = useState({ visibility: 'hidden' }); const me = useAppSelector((state) => state.me); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const handleAction = () => { - // @ts-ignore - onActionClick(account); + onActionClick!(account); }; const renderAction = () => { @@ -162,12 +160,12 @@ const Account = ({ const intl = useIntl(); - React.useEffect(() => { + useLayoutEffect(() => { const style: React.CSSProperties = {}; const actionWidth = actionRef.current?.clientWidth || 0; if (overflowRef.current) { - style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth; + style.maxWidth = Math.max(0, overflowRef.current.clientWidth - 30 - avatarSize - actionWidth); } else { style.visibility = 'hidden'; } From a433d22ba30217a4a23285244d07ad01e36ff3c3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 16:09:25 -0600 Subject: [PATCH 03/12] Account: don't calculate max-width unnecessarily --- app/soapbox/components/account.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 7ee623586..7e8ffce0c 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -165,7 +165,9 @@ const Account = ({ const actionWidth = actionRef.current?.clientWidth || 0; if (overflowRef.current) { - style.maxWidth = Math.max(0, overflowRef.current.clientWidth - 30 - avatarSize - actionWidth); + if (action && withRelationship && typeof overflowRef.current.style.maxWidth !== 'number') { + style.maxWidth = Math.max(0, overflowRef.current.clientWidth - 30 - avatarSize - actionWidth); + } } else { style.visibility = 'hidden'; } From f6f3973eaccf39598876f985b33e715906ac8b11 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 16:09:59 -0600 Subject: [PATCH 04/12] Account: let avatar and display name be truncated --- app/soapbox/components/account.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 7e8ffce0c..59c0f3fa3 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -195,7 +195,7 @@ const Account = ({ return (
- + {children}} @@ -215,7 +215,7 @@ const Account = ({ -
+
{children}} From aa8f84d3524d8a3a1ffe842c769b2cb21c6fece4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 16:15:00 -0600 Subject: [PATCH 05/12] Menu: move
styles to component --- app/soapbox/components/ui/menu/menu.css | 4 ---- app/soapbox/components/ui/menu/menu.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/soapbox/components/ui/menu/menu.css b/app/soapbox/components/ui/menu/menu.css index 216ffbd7c..96f510304 100644 --- a/app/soapbox/components/ui/menu/menu.css +++ b/app/soapbox/components/ui/menu/menu.css @@ -31,7 +31,3 @@ div:focus[data-reach-menu-list] { [data-reach-menu-link][data-disabled] { @apply opacity-25 cursor-default; } - -[data-reach-menu-popover] hr { - @apply my-1 mx-2 border-t-2 border-gray-100 dark:border-gray-800; -} diff --git a/app/soapbox/components/ui/menu/menu.tsx b/app/soapbox/components/ui/menu/menu.tsx index 4056674ef..c61cc56d2 100644 --- a/app/soapbox/components/ui/menu/menu.tsx +++ b/app/soapbox/components/ui/menu/menu.tsx @@ -37,6 +37,6 @@ const MenuList: React.FC = (props) => { }; /** Divides menu items. */ -const MenuDivider = () =>
; +const MenuDivider = () =>
; export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink }; From 7f6b19aa4a4fe1217a9f5d6d9356a7680226935c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 16:33:08 -0600 Subject: [PATCH 06/12] ProfileDropdown: refactor with Floating UI --- .../ui/components/profile-dropdown.tsx | 104 ++++++++++++------ package.json | 1 + yarn.lock | 40 +++++++ 3 files changed, 113 insertions(+), 32 deletions(-) diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index fa2b5a508..b8bbb127f 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -1,11 +1,13 @@ +import { useFloating } from '@floating-ui/react'; +import clsx from 'clsx'; import throttle from 'lodash/throttle'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import Account from 'soapbox/components/account'; -import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; +import { MenuDivider } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; @@ -39,6 +41,8 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const features = useFeatures(); const intl = useIntl(); + const [visible, setVisible] = useState(false); + const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); const authUsers = useAppSelector((state) => state.auth.users); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); @@ -62,7 +66,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { ); }; - const menu: IMenuItem[] = React.useMemo(() => { + const menu: IMenuItem[] = useMemo(() => { const menu: IMenuItem[] = []; menu.push({ text: renderAccount(account), to: `/@${account.acct}` }); @@ -96,42 +100,78 @@ const ProfileDropdown: React.FC = ({ account, children }) => { return menu; }, [account, authUsers, features]); - React.useEffect(() => { + const toggleVisible = () => setVisible(!visible); + + useEffect(() => { fetchOwnAccountThrottled(); }, [account, authUsers]); return ( - - + <> + - - {menu.map((menuItem, idx) => { - if (menuItem.toggle) { - return ( -
- {menuItem.text} - - {menuItem.toggle} -
- ); - } else if (!menuItem.text) { - return ; - } else { - const Comp: any = menuItem.action ? MenuItem : MenuLink; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link }; - - return ( - - {menuItem.text} - - ); - } - })} -
-
+ {visible && ( +
+ {menu.map((menuItem, i) => ( + + ))} +
+ )} + ); }; +interface MenuItemProps { + className?: string + menuItem: IMenuItem +} + +const MenuItem: React.FC = ({ className, menuItem }) => { + const baseClassName = clsx(className, 'block cursor-pointer truncate px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500'); + + if (menuItem.toggle) { + return ( +
+ {menuItem.text} + + {menuItem.toggle} +
+ ); + } else if (!menuItem.text) { + return ; + } else if (menuItem.action) { + return ( + + ); + } else if (menuItem.to) { + return ( + + {menuItem.text} + + ); + } else { + throw menuItem; + } +}; + export default ProfileDropdown; diff --git a/package.json b/package.json index 466d64c29..e975a8738 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.20.13", + "@floating-ui/react": "^0.19.1", "@fontsource/inter": "^4.5.1", "@fontsource/roboto-mono": "^4.5.8", "@gamestdio/websocket": "^0.3.2", diff --git a/yarn.lock b/yarn.lock index 5874e4b71..6631a07a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1722,6 +1722,34 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.0.tgz#ae7ae7923d41f3d84cb2fd88740a89436610bbec" + integrity sha512-GHUXPEhMEmTpnpIfesFA2KAoMJPb1SPQw964tToQwt+BbGXdhqTCWT1rOb0VURGylsxsYxiGMnseJ3IlclVpVA== + +"@floating-ui/dom@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.0.tgz#a60212069cc58961c478037c30eba4b191c75316" + integrity sha512-QXzg57o1cjLz3cGETzKXjI3kx1xyS49DW9l7kV2jw2c8Yftd434t2hllX0sVGn2Q8MtcW/4pNm8bfE1/4n6mng== + dependencies: + "@floating-ui/core" "^1.2.0" + +"@floating-ui/react-dom@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.2.2.tgz#ed256992fd44fcfcddc96da68b4b92f123d61871" + integrity sha512-DbmFBLwFrZhtXgCI2ra7wXYT8L2BN4/4AMQKyu05qzsVji51tXOfF36VE2gpMB6nhJGHa85PdEg75FB4+vnLFQ== + dependencies: + "@floating-ui/dom" "^1.1.1" + +"@floating-ui/react@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.1.tgz#bcaeaf3856dfeea388816f7e66750cab26208376" + integrity sha512-h7hr53rLp+VVvWvbu0dOBvGsLeeZwn1DTLIllIaLYjGWw20YhAgEqegHU+nc7BJ30ttxq4Sq6hqARm0ne6chXQ== + dependencies: + "@floating-ui/react-dom" "^1.2.2" + aria-hidden "^1.1.3" + tabbable "^6.0.1" + "@fontsource/inter@^4.5.1": version "4.5.1" resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-4.5.1.tgz#058d8a02354f3c78e369d452c15d33557ec1b705" @@ -5381,6 +5409,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.1.3: + version "1.2.2" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.2.tgz#8c4f7cc88d73ca42114106fdf6f47e68d31475b8" + integrity sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA== + dependencies: + tslib "^2.0.0" + aria-query@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" @@ -16505,6 +16540,11 @@ tabbable@^5.3.3: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== +tabbable@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.1.tgz#427a09b13c83ae41eed3e88abb76a4af28bde1a6" + integrity sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA== + table@^6.8.1: version "6.8.1" resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" From 53a930c75c81c26d2c54e494ade2c16b4bae1c7e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Feb 2023 16:53:56 -0600 Subject: [PATCH 07/12] ProfileDropdown: dismiss when clicked outside --- .../ui/components/profile-dropdown.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index b8bbb127f..03dce6d55 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -42,7 +42,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const intl = useIntl(); const [visible, setVisible] = useState(false); - const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); + const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); const authUsers = useAppSelector((state) => state.auth.users); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); @@ -102,10 +102,30 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const toggleVisible = () => setVisible(!visible); + const handleWindowClick = (e: MouseEvent) => { + if (e.target) { + const clickWithin = [ + refs.floating.current?.contains(e.target as Node), + (refs.reference.current as HTMLButtonElement | undefined)?.contains(e.target as Node), + ].some(Boolean); + + if (!clickWithin) { + setVisible(false); + } + } + }; + useEffect(() => { fetchOwnAccountThrottled(); }, [account, authUsers]); + useEffect(() => { + window.addEventListener('click', handleWindowClick); + return () => { + window.removeEventListener('click', handleWindowClick); + }; + }, []); + return ( <>