update palette component to use floating ui

This commit is contained in:
Red Adaya 2024-10-17 20:19:20 +08:00
parent 7db6d19c56
commit c73a4e5968
8 changed files with 160 additions and 120 deletions

View File

@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { useRef } from "react";
import { EmojiPalette } from "./emojipalette"; import { EmojiPalette } from "./emojipalette";
const meta: Meta<typeof EmojiPalette> = { const meta: Meta<typeof EmojiPalette> = {
@ -26,11 +25,9 @@ type Story = StoryObj<typeof EmojiPalette>;
export const DefaultEmojiPalette: Story = { export const DefaultEmojiPalette: Story = {
render: (args) => { render: (args) => {
const scopeRef = useRef<HTMLDivElement>(null);
return ( return (
<div ref={scopeRef} style={{ padding: "20px", height: "300px", border: "2px solid black" }}> <div style={{ padding: "20px", height: "300px", border: "2px solid black" }}>
<EmojiPalette {...args} scopeRef={scopeRef} /> <EmojiPalette {...args} />
</div> </div>
); );
}, },

View File

@ -2,10 +2,12 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import clsx from "clsx"; import clsx from "clsx";
import React, { memo, useEffect, useRef, useState } from "react"; import React, { memo, useState } from "react";
import { Button } from "./button"; import { Button } from "./button";
import { Input } from "./input"; import { Input } from "./input";
import { Palette } from "./palette"; import { Palette } from "./palette";
import { PaletteButton } from "./palettebutton";
import { PaletteContent } from "./palettecontent";
import "./emojiPalette.less"; import "./emojiPalette.less";
@ -215,28 +217,13 @@ const emojiList = [
{ emoji: "💔", name: "broken heart" }, { emoji: "💔", name: "broken heart" },
]; ];
const EmojiPalette = memo(({ scopeRef, className }: EmojiPaletteProps) => { interface EmojiPaletteProps {
const anchorRef = useRef<HTMLButtonElement>(null); className?: string;
const [isPaletteVisible, setIsPaletteVisible] = useState(false); }
const EmojiPalette = memo(({ className }: EmojiPaletteProps) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (anchorRef.current && !anchorRef.current.contains(event.target as Node)) {
setIsPaletteVisible(false);
}
};
scopeRef?.current?.addEventListener("mousedown", handleClickOutside);
return () => {
scopeRef?.current?.removeEventListener("mousedown", handleClickOutside);
};
}, [scopeRef]);
const handleAnchorClick = () => {
setIsPaletteVisible((prev) => !prev);
};
const handleSearchChange = (val: string) => { const handleSearchChange = (val: string) => {
setSearchTerm(val.toLowerCase()); setSearchTerm(val.toLowerCase());
}; };
@ -245,11 +232,11 @@ const EmojiPalette = memo(({ scopeRef, className }: EmojiPaletteProps) => {
return ( return (
<div className={clsx("emoji-palette", className)}> <div className={clsx("emoji-palette", className)}>
<Button ref={anchorRef} className="ghost grey" onClick={handleAnchorClick}> <Palette>
<i className="fa-sharp fa-solid fa-face-smile"></i> <PaletteButton className="ghost grey">
</Button> <i className="fa-sharp fa-solid fa-face-smile"></i>
{isPaletteVisible && ( </PaletteButton>
<Palette anchorRef={anchorRef} scopeRef={scopeRef} className="emoji-palette-content"> <PaletteContent className="emoji-palette-content">
<Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} /> <Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} />
<div className="emoji-grid"> <div className="emoji-grid">
{filteredEmojis.length > 0 ? ( {filteredEmojis.length > 0 ? (
@ -259,7 +246,6 @@ const EmojiPalette = memo(({ scopeRef, className }: EmojiPaletteProps) => {
className="ghost emoji-button" className="ghost emoji-button"
onClick={() => { onClick={() => {
console.log(`Emoji selected: ${item.emoji}`); console.log(`Emoji selected: ${item.emoji}`);
setIsPaletteVisible(false);
}} }}
> >
{item.emoji} {item.emoji}
@ -269,8 +255,8 @@ const EmojiPalette = memo(({ scopeRef, className }: EmojiPaletteProps) => {
<div className="no-emojis">No emojis found</div> <div className="no-emojis">No emojis found</div>
)} )}
</div> </div>
</Palette> </PaletteContent>
)} </Palette>
</div> </div>
); );
}); });

View File

@ -139,7 +139,6 @@ const MenuComponent = memo(
> >
{children} {children}
</div> </div>
{isOpen && ( {isOpen && (
<div <div
className={clsx("menu", className)} className={clsx("menu", className)}

View File

@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
.palette { .palette-content {
min-width: 100px; min-width: 100px;
min-height: 150px; min-height: 150px;
position: absolute; position: absolute;
@ -13,5 +13,4 @@
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
background: #212121; background: #212121;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
visibility: hidden;
} }

View File

@ -1,8 +1,10 @@
// Story for Palette Component // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { useEffect, useRef, useState } from "react";
import { Button } from "./button";
import { Palette } from "./palette"; import { Palette } from "./palette";
import { PaletteButton } from "./palettebutton";
import { PaletteContent } from "./palettecontent";
const meta: Meta<typeof Palette> = { const meta: Meta<typeof Palette> = {
title: "Elements/Palette", title: "Elements/Palette",
@ -14,9 +16,6 @@ const meta: Meta<typeof Palette> = {
className: { className: {
description: "Custom class for palette styling", description: "Custom class for palette styling",
}, },
anchorRef: {
description: "Reference to the anchor element for positioning",
},
}, },
}; };
@ -25,38 +24,13 @@ type Story = StoryObj<typeof Palette>;
export const DefaultPalette: Story = { export const DefaultPalette: Story = {
render: (args) => { render: (args) => {
const anchorRef = useRef<HTMLButtonElement>(null);
const scopeRef = useRef<HTMLDivElement>(null);
const [isMenuVisible, setIsMenuVisible] = useState(false);
const handleAnchorClick = () => {
setIsMenuVisible((prev) => !prev);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (anchorRef.current && !anchorRef.current.contains(event.target as Node)) {
setIsMenuVisible(false);
}
};
scopeRef?.current?.addEventListener("mousedown", handleClickOutside);
return () => {
scopeRef?.current?.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return ( return (
<div <div className="boundary" style={{ padding: "20px", height: "500px", border: "2px solid black" }}>
ref={scopeRef} <Palette {...args}>
className="boundary" <PaletteButton className="ghost grey">
style={{ padding: "20px", height: "300px", border: "2px solid black" }} <i className="fa-sharp fa-solid fa-face-smile"></i>
> </PaletteButton>
<Button ref={anchorRef} className="ghost grey" onClick={handleAnchorClick}> <PaletteContent>
<i className="fa-sharp fa-solid fa-face-smile"></i>
</Button>
{isMenuVisible && (
<Palette anchorRef={anchorRef} scopeRef={scopeRef} {...args}>
<div <div
style={{ style={{
opacity: ".3", opacity: ".3",
@ -71,8 +45,8 @@ export const DefaultPalette: Story = {
<i className="fa-sharp fa-solid fa-shelves-empty"></i> <i className="fa-sharp fa-solid fa-shelves-empty"></i>
<span style={{ fontSize: "11px" }}>Empty</span> <span style={{ fontSize: "11px" }}>Empty</span>
</div> </div>
</Palette> </PaletteContent>
)} </Palette>
</div> </div>
); );
}, },

View File

@ -1,61 +1,83 @@
// Copyright 2024, Command Line Inc. import { useDismiss, useFloating, useInteractions, type Placement } from "@floating-ui/react";
// SPDX-License-Identifier: Apache-2.0 import {
Children,
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; cloneElement,
import clsx from "clsx"; isValidElement,
import { memo, useEffect, useRef, useState } from "react"; JSXElementConstructor,
import { createPortal } from "react-dom"; memo,
ReactElement,
ReactNode,
useState,
} from "react";
import { PaletteButton, PaletteButtonProps } from "./palettebutton";
import { PaletteContent, PaletteContentProps } from "./palettecontent";
import "./palette.less"; import "./palette.less";
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
interface PaletteProps { interface PaletteProps {
anchorRef: React.RefObject<HTMLElement>; children: ReactNode;
scopeRef: React.RefObject<HTMLElement>;
children: React.ReactNode;
className?: string; className?: string;
placement?: Placement;
onOpenChange?: (isOpen: boolean) => void;
} }
const Palette = memo(({ children, className, anchorRef, scopeRef }: PaletteProps) => { const isPaletteButton = (
const paletteRef = useRef<HTMLDivElement | null>(null); element: ReactElement
const [position, setPosition] = useState({ top: 0, left: 0 }); ): element is ReactElement<PaletteButtonProps, JSXElementConstructor<PaletteButtonProps>> => {
const domRect = useDimensionsWithExistingRef(scopeRef); return element.type === PaletteButton;
const width = domRect?.width ?? 0; };
const height = domRect?.height ?? 0;
useEffect(() => { const isPaletteContent = (
const paletteEl = paletteRef.current; element: ReactElement
const anchorEl = anchorRef.current; ): element is ReactElement<PaletteContentProps, JSXElementConstructor<PaletteContentProps>> => {
if (paletteEl && anchorEl) { return element.type === PaletteContent;
const anchorRect = anchorEl.getBoundingClientRect(); };
let { bottom, left } = anchorRect;
// Check if the palette goes beyond the right edge of the window const Palette = memo(({ children, className, placement, onOpenChange }: PaletteProps) => {
const rightEdge = left + paletteEl.offsetWidth; const [isOpen, setIsOpen] = useState(false);
if (rightEdge > window.innerWidth) {
left = window.innerWidth - paletteEl.offsetWidth - 10; const toggleOpen = () => {
setIsOpen((prev) => !prev);
onOpenChange?.(!isOpen);
};
const { refs, floatingStyles, context } = useFloating({
placement: placement ?? "bottom-start",
open: isOpen,
onOpenChange: setIsOpen,
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
const renderChildren = Children.map(children, (child) => {
if (isValidElement(child)) {
if (isPaletteButton(child)) {
return cloneElement(child as any, {
isActive: isOpen,
ref: refs.setReference,
getReferenceProps,
onClick: toggleOpen,
});
} }
// Check if the palette goes beyond the bottom edge of the window if (isPaletteContent(child)) {
if (bottom + paletteEl.offsetHeight > window.innerHeight) { return isOpen
bottom = anchorRect.top - paletteEl.offsetHeight; ? cloneElement(child as any, {
ref: refs.setFloating,
style: floatingStyles,
getFloatingProps,
})
: null;
} }
setPosition({ top: bottom, left });
} }
}, [anchorRef, scopeRef, width, height]); return child;
});
useEffect(() => { return <>{renderChildren}</>;
if (position.top > 0 && paletteRef.current?.style.visibility !== "visible") {
paletteRef.current.style.visibility = "visible";
}
}, [position.top]);
return createPortal(
<div ref={paletteRef} style={{ top: position.top, left: position.left }} className={clsx("palette", className)}>
{children}
</div>,
document.body
);
}); });
Palette.displayName = "Palette"; Palette.displayName = "Palette";

View File

@ -0,0 +1,33 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx";
import { forwardRef } from "react";
import { Button } from "./button";
interface PaletteButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isActive?: boolean;
children: React.ReactNode;
onClick?: () => void;
getReferenceProps?: () => any;
}
const PaletteButton = forwardRef<HTMLButtonElement, PaletteButtonProps>(
({ isActive, children, onClick, getReferenceProps, className, ...props }, ref) => {
return (
<Button
ref={ref}
className={clsx("ghost grey palette-button", className, { "is-active": isActive })}
onClick={onClick}
{...getReferenceProps?.()}
{...props}
>
{children}
</Button>
);
}
);
PaletteButton.displayName = "PaletteButton";
export { PaletteButton, type PaletteButtonProps };

View File

@ -0,0 +1,30 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx";
import { forwardRef } from "react";
interface PaletteContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
getFloatingProps?: () => any;
}
const PaletteContent = forwardRef<HTMLDivElement, PaletteContentProps>(
({ children, className, getFloatingProps, style, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx("palette-content", className)}
style={style}
{...getFloatingProps?.()}
{...props}
>
{children}
</div>
);
}
);
PaletteContent.displayName = "PaletteContent";
export { PaletteContent, type PaletteContentProps };