mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-04 18:59:08 +01:00
update palette component to use floating ui
This commit is contained in:
parent
7db6d19c56
commit
c73a4e5968
@ -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>
|
||||
);
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -139,7 +139,6 @@ const MenuComponent = memo(
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={clsx("menu", className)}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
@ -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";
|
||||
|
33
frontend/app/element/palettebutton.tsx
Normal file
33
frontend/app/element/palettebutton.tsx
Normal 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 };
|
30
frontend/app/element/palettecontent.tsx
Normal file
30
frontend/app/element/palettecontent.tsx
Normal 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 };
|
Loading…
Reference in New Issue
Block a user