mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-06 19:18:22 +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
|
// 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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -139,7 +139,6 @@ const MenuComponent = memo(
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={clsx("menu", className)}
|
className={clsx("menu", className)}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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";
|
||||||
|
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