1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-25 11:42:30 +01:00

floating ui take 2

This commit is contained in:
Puyodead1 2023-12-13 18:26:46 -05:00
parent fa960f36e9
commit 2c587af4a8
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
14 changed files with 199 additions and 276 deletions

View File

@ -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<HTMLDivElement>) {
return ({ children }: { children: React.ReactNode }) => {
return <div onClick={onClick}>{children}</div>;
};
}
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 (
<Wrapper size={props.size ?? 32} style={props.style} ref={ref} hasClick={props.onClick !== null} {...clickProp}>
<img
style={{
borderRadius: "50%",
}}
src={user.avatarUrl}
width={props.size ?? 32}
height={props.size ?? 32}
loading="eager"
/>
{props.presence && props.presence.status !== PresenceUpdateStatus.Offline && (
<StatusDot color={app.theme.getStatusColor(props.presence.status)} {...props.statusDotStyle} />
)}
</Wrapper>
<Floating placement="right-start">
<Base>
<Wrapper size={props.size ?? 32} style={props.style} ref={ref} hasClick={props.onClick !== null}>
<img
style={{
borderRadius: "50%",
}}
src={user.avatarUrl}
width={props.size ?? 32}
height={props.size ?? 32}
loading="eager"
/>
{props.presence && props.presence.status !== PresenceUpdateStatus.Offline && (
<StatusDot color={app.theme.getStatusColor(props.presence.status)} {...props.statusDotStyle} />
)}
</Wrapper>
</Base>
<FloatingContent>
<UserProfilePopout user={user as unknown as User} />
</FloatingContent>
</Floating>
);
}

View File

@ -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 (
<>
<ListItem key={item.user?.id} ref={floating.refs.setReference} {...interactions.getReferenceProps()}>
<Floating placement="right-start">
<ListItem key={item.user?.id}>
<Container>
<Wrapper offline={presence?.status === PresenceUpdateStatus.Offline}>
<AvatarWrapper>
@ -106,25 +81,10 @@ function MemberListItem({ item }: Props) {
</Container>
</ListItem>
{open && (
<FloatingPortal>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
>
<div
ref={floating.refs.setFloating}
style={floating.floatingStyles}
{...interactions.getFloatingProps()}
>
<UserProfilePopout user={app.account! as unknown as User} member={item} />
</div>
</motion.div>
</FloatingPortal>
)}
</>
<FloatingContent>
<UserProfilePopout user={app.account! as unknown as User} member={item} />
</FloatingContent>
</Floating>
);
}

View File

@ -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 (
<>
<Floating placement="bottom">
<Section>
<Container>
<AvatarWrapper ref={floating.refs.setReference} {...interactions.getReferenceProps()}>
<AvatarWrapper>
<Avatar popoutPlacement="top" onClick={null} />
<Name>
<Username>{app.account?.username}</Username>
@ -121,25 +97,10 @@ function UserPanel() {
</Container>
</Section>
{open && (
<FloatingPortal>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
>
<div
ref={floating.refs.setFloating}
style={floating.floatingStyles}
{...interactions.getFloatingProps()}
>
<UserProfilePopout user={app.account! as unknown as User} />
</div>
</motion.div>
</FloatingPortal>
)}
</>
<FloatingContent>
<UserProfilePopout user={app.account! as unknown as User} />
</FloatingContent>
</Floating>
);
}

View File

@ -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 <FloatingContext.Provider value={floating}>{children}</FloatingContext.Provider>;
}
export default Floating;

View File

@ -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<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(function PopoverContent(
{ style, ...props },
propRef,
) {
const { context: floatingContext, ...context } = useFloatingContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!floatingContext.open) return null;
return (
<FloatingPortal>
<FloatingFocusManager context={floatingContext}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
>
<div
ref={ref}
style={{ ...context.floatingStyles, ...style, zIndex: 1000, outline: "none" }}
{...context.getFloatingProps(props)}
>
{props.children}
</div>
</motion.div>
</FloatingFocusManager>
</FloatingPortal>
);
});

View File

@ -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<HTMLElement, React.HTMLProps<HTMLElement> & 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 (
<div ref={ref} data-state={context.open ? "open" : "closed"} {...context.getReferenceProps(props)}>
{children}
</div>
);
},
);

View File

@ -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}

View File

@ -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 (
<Container
ref={ref}
style={{
color,
}}
onClick={openPopout}
>
{message.author.username}
</Container>
<Floating placement="right-start">
<FloatingTrigger>
<Container
ref={ref}
style={{
color,
}}
>
{message.author.username}
</Container>
</FloatingTrigger>
<FloatingContent>
<UserProfilePopout user={message.author} />
</FloatingContent>
</Floating>
);
}

View File

@ -0,0 +1,6 @@
import React from "react";
import useFloating from "../hooks/useFloating";
type ContextType = ReturnType<typeof useFloating> | null;
export const FloatingContext = React.createContext<ContextType>(null);

View File

@ -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<string, React.FC<any>>;
export type FloatingElement = {
type: string;
data: UseFloatingData & ReturnType<typeof useInteractions>;
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 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
style={element.data.floatingStyles}
key={index}
{...element.data.getFloatingProps()}
>
<div
ref={element.data.refs.setFloating}
style={{
position: element.data.strategy,
top: `${element.data.y ?? 0}px`,
left: `${element.data.x ?? 0}px`,
zIndex: 10000,
}}
>
<Component {...element.props} />
</div>
</motion.div>
)
);
})}
</>
);
}
}
export const floatingController = new FloatingController({
userPopout: UserProfilePopout,
});

View File

@ -1,9 +0,0 @@
import { FloatingPortal } from "@floating-ui/react";
import { observer } from "mobx-react-lite";
import { floatingController } from "./FloatingController";
function FloatingRender() {
return <FloatingPortal>{floatingController.rendered}</FloatingPortal>;
}
export default observer(FloatingRender);

View File

@ -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<string>();
// const [key, setKey] = useState<string>();
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(
() => ({

View File

@ -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 <Floating />");
}
return context;
};

View File

@ -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(
<BannerContextProvider>
<App />
<ModalRenderer />
<FloatingRenderer />
</BannerContextProvider>
<Theme />
</BrowserRouter>