diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 10c7726..adf8828 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -7,6 +7,10 @@ import { useAppStore } from "../stores/AppStore"; import Presence from "../stores/objects/Presence"; import User from "../stores/objects/User"; import Container from "./Container"; +import Floating from "./floating/Floating"; +import FloatingContent from "./floating/FloatingContent"; +import FloatingTrigger from "./floating/FloatingTrigger"; +import UserProfilePopout from "./floating/UserProfilePopout"; const Wrapper = styled(Container)<{ size: number; hasClick?: boolean }>` width: ${(props) => props.size}px; @@ -31,6 +35,12 @@ const StatusDot = styled.span<{ color: string; width?: number; height?: number } height: ${(props) => props.height ?? 10}px; `; +function Yes(onClick: React.MouseEventHandler) { + return ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }; +} + interface Props { user?: User | AccountStore; size?: number; @@ -52,30 +62,31 @@ function Avatar(props: Props) { const user = props.user ?? app.account; if (!user) return null; - const openPopout = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // TODO: - }; - - const clickProp = props.onClick === null ? {} : { onClick: props.onClick ?? openPopout }; + const Base = props.onClick ? Yes(props.onClick) : FloatingTrigger; return ( - - - {props.presence && props.presence.status !== PresenceUpdateStatus.Offline && ( - - )} - + + + + + {props.presence && props.presence.status !== PresenceUpdateStatus.Offline && ( + + )} + + + + + + + ); } diff --git a/src/components/MemberList/MemberListItem.tsx b/src/components/MemberList/MemberListItem.tsx index 2e371fe..d37cab6 100644 --- a/src/components/MemberList/MemberListItem.tsx +++ b/src/components/MemberList/MemberListItem.tsx @@ -1,25 +1,15 @@ -import { - FloatingPortal, - flip, - offset, - shift, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; import { PresenceUpdateStatus } from "@spacebarchat/spacebar-api-types/v9"; -import { motion } from "framer-motion"; -import { useState } from "react"; import styled from "styled-components"; import { useAppStore } from "../../stores/AppStore"; import GuildMember from "../../stores/objects/GuildMember"; import User from "../../stores/objects/User"; import Avatar from "../Avatar"; +import Floating from "../floating/Floating"; +import FloatingContent from "../floating/FloatingContent"; +import FloatingTrigger from "../floating/FloatingTrigger"; import UserProfilePopout from "../floating/UserProfilePopout"; -const ListItem = styled.div<{ isCategory?: boolean }>` +const ListItem = styled(FloatingTrigger)<{ isCategory?: boolean }>` padding: ${(props) => (props.isCategory ? "16px 8px 0 0" : "1px 8px 0 0")}; cursor: pointer; user-select: none; @@ -76,24 +66,9 @@ function MemberListItem({ item }: Props) { const app = useAppStore(); const presence = app.presences.get(item.guild.id)?.get(item.user!.id); - const [open, setOpen] = useState(false); - - const floating = useFloating({ - placement: "right-start", - open, - onOpenChange: setOpen, - // whileElementsMounted: autoUpdate, - middleware: [offset(5), flip(), shift()], - }); - - const click = useClick(floating.context); - const dismiss = useDismiss(floating.context); - const role = useRole(floating.context); - const interactions = useInteractions([click, dismiss, role]); - return ( - <> - + + @@ -106,25 +81,10 @@ function MemberListItem({ item }: Props) { - {open && ( - - -
- -
-
-
- )} - + + + +
); } diff --git a/src/components/UserPanel.tsx b/src/components/UserPanel.tsx index f89296e..9f392da 100644 --- a/src/components/UserPanel.tsx +++ b/src/components/UserPanel.tsx @@ -1,16 +1,3 @@ -import { - FloatingPortal, - flip, - offset, - shift, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -import { motion } from "framer-motion"; -import { useState } from "react"; import styled from "styled-components"; import { useAppStore } from "../stores/AppStore"; import User from "../stores/objects/User"; @@ -18,6 +5,9 @@ import Avatar from "./Avatar"; import Icon from "./Icon"; import IconButton from "./IconButton"; import Tooltip from "./Tooltip"; +import Floating from "./floating/Floating"; +import FloatingContent from "./floating/FloatingContent"; +import FloatingTrigger from "./floating/FloatingTrigger"; import UserProfilePopout from "./floating/UserProfilePopout"; const Section = styled.section` @@ -34,7 +24,7 @@ const Container = styled.div` background-color: var(--background-secondary-alt); `; -const AvatarWrapper = styled.div` +const AvatarWrapper = styled(FloatingTrigger)` display: flex; align-items: center; min-width: 120px; @@ -82,28 +72,14 @@ const ActionsWrapper = styled.div` function UserPanel() { const app = useAppStore(); - const [open, setOpen] = useState(false); - - const floating = useFloating({ - placement: "bottom", - open, - onOpenChange: setOpen, - // whileElementsMounted: autoUpdate, - middleware: [offset(5), flip(), shift()], - }); - - const click = useClick(floating.context); - const dismiss = useDismiss(floating.context); - const role = useRole(floating.context); - const interactions = useInteractions([click, dismiss, role]); const openSettingsModal = () => {}; return ( - <> +
- + {app.account?.username} @@ -121,25 +97,10 @@ function UserPanel() {
- {open && ( - - -
- -
-
-
- )} - + + + +
); } diff --git a/src/components/floating/Floating.tsx b/src/components/floating/Floating.tsx new file mode 100644 index 0000000..466b857 --- /dev/null +++ b/src/components/floating/Floating.tsx @@ -0,0 +1,14 @@ +import { FloatingContext } from "../../contexts/FloatingContext"; +import useFloating, { FloatingOptions } from "../../hooks/useFloating"; + +function Floating({ + children, + ...restOptions +}: { + children: React.ReactNode; +} & FloatingOptions) { + const floating = useFloating({ ...restOptions }); + return {children}; +} + +export default Floating; diff --git a/src/components/floating/FloatingContent.tsx b/src/components/floating/FloatingContent.tsx new file mode 100644 index 0000000..7a72a9e --- /dev/null +++ b/src/components/floating/FloatingContent.tsx @@ -0,0 +1,35 @@ +import { FloatingFocusManager, FloatingPortal, useMergeRefs } from "@floating-ui/react"; +import { motion } from "framer-motion"; +import React from "react"; +import useFloatingContext from "../../hooks/useFloatingContext"; + +export default React.forwardRef>(function PopoverContent( + { style, ...props }, + propRef, +) { + const { context: floatingContext, ...context } = useFloatingContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + if (!floatingContext.open) return null; + + return ( + + + +
+ {props.children} +
+
+
+
+ ); +}); diff --git a/src/components/floating/FloatingTrigger.tsx b/src/components/floating/FloatingTrigger.tsx new file mode 100644 index 0000000..53eb010 --- /dev/null +++ b/src/components/floating/FloatingTrigger.tsx @@ -0,0 +1,36 @@ +import { useMergeRefs } from "@floating-ui/react"; +import React from "react"; +import useFloatingContext from "../../hooks/useFloatingContext"; + +interface PopoverTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} + +export default React.forwardRef & PopoverTriggerProps>( + function FloatingTrigger({ children, asChild = false, ...props }, propRef) { + const context = useFloatingContext(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const childrenRef = (children as any).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + // `asChild` allows the user to pass any element as the anchor + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + "data-state": context.open ? "open" : "closed", + }), + ); + } + + return ( +
+ {children} +
+ ); + }, +); diff --git a/src/components/floating/UserProfilePopout.tsx b/src/components/floating/UserProfilePopout.tsx index 59b6400..474f8d3 100644 --- a/src/components/floating/UserProfilePopout.tsx +++ b/src/components/floating/UserProfilePopout.tsx @@ -170,9 +170,10 @@ function UserProfilePopout({ user, member }: Props) { style={{ margin: "22px 16px" }} size={80} onClick={(e) => { - // TODO: open profile modal e.preventDefault(); e.stopPropagation(); + // TODO: open profile modal + logger.debug("open profile modal"); }} user={user} // presence={presence} diff --git a/src/components/messaging/MessageAuthor.tsx b/src/components/messaging/MessageAuthor.tsx index 068b558..df883a6 100644 --- a/src/components/messaging/MessageAuthor.tsx +++ b/src/components/messaging/MessageAuthor.tsx @@ -3,6 +3,10 @@ import React from "react"; import styled from "styled-components"; import { useAppStore } from "../../stores/AppStore"; import { MessageLike } from "../../stores/objects/Message"; +import Floating from "../floating/Floating"; +import FloatingContent from "../floating/FloatingContent"; +import FloatingTrigger from "../floating/FloatingTrigger"; +import UserProfilePopout from "../floating/UserProfilePopout"; const Container = styled.div` font-size: 16px; @@ -34,24 +38,22 @@ function MessageAuthor({ message }: Props) { } }, [message]); - const openPopout = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // TODO: user popout - }; - return ( - - {message.author.username} - + + + + {message.author.username} + + + + + + ); } diff --git a/src/contexts/FloatingContext.tsx b/src/contexts/FloatingContext.tsx new file mode 100644 index 0000000..d383f34 --- /dev/null +++ b/src/contexts/FloatingContext.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import useFloating from "../hooks/useFloating"; + +type ContextType = ReturnType | null; + +export const FloatingContext = React.createContext(null); diff --git a/src/controllers/modals/floating/FloatingController.tsx b/src/controllers/modals/floating/FloatingController.tsx deleted file mode 100644 index b9a2f96..0000000 --- a/src/controllers/modals/floating/FloatingController.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { UseFloatingData, useInteractions } from "@floating-ui/react"; -import { motion } from "framer-motion"; -import { action, computed, makeObservable, observable } from "mobx"; -import UserProfilePopout from "../../../components/floating/UserProfilePopout"; -import GuildMember from "../../../stores/objects/GuildMember"; -import User from "../../../stores/objects/User"; - -function randomUUID() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - // eslint-disable-next-line no-bitwise - const r = (Math.random() * 16) | 0; - // eslint-disable-next-line no-bitwise, no-mixed-operators - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Components = Record>; - -export type FloatingElement = { - type: string; - data: UseFloatingData & ReturnType; - open: boolean; - props: { - user: User; - member?: GuildMember; - }; -}; - -class FloatingController { - elements: (FloatingElement & { key: string })[] = []; - components: Components; - - constructor(components: Components) { - this.components = components; - makeObservable(this, { - elements: observable, - add: action, - remove: action, - rendered: computed, - }); - } - - add(element: FloatingElement) { - console.log(`Add was called with `, element); - const key = randomUUID(); - this.elements = [...this.elements, { ...element, key }]; - return key; - } - - remove(key: string) { - console.log(`Remove was called with `, key); - this.elements = this.elements.filter((element) => element.key !== key); - } - - get rendered() { - return ( - <> - {this.elements.map((element, index) => { - const Component = this.components[element.type]; - return ( - element.open && ( - -
- -
-
- ) - ); - })} - - ); - } -} - -export const floatingController = new FloatingController({ - userPopout: UserProfilePopout, -}); diff --git a/src/controllers/modals/floating/FloatingRenderer.tsx b/src/controllers/modals/floating/FloatingRenderer.tsx deleted file mode 100644 index d8c2dbd..0000000 --- a/src/controllers/modals/floating/FloatingRenderer.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FloatingPortal } from "@floating-ui/react"; -import { observer } from "mobx-react-lite"; -import { floatingController } from "./FloatingController"; - -function FloatingRender() { - return {floatingController.rendered}; -} - -export default observer(FloatingRender); diff --git a/src/hooks/useFloating.tsx b/src/hooks/useFloating.tsx index 4f95a3d..980c0a1 100644 --- a/src/hooks/useFloating.tsx +++ b/src/hooks/useFloating.tsx @@ -10,33 +10,24 @@ import { useInteractions, useRole, } from "@floating-ui/react"; -import { useEffect, useMemo, useState } from "react"; -import { floatingController } from "../controllers/modals/floating/FloatingController"; -import GuildMember from "../stores/objects/GuildMember"; -import User from "../stores/objects/User"; +import { useMemo, useState } from "react"; -interface FloatingProps { - type: "userPopout"; +export interface FloatingOptions { initialOpen?: boolean; - placement: Placement; + placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; - config: { - user: User; - member?: GuildMember; - }; } export default function ({ - type, initialOpen = false, placement, open: controlledOpen, onOpenChange: setControlledOpen, - config, -}: FloatingProps) { +}: // config, +FloatingOptions) { const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); - const [key, setKey] = useState(); + // const [key, setKey] = useState(); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; @@ -56,22 +47,22 @@ export default function ({ const role = useRole(context); const interactions = useInteractions([click, dismiss, role]); - useEffect(() => { - if (open) { - const k = floatingController.add({ - type, - data: { - ...interactions, - ...data, - }, - open, - props: config, - }); - setKey(k); - } else { - key && floatingController.remove(key); - } - }, [open]); + // useEffect(() => { + // if (open) { + // const k = floatingController.add({ + // type, + // data: { + // ...interactions, + // ...data, + // }, + // open, + // props: config, + // }); + // setKey(k); + // } else { + // key && floatingController.remove(key); + // } + // }, [open]); return useMemo( () => ({ diff --git a/src/hooks/useFloatingContext.tsx b/src/hooks/useFloatingContext.tsx new file mode 100644 index 0000000..bd7c526 --- /dev/null +++ b/src/hooks/useFloatingContext.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { FloatingContext } from "../contexts/FloatingContext"; + +export default () => { + const context = React.useContext(FloatingContext); + + if (context == null) { + throw new Error("Floating components must be wrapped in "); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index 22f2e0c..7983116 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,7 +21,6 @@ import App from "./App"; import { BannerContextProvider } from "./contexts/BannerContext"; import Theme from "./contexts/Theme"; import ModalRenderer from "./controllers/modals/ModalRenderer"; -import FloatingRenderer from "./controllers/modals/floating/FloatingRenderer"; import "./index.css"; import { calendarStrings } from "./utils/i18n"; @@ -34,7 +33,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( -