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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.palette {
.palette-content {
min-width: 100px;
min-height: 150px;
position: absolute;
@ -13,5 +13,4 @@
border: 1px solid rgba(255, 255, 255, 0.15);
background: #212121;
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 { useEffect, useRef, useState } from "react";
import { Button } from "./button";
import { Palette } from "./palette";
import { PaletteButton } from "./palettebutton";
import { PaletteContent } from "./palettecontent";
const meta: Meta<typeof Palette> = {
title: "Elements/Palette",
@ -14,9 +16,6 @@ const meta: Meta<typeof Palette> = {
className: {
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 = {
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 (
<div
ref={scopeRef}
className="boundary"
style={{ padding: "20px", height: "300px", border: "2px solid black" }}
>
<Button ref={anchorRef} className="ghost grey" onClick={handleAnchorClick}>
<i className="fa-sharp fa-solid fa-face-smile"></i>
</Button>
{isMenuVisible && (
<Palette anchorRef={anchorRef} scopeRef={scopeRef} {...args}>
<div className="boundary" style={{ padding: "20px", height: "500px", border: "2px solid black" }}>
<Palette {...args}>
<PaletteButton className="ghost grey">
<i className="fa-sharp fa-solid fa-face-smile"></i>
</PaletteButton>
<PaletteContent>
<div
style={{
opacity: ".3",
@ -71,8 +45,8 @@ export const DefaultPalette: Story = {
<i className="fa-sharp fa-solid fa-shelves-empty"></i>
<span style={{ fontSize: "11px" }}>Empty</span>
</div>
</Palette>
)}
</PaletteContent>
</Palette>
</div>
);
},

View File

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