mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
workspace switcher + new elements (in storybook) (#1148)
This commit is contained in:
parent
7e6f96348f
commit
55c8e5a213
5
frontend/app/asset/thunder.svg
Normal file
5
frontend/app/asset/thunder.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g opacity="0.6">
|
||||
<path d="M14.108 5.99867H8.84133L9.79867 0.25L2 9.99867H7.26667L6.30933 15.7473L14.108 5.99867Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 245 B |
8
frontend/app/asset/workspace.svg
Normal file
8
frontend/app/asset/workspace.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g opacity="0.8">
|
||||
<path d="M12.0832 6.84115C13.694 6.84115 14.9998 5.53531 14.9998 3.92448C14.9998 2.31365 13.694 1.00781 12.0832 1.00781C10.4723 1.00781 9.1665 2.31365 9.1665 3.92448C9.1665 5.53531 10.4723 6.84115 12.0832 6.84115Z" fill="white"/>
|
||||
<path d="M3.91667 6.84115C5.5275 6.84115 6.83333 5.53531 6.83333 3.92448C6.83333 2.31365 5.5275 1.00781 3.91667 1.00781C2.30584 1.00781 1 2.31365 1 3.92448C1 5.53531 2.30584 6.84115 3.91667 6.84115Z" fill="white"/>
|
||||
<path d="M12.0832 15.0052C13.694 15.0052 14.9998 13.6994 14.9998 12.0885C14.9998 10.4777 13.694 9.17188 12.0832 9.17188C10.4723 9.17188 9.1665 10.4777 9.1665 12.0885C9.1665 13.6994 10.4723 15.0052 12.0832 15.0052Z" fill="white"/>
|
||||
<path d="M3.91667 15.0052C5.5275 15.0052 6.83333 13.6994 6.83333 12.0885C6.83333 10.4777 5.5275 9.17188 3.91667 9.17188C2.30584 9.17188 1 10.4777 1 12.0885C1 13.6994 2.30584 15.0052 3.91667 15.0052Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
57
frontend/app/element/avatar.less
Normal file
57
frontend/app/element/avatar.less
Normal file
@ -0,0 +1,57 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--main-text-color);
|
||||
font-size: 18px;
|
||||
text-transform: uppercase;
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
|
||||
&.online {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background-color: var(--grey-text-color);
|
||||
}
|
||||
|
||||
&.busy {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
||||
&.away {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-dims-mixin();
|
||||
}
|
68
frontend/app/element/avatar.stories.tsx
Normal file
68
frontend/app/element/avatar.stories.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar } from "./avatar";
|
||||
|
||||
const meta = {
|
||||
title: "Elements/Avatar",
|
||||
component: Avatar,
|
||||
args: {
|
||||
name: "John Doe",
|
||||
status: "offline",
|
||||
imageUrl: "",
|
||||
},
|
||||
argTypes: {
|
||||
name: {
|
||||
control: { type: "text" },
|
||||
description: "The name of the user",
|
||||
},
|
||||
status: {
|
||||
control: { type: "select", options: ["online", "offline", "busy", "away"] },
|
||||
description: "The status of the user",
|
||||
},
|
||||
imageUrl: {
|
||||
control: { type: "text" },
|
||||
description: "Optional image URL for the avatar",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Avatar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Default case (without an image, default status: offline)
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: "John Doe",
|
||||
status: "offline",
|
||||
imageUrl: "",
|
||||
},
|
||||
};
|
||||
|
||||
// Online status with an image
|
||||
export const OnlineWithImage: Story = {
|
||||
args: {
|
||||
name: "Alice Smith",
|
||||
status: "online",
|
||||
imageUrl: "https://i.pravatar.cc/150?u=a042581f4e29026704d",
|
||||
},
|
||||
};
|
||||
|
||||
// Busy status without an image
|
||||
export const BusyWithoutImage: Story = {
|
||||
args: {
|
||||
name: "Michael Johnson",
|
||||
status: "busy",
|
||||
imageUrl: "",
|
||||
},
|
||||
};
|
||||
|
||||
// Away status with an image
|
||||
export const AwayWithImage: Story = {
|
||||
args: {
|
||||
name: "Sarah Connor",
|
||||
status: "away",
|
||||
imageUrl: "https://i.pravatar.cc/150?u=a042581f4e29026704d",
|
||||
},
|
||||
};
|
36
frontend/app/element/avatar.tsx
Normal file
36
frontend/app/element/avatar.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { memo } from "react";
|
||||
|
||||
import clsx from "clsx";
|
||||
import "./avatar.less";
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
status: "online" | "offline" | "busy" | "away";
|
||||
className?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const Avatar = memo(({ name, status = "offline", className, imageUrl }: AvatarProps) => {
|
||||
const getInitials = (name: string) => {
|
||||
const nameParts = name.split(" ");
|
||||
const initials = nameParts.map((part) => part[0]).join("");
|
||||
return initials.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("avatar", status, className)} title="status">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={`${name}'s avatar`} className="avatar-image" />
|
||||
) : (
|
||||
<div className="avatar-initials">{getInitials(name)}</div>
|
||||
)}
|
||||
<div className={`status-indicator ${status}`} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Avatar.displayName = "Avatar";
|
||||
|
||||
export { Avatar };
|
@ -1,6 +1,8 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.button {
|
||||
// override default button appearance
|
||||
border: 1px solid transparent;
|
||||
@ -155,150 +157,10 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// customs styles here
|
||||
&.border-radius-2 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.border-radius-3 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.border-radius-4 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.border-radius-5 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.border-radius-6 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.border-radius-10 {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.vertical-padding-0 {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
&.vertical-padding-1 {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
&.vertical-padding-2 {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
&.vertical-padding-3 {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
&.vertical-padding-4 {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&.vertical-padding-5 {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
&.vertical-padding-6 {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
&.vertical-padding-7 {
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
&.vertical-padding-8 {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&.vertical-padding-9 {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
&.vertical-padding-10 {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-1 {
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-2 {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-3 {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-4 {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-5 {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-6 {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-7 {
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-8 {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-9 {
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
&.horizontal-padding-10 {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
&.font-size-11 {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.font-weight-500 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.font-weight-600 {
|
||||
font-weight: 600;
|
||||
}
|
||||
// Include mixins
|
||||
.border-radius-mixin();
|
||||
.vertical-padding-mixin();
|
||||
.horizontal-padding-mixin();
|
||||
.font-size-mixin();
|
||||
.font-weight-mixin();
|
||||
}
|
||||
|
@ -2,22 +2,22 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import clsx from "clsx";
|
||||
import { Children, forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react";
|
||||
import { forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import "./button.less";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
as?: keyof JSX.IntrinsicElements | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
const Button = memo(
|
||||
forwardRef<HTMLButtonElement, ButtonProps>(({ children, disabled, className = "", ...props }: ButtonProps, ref) => {
|
||||
forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, disabled, className = "", as: Component = "button", ...props }: ButtonProps, ref) => {
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement);
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
// Check if the className contains any of the categories: solid, outlined, or ghost
|
||||
const containsButtonCategory = /(solid|outline|ghost)/.test(className);
|
||||
// If no category is present, default to 'solid'
|
||||
@ -29,17 +29,18 @@ const Button = memo(
|
||||
const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Component
|
||||
ref={btnRef}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={clsx("button", finalClassName)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{childrenArray}
|
||||
</button>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
})
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
64
frontend/app/element/collapsiblemenu.less
Normal file
64
frontend/app/element/collapsiblemenu.less
Normal file
@ -0,0 +1,64 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.collapsible-menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapsible-menu-item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapsible-menu-item-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collapsible-menu-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsible-menu-item-icon {
|
||||
margin-right: 10px; /* Space between icon and text */
|
||||
}
|
||||
|
||||
.collapsible-menu-item-text {
|
||||
.ellipsis();
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nested-list {
|
||||
list-style: none;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.nested-list.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nested-list.closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-menu-item-button {
|
||||
padding: 10px;
|
||||
color: var(--main-text-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-grey-hover-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-menu-item-button.clickable:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
170
frontend/app/element/collapsiblemenu.stories.tsx
Normal file
170
frontend/app/element/collapsiblemenu.stories.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar } from "./avatar";
|
||||
import { CollapsibleMenu } from "./collapsiblemenu";
|
||||
|
||||
const meta: Meta<typeof CollapsibleMenu> = {
|
||||
title: "Elements/CollapsibleMenu",
|
||||
component: CollapsibleMenu,
|
||||
argTypes: {
|
||||
items: { control: "object" },
|
||||
renderItem: { control: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Container style for limiting the width to 360px
|
||||
const Container = (props: any) => (
|
||||
<div
|
||||
style={{ width: "360px", margin: "0 auto", border: "1px solid #ccc", padding: "10px", boxSizing: "border-box" }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const basicItems = [
|
||||
{
|
||||
label: "Inbox",
|
||||
icon: <i className="fa-sharp fa-solid fa-inbox"></i>,
|
||||
onClick: () => console.log("Inbox clicked"),
|
||||
},
|
||||
{
|
||||
label: "Sent Mail",
|
||||
icon: <i className="fa-sharp fa-solid fa-paper-plane"></i>,
|
||||
onClick: () => console.log("Sent Mail clicked"),
|
||||
},
|
||||
{
|
||||
label: "Drafts",
|
||||
icon: <i className="fa-sharp fa-solid fa-drafting-compass"></i>,
|
||||
onClick: () => console.log("Drafts clicked"),
|
||||
},
|
||||
];
|
||||
|
||||
const nestedItems = [
|
||||
{
|
||||
label: "Inbox",
|
||||
icon: <i className="fa-sharp fa-solid fa-inbox"></i>,
|
||||
onClick: () => console.log("Inbox clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Starred",
|
||||
icon: <i className="fa-sharp fa-solid fa-star"></i>,
|
||||
onClick: () => console.log("Starred clicked"),
|
||||
},
|
||||
{
|
||||
label: "Important",
|
||||
icon: <i className="fa-sharp fa-solid fa-star"></i>,
|
||||
onClick: () => console.log("Important clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sent Mail",
|
||||
icon: <i className="fa-sharp fa-solid fa-paper-plane"></i>,
|
||||
onClick: () => console.log("Sent Mail clicked"),
|
||||
},
|
||||
{
|
||||
label: "Drafts",
|
||||
icon: <i className="fa-sharp fa-solid fa-drafting-compass"></i>,
|
||||
onClick: () => console.log("Drafts clicked"),
|
||||
},
|
||||
];
|
||||
|
||||
const customRenderItem = (
|
||||
item: MenuItem,
|
||||
isOpen: boolean,
|
||||
handleClick: (e: React.MouseEvent<any>, item: MenuItem, itemKey: string) => void
|
||||
) => (
|
||||
<div className="custom-list-item">
|
||||
<span className="custom-list-item-icon" onClick={(e) => handleClick(e, item, `${item.label}`)}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="custom-list-item-text" onClick={(e) => handleClick(e, item, `${item.label}`)}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.subItems && <i className={`fa-sharp fa-solid ${isOpen ? "fa-angle-up" : "fa-angle-down"}`}></i>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<CollapsibleMenu {...args} />
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const NestedList: Story = {
|
||||
args: {
|
||||
items: nestedItems,
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<CollapsibleMenu {...args} />
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const CustomRender: Story = {
|
||||
args: {
|
||||
items: nestedItems,
|
||||
renderItem: customRenderItem,
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<CollapsibleMenu {...args} />
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithClickHandlers: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<CollapsibleMenu {...args} />
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const NestedWithClickHandlers: Story = {
|
||||
args: {
|
||||
items: nestedItems,
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<CollapsibleMenu {...args} />
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
const avatarItems = [
|
||||
{
|
||||
label: "John Doe",
|
||||
icon: <Avatar name="John Doe" status="online" className="size-lg" />,
|
||||
onClick: () => console.log("John Doe clicked"),
|
||||
},
|
||||
{
|
||||
label: "Jane Smith",
|
||||
icon: <Avatar name="Jane Smith" status="busy" className="size-lg" />,
|
||||
onClick: () => console.log("Jane Smith clicked"),
|
||||
},
|
||||
{
|
||||
label: "Robert Brown",
|
||||
icon: <Avatar name="Robert Brown" status="away" className="size-lg" />,
|
||||
onClick: () => console.log("Robert Brown clicked"),
|
||||
},
|
||||
{
|
||||
label: "Alice Lambert",
|
||||
icon: <Avatar name="Alice Lambert" status="offline" className="size-lg" />,
|
||||
onClick: () => console.log("Alice Lambert clicked"),
|
||||
},
|
||||
];
|
76
frontend/app/element/collapsiblemenu.tsx
Normal file
76
frontend/app/element/collapsiblemenu.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import clsx from "clsx";
|
||||
import React, { memo, useState } from "react";
|
||||
import "./collapsiblemenu.less";
|
||||
|
||||
interface VerticalNavProps {
|
||||
items: MenuItem[];
|
||||
className?: string;
|
||||
renderItem?: (
|
||||
item: MenuItem,
|
||||
isOpen: boolean,
|
||||
handleClick: (e: React.MouseEvent<any>, item: MenuItem, itemKey: string) => void
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
const CollapsibleMenu = memo(({ items, className, renderItem }: VerticalNavProps) => {
|
||||
const [open, setOpen] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
// Helper function to generate a unique key for each item based on its path in the hierarchy
|
||||
const getItemKey = (item: MenuItem, path: string) => `${path}-${item.label}`;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<any>, item: MenuItem, itemKey: string) => {
|
||||
setOpen((prevState) => ({ ...prevState, [itemKey]: !prevState[itemKey] }));
|
||||
if (item.onClick) {
|
||||
item.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const renderListItem = (item: MenuItem, index: number, path: string) => {
|
||||
const itemKey = getItemKey(item, path);
|
||||
const isOpen = open[itemKey] === true;
|
||||
const hasChildren = item.subItems && item.subItems.length > 0;
|
||||
|
||||
return (
|
||||
<li key={itemKey} className="collapsible-menu-item">
|
||||
{renderItem ? (
|
||||
renderItem(item, isOpen, (e) => handleClick(e, item, itemKey))
|
||||
) : (
|
||||
<div className="collapsible-menu-item-button" onClick={(e) => handleClick(e, item, itemKey)}>
|
||||
<div
|
||||
className={clsx("collapsible-menu-item-content", {
|
||||
"has-children": hasChildren,
|
||||
"is-open": isOpen && hasChildren,
|
||||
})}
|
||||
>
|
||||
{item.icon && <div className="collapsible-menu-item-icon">{item.icon}</div>}
|
||||
<div className="collapsible-menu-item-text">{item.label}</div>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<i className={`fa-sharp fa-solid ${isOpen ? "fa-angle-up" : "fa-angle-down"}`}></i>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && (
|
||||
<ul className={`nested-list ${isOpen ? "open" : "closed"}`}>
|
||||
{item.subItems.map((child, childIndex) =>
|
||||
renderListItem(child, childIndex, `${path}-${index}`)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={clsx("collapsible-menu", className)} role="navigation">
|
||||
{items.map((item, index) => renderListItem(item, index, "root"))}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleMenu.displayName = "CollapsibleMenu";
|
||||
|
||||
export { CollapsibleMenu };
|
40
frontend/app/element/emojipalette.less
Normal file
40
frontend/app/element/emojipalette.less
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.emoji-palette-content {
|
||||
padding: 10px;
|
||||
max-height: 350px;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(35px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
font-size: 24px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-emojis {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
34
frontend/app/element/emojipalette.stories.tsx
Normal file
34
frontend/app/element/emojipalette.stories.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmojiPalette } from "./emojipalette";
|
||||
|
||||
const meta: Meta<typeof EmojiPalette> = {
|
||||
title: "Elements/EmojiPalette",
|
||||
component: EmojiPalette,
|
||||
args: {
|
||||
className: "custom-emoji-palette-class",
|
||||
},
|
||||
argTypes: {
|
||||
className: {
|
||||
description: "Custom class for emoji palette styling",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmojiPalette>;
|
||||
|
||||
export const DefaultEmojiPalette: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<div style={{ padding: "20px", height: "500px", border: "2px solid black" }}>
|
||||
<EmojiPalette {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
className: "custom-emoji-palette-class",
|
||||
},
|
||||
};
|
268
frontend/app/element/emojipalette.tsx
Normal file
268
frontend/app/element/emojipalette.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { type Placement } from "@floating-ui/react";
|
||||
import clsx from "clsx";
|
||||
import { memo, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Input, InputGroup, InputLeftElement } from "./input";
|
||||
import { Popover, PopoverButton, PopoverContent } from "./popover";
|
||||
|
||||
import "./emojipalette.less";
|
||||
|
||||
type EmojiItem = { emoji: string; name: string };
|
||||
|
||||
const emojiList: EmojiItem[] = [
|
||||
// Smileys & Emotion
|
||||
{ emoji: "😀", name: "grinning face" },
|
||||
{ emoji: "😁", name: "beaming face with smiling eyes" },
|
||||
{ emoji: "😂", name: "face with tears of joy" },
|
||||
{ emoji: "🤣", name: "rolling on the floor laughing" },
|
||||
{ emoji: "😃", name: "grinning face with big eyes" },
|
||||
{ emoji: "😄", name: "grinning face with smiling eyes" },
|
||||
{ emoji: "😅", name: "grinning face with sweat" },
|
||||
{ emoji: "😆", name: "grinning squinting face" },
|
||||
{ emoji: "😉", name: "winking face" },
|
||||
{ emoji: "😊", name: "smiling face with smiling eyes" },
|
||||
{ emoji: "😋", name: "face savoring food" },
|
||||
{ emoji: "😎", name: "smiling face with sunglasses" },
|
||||
{ emoji: "😍", name: "smiling face with heart-eyes" },
|
||||
{ emoji: "😘", name: "face blowing a kiss" },
|
||||
{ emoji: "😗", name: "kissing face" },
|
||||
{ emoji: "😙", name: "kissing face with smiling eyes" },
|
||||
{ emoji: "😚", name: "kissing face with closed eyes" },
|
||||
{ emoji: "🙂", name: "slightly smiling face" },
|
||||
{ emoji: "🤗", name: "hugging face" },
|
||||
{ emoji: "🤔", name: "thinking face" },
|
||||
{ emoji: "😐", name: "neutral face" },
|
||||
{ emoji: "😑", name: "expressionless face" },
|
||||
{ emoji: "😶", name: "face without mouth" },
|
||||
{ emoji: "🙄", name: "face with rolling eyes" },
|
||||
{ emoji: "😏", name: "smirking face" },
|
||||
{ emoji: "😣", name: "persevering face" },
|
||||
{ emoji: "😥", name: "sad but relieved face" },
|
||||
{ emoji: "😮", name: "face with open mouth" },
|
||||
{ emoji: "🤐", name: "zipper-mouth face" },
|
||||
{ emoji: "😯", name: "hushed face" },
|
||||
{ emoji: "😪", name: "sleepy face" },
|
||||
{ emoji: "😫", name: "tired face" },
|
||||
{ emoji: "🥱", name: "yawning face" },
|
||||
{ emoji: "😴", name: "sleeping face" },
|
||||
{ emoji: "😌", name: "relieved face" },
|
||||
{ emoji: "😛", name: "face with tongue" },
|
||||
{ emoji: "😜", name: "winking face with tongue" },
|
||||
{ emoji: "😝", name: "squinting face with tongue" },
|
||||
{ emoji: "🤤", name: "drooling face" },
|
||||
{ emoji: "😒", name: "unamused face" },
|
||||
{ emoji: "😓", name: "downcast face with sweat" },
|
||||
{ emoji: "😔", name: "pensive face" },
|
||||
{ emoji: "😕", name: "confused face" },
|
||||
{ emoji: "🙃", name: "upside-down face" },
|
||||
{ emoji: "🫠", name: "melting face" },
|
||||
{ emoji: "😲", name: "astonished face" },
|
||||
{ emoji: "☹️", name: "frowning face" },
|
||||
{ emoji: "🙁", name: "slightly frowning face" },
|
||||
{ emoji: "😖", name: "confounded face" },
|
||||
{ emoji: "😞", name: "disappointed face" },
|
||||
{ emoji: "😟", name: "worried face" },
|
||||
{ emoji: "😤", name: "face with steam from nose" },
|
||||
{ emoji: "😢", name: "crying face" },
|
||||
{ emoji: "😭", name: "loudly crying face" },
|
||||
{ emoji: "😦", name: "frowning face with open mouth" },
|
||||
{ emoji: "😧", name: "anguished face" },
|
||||
{ emoji: "😨", name: "fearful face" },
|
||||
{ emoji: "😩", name: "weary face" },
|
||||
{ emoji: "🤯", name: "exploding head" },
|
||||
{ emoji: "😬", name: "grimacing face" },
|
||||
{ emoji: "😰", name: "anxious face with sweat" },
|
||||
{ emoji: "😱", name: "face screaming in fear" },
|
||||
{ emoji: "🥵", name: "hot face" },
|
||||
{ emoji: "🥶", name: "cold face" },
|
||||
{ emoji: "😳", name: "flushed face" },
|
||||
{ emoji: "🤪", name: "zany face" },
|
||||
{ emoji: "😵", name: "dizzy face" },
|
||||
{ emoji: "🥴", name: "woozy face" },
|
||||
{ emoji: "😠", name: "angry face" },
|
||||
{ emoji: "😡", name: "pouting face" },
|
||||
{ emoji: "🤬", name: "face with symbols on mouth" },
|
||||
{ emoji: "🤮", name: "face vomiting" },
|
||||
{ emoji: "🤢", name: "nauseated face" },
|
||||
{ emoji: "😷", name: "face with medical mask" },
|
||||
|
||||
// Gestures & Hand Signs
|
||||
{ emoji: "👋", name: "waving hand" },
|
||||
{ emoji: "🤚", name: "raised back of hand" },
|
||||
{ emoji: "🖐️", name: "hand with fingers splayed" },
|
||||
{ emoji: "✋", name: "raised hand" },
|
||||
{ emoji: "👌", name: "OK hand" },
|
||||
{ emoji: "✌️", name: "victory hand" },
|
||||
{ emoji: "🤞", name: "crossed fingers" },
|
||||
{ emoji: "🤟", name: "love-you gesture" },
|
||||
{ emoji: "🤘", name: "sign of the horns" },
|
||||
{ emoji: "🤙", name: "call me hand" },
|
||||
{ emoji: "👈", name: "backhand index pointing left" },
|
||||
{ emoji: "👉", name: "backhand index pointing right" },
|
||||
{ emoji: "👆", name: "backhand index pointing up" },
|
||||
{ emoji: "👇", name: "backhand index pointing down" },
|
||||
{ emoji: "👍", name: "thumbs up" },
|
||||
{ emoji: "👎", name: "thumbs down" },
|
||||
{ emoji: "👏", name: "clapping hands" },
|
||||
{ emoji: "🙌", name: "raising hands" },
|
||||
{ emoji: "👐", name: "open hands" },
|
||||
{ emoji: "🙏", name: "folded hands" },
|
||||
|
||||
// Animals & Nature
|
||||
{ emoji: "🐶", name: "dog face" },
|
||||
{ emoji: "🐱", name: "cat face" },
|
||||
{ emoji: "🐭", name: "mouse face" },
|
||||
{ emoji: "🐹", name: "hamster face" },
|
||||
{ emoji: "🐰", name: "rabbit face" },
|
||||
{ emoji: "🦊", name: "fox face" },
|
||||
{ emoji: "🐻", name: "bear face" },
|
||||
{ emoji: "🐼", name: "panda face" },
|
||||
{ emoji: "🐨", name: "koala" },
|
||||
{ emoji: "🐯", name: "tiger face" },
|
||||
{ emoji: "🦁", name: "lion" },
|
||||
{ emoji: "🐮", name: "cow face" },
|
||||
{ emoji: "🐷", name: "pig face" },
|
||||
{ emoji: "🐸", name: "frog face" },
|
||||
{ emoji: "🐵", name: "monkey face" },
|
||||
{ emoji: "🦄", name: "unicorn face" },
|
||||
{ emoji: "🐢", name: "turtle" },
|
||||
{ emoji: "🐍", name: "snake" },
|
||||
{ emoji: "🦋", name: "butterfly" },
|
||||
{ emoji: "🐝", name: "honeybee" },
|
||||
{ emoji: "🐞", name: "lady beetle" },
|
||||
{ emoji: "🦀", name: "crab" },
|
||||
{ emoji: "🐠", name: "tropical fish" },
|
||||
{ emoji: "🐟", name: "fish" },
|
||||
{ emoji: "🐬", name: "dolphin" },
|
||||
{ emoji: "🐳", name: "spouting whale" },
|
||||
{ emoji: "🐋", name: "whale" },
|
||||
{ emoji: "🦈", name: "shark" },
|
||||
|
||||
// Food & Drink
|
||||
{ emoji: "🍏", name: "green apple" },
|
||||
{ emoji: "🍎", name: "red apple" },
|
||||
{ emoji: "🍐", name: "pear" },
|
||||
{ emoji: "🍊", name: "tangerine" },
|
||||
{ emoji: "🍋", name: "lemon" },
|
||||
{ emoji: "🍌", name: "banana" },
|
||||
{ emoji: "🍉", name: "watermelon" },
|
||||
{ emoji: "🍇", name: "grapes" },
|
||||
{ emoji: "🍓", name: "strawberry" },
|
||||
{ emoji: "🫐", name: "blueberries" },
|
||||
{ emoji: "🍈", name: "melon" },
|
||||
{ emoji: "🍒", name: "cherries" },
|
||||
{ emoji: "🍑", name: "peach" },
|
||||
{ emoji: "🥭", name: "mango" },
|
||||
{ emoji: "🍍", name: "pineapple" },
|
||||
{ emoji: "🥥", name: "coconut" },
|
||||
{ emoji: "🥑", name: "avocado" },
|
||||
{ emoji: "🥦", name: "broccoli" },
|
||||
{ emoji: "🥕", name: "carrot" },
|
||||
{ emoji: "🌽", name: "corn" },
|
||||
{ emoji: "🌶️", name: "hot pepper" },
|
||||
{ emoji: "🍔", name: "hamburger" },
|
||||
{ emoji: "🍟", name: "french fries" },
|
||||
{ emoji: "🍕", name: "pizza" },
|
||||
{ emoji: "🌭", name: "hot dog" },
|
||||
{ emoji: "🥪", name: "sandwich" },
|
||||
{ emoji: "🍿", name: "popcorn" },
|
||||
{ emoji: "🥓", name: "bacon" },
|
||||
{ emoji: "🥚", name: "egg" },
|
||||
{ emoji: "🍰", name: "cake" },
|
||||
{ emoji: "🎂", name: "birthday cake" },
|
||||
{ emoji: "🍦", name: "ice cream" },
|
||||
{ emoji: "🍩", name: "doughnut" },
|
||||
{ emoji: "🍪", name: "cookie" },
|
||||
{ emoji: "🍫", name: "chocolate bar" },
|
||||
{ emoji: "🍬", name: "candy" },
|
||||
{ emoji: "🍭", name: "lollipop" },
|
||||
|
||||
// Activities
|
||||
{ emoji: "⚽", name: "soccer ball" },
|
||||
{ emoji: "🏀", name: "basketball" },
|
||||
{ emoji: "🏈", name: "american football" },
|
||||
{ emoji: "⚾", name: "baseball" },
|
||||
{ emoji: "🥎", name: "softball" },
|
||||
{ emoji: "🎾", name: "tennis" },
|
||||
{ emoji: "🏐", name: "volleyball" },
|
||||
{ emoji: "🎳", name: "bowling" },
|
||||
{ emoji: "⛳", name: "flag in hole" },
|
||||
{ emoji: "🚴", name: "person biking" },
|
||||
{ emoji: "🎮", name: "video game" },
|
||||
{ emoji: "🎲", name: "game die" },
|
||||
{ emoji: "🎸", name: "guitar" },
|
||||
{ emoji: "🎺", name: "trumpet" },
|
||||
|
||||
// Miscellaneous
|
||||
{ emoji: "🚀", name: "rocket" },
|
||||
{ emoji: "💖", name: "sparkling heart" },
|
||||
{ emoji: "🎉", name: "party popper" },
|
||||
{ emoji: "🔥", name: "fire" },
|
||||
{ emoji: "🎁", name: "gift" },
|
||||
{ emoji: "❤️", name: "red heart" },
|
||||
{ emoji: "🧡", name: "orange heart" },
|
||||
{ emoji: "💛", name: "yellow heart" },
|
||||
{ emoji: "💚", name: "green heart" },
|
||||
{ emoji: "💙", name: "blue heart" },
|
||||
{ emoji: "💜", name: "purple heart" },
|
||||
{ emoji: "🤍", name: "white heart" },
|
||||
{ emoji: "🤎", name: "brown heart" },
|
||||
{ emoji: "💔", name: "broken heart" },
|
||||
];
|
||||
|
||||
interface EmojiPaletteProps {
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
onSelect?: (_: EmojiItem) => void;
|
||||
}
|
||||
|
||||
const EmojiPalette = memo(({ className, placement, onSelect }: EmojiPaletteProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearchTerm(val.toLowerCase());
|
||||
};
|
||||
|
||||
const handleSelect = (item: { name: string; emoji: string }) => {
|
||||
onSelect?.(item);
|
||||
};
|
||||
|
||||
const filteredEmojis = emojiList.filter((item) => item.name.includes(searchTerm));
|
||||
|
||||
return (
|
||||
<div className={clsx("emoji-palette", className)}>
|
||||
<Popover placement={placement}>
|
||||
<PopoverButton className="ghost grey">
|
||||
<i className="fa-sharp fa-solid fa-face-smile"></i>
|
||||
</PopoverButton>
|
||||
<PopoverContent className="emoji-palette-content">
|
||||
<InputGroup>
|
||||
<InputLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-magnifying-glass"></i>
|
||||
</InputLeftElement>
|
||||
<Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} />
|
||||
</InputGroup>
|
||||
<div className="emoji-grid">
|
||||
{filteredEmojis.length > 0 ? (
|
||||
filteredEmojis.map((item, index) => (
|
||||
<Button key={index} className="ghost emoji-button" onClick={() => handleSelect(item)}>
|
||||
{item.emoji}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
<div className="no-emojis">No emojis found</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojiPalette.displayName = "EmojiPalette";
|
||||
|
||||
export { EmojiPalette };
|
||||
export type { EmojiItem };
|
73
frontend/app/element/expandablemenu.less
Normal file
73
frontend/app/element/expandablemenu.less
Normal file
@ -0,0 +1,73 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.expandable-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.expandable-menu-item,
|
||||
.expandable-menu-item-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px; /* Left and right padding, we'll adjust this for the right side */
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
|
||||
.label {
|
||||
.ellipsis();
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item-group-title {
|
||||
&:hover {
|
||||
background-color: var(--button-grey-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item {
|
||||
&.with-hover-effect {
|
||||
&:hover {
|
||||
background-color: var(--button-grey-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item-left,
|
||||
.expandable-menu-item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandable-menu-item-left {
|
||||
margin-right: 8px; /* Space for the left element */
|
||||
}
|
||||
|
||||
.expandable-menu-item-right {
|
||||
margin-left: auto; /* This keeps the right element (if any) on the far right */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expandable-menu-item-content {
|
||||
flex-grow: 1; /* Ensures the content grows to fill available space between left and right elements */
|
||||
}
|
||||
|
||||
.expandable-menu-item-group-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
margin-left: 16px; /* Retaining left indentation */
|
||||
margin-right: 0; /* Removing right padding */
|
||||
|
||||
&.open {
|
||||
max-height: 1000px; /* Ensure large enough max-height for expansion */
|
||||
}
|
||||
}
|
||||
|
||||
.no-indent .expandable-menu-item-group-content {
|
||||
margin-left: 0; // Remove left indentation when noIndent is true
|
||||
}
|
391
frontend/app/element/expandablemenu.stories.tsx
Normal file
391
frontend/app/element/expandablemenu.stories.tsx
Normal file
@ -0,0 +1,391 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
ExpandableMenu,
|
||||
ExpandableMenuItem,
|
||||
ExpandableMenuItemGroup,
|
||||
ExpandableMenuItemGroupTitle,
|
||||
ExpandableMenuItemLeftElement,
|
||||
ExpandableMenuItemRightElement,
|
||||
type ExpandableMenuItemData,
|
||||
} from "./expandablemenu";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Elements/ExpandableMenu",
|
||||
component: ExpandableMenu,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ExpandableMenu>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ExpandableMenu>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>🏠</ExpandableMenuItemLeftElement>
|
||||
<div>Dashboard</div>
|
||||
<ExpandableMenuItemRightElement>Ctrl + D</ExpandableMenuItemRightElement>
|
||||
</ExpandableMenuItem>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Settings</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>👤</ExpandableMenuItemLeftElement>
|
||||
<div>Profile</div>
|
||||
</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>🔒</ExpandableMenuItemLeftElement>
|
||||
<div>Account</div>
|
||||
</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>More</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Submenu</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>📄</ExpandableMenuItemLeftElement>
|
||||
<div>Item 1</div>
|
||||
</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>📄</ExpandableMenuItemLeftElement>
|
||||
<div>Item 2</div>
|
||||
</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenu>
|
||||
),
|
||||
};
|
||||
|
||||
export const NestedExpandableMenu: Story = {
|
||||
render: () => (
|
||||
<ExpandableMenu>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>🏠</ExpandableMenuItemLeftElement>
|
||||
<div>Home</div>
|
||||
</ExpandableMenuItem>
|
||||
<ExpandableMenuItemGroup isOpen={true}>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemLeftElement>📁</ExpandableMenuItemLeftElement>
|
||||
<div>Categories</div>
|
||||
<ExpandableMenuItemRightElement>{">"}</ExpandableMenuItemRightElement>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemLeftElement>📱</ExpandableMenuItemLeftElement>
|
||||
<div>Electronics</div>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemLeftElement>📱</ExpandableMenuItemLeftElement>
|
||||
<div>Mobile Phones</div>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemLeftElement>🤖</ExpandableMenuItemLeftElement>
|
||||
<div>Android Phones</div>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemLeftElement>🔝</ExpandableMenuItemLeftElement>
|
||||
<div>High-End</div>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>📱</ExpandableMenuItemLeftElement>
|
||||
<div>Samsung Galaxy S Series</div>
|
||||
<ExpandableMenuItemRightElement>Ctrl + 1</ExpandableMenuItemRightElement>
|
||||
</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>
|
||||
<ExpandableMenuItemLeftElement>📱</ExpandableMenuItemLeftElement>
|
||||
<div>Google Pixel</div>
|
||||
<ExpandableMenuItemRightElement>Ctrl + 2</ExpandableMenuItemRightElement>
|
||||
</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Budget</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>Redmi Note Series</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>Realme</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>iPhones</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>iPhone 14</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>iPhone SE</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Laptops</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>Gaming Laptops</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>Ultrabooks</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Appliances</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Kitchen Appliances</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>Microwaves</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>Ovens</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Large Appliances</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>Refrigerators</ExpandableMenuItem>
|
||||
<ExpandableMenuItem>Washing Machines</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroup>
|
||||
<ExpandableMenuItemGroupTitle>Palette</ExpandableMenuItemGroupTitle>
|
||||
<ExpandableMenuItem>
|
||||
<div style={{ width: "400px", height: "500px" }}>test</div>
|
||||
</ExpandableMenuItem>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenuItemGroup>
|
||||
</ExpandableMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const menuData: ExpandableMenuItemData[] = [
|
||||
{
|
||||
type: "item",
|
||||
leftElement: "🏠",
|
||||
content: "Home",
|
||||
id: "16830f20-b3b9-42bb-8cc9-db6f409651d8",
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: "📁",
|
||||
label: "Categories",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
isOpen: true,
|
||||
id: "4564f119-645e-448c-80b7-2f40f887e670",
|
||||
children: [
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: "📱",
|
||||
label: "Electronics",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "596e76eb-d87d-425e-9f6e-1519069ee447",
|
||||
children: [
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: "📱",
|
||||
label: "Mobile Phones",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "0dbb9dff-dad3-4a5a-a6b1-53fea2d811c6",
|
||||
children: [
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: "🤖",
|
||||
label: "Android Phones",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "7cc2a2df-37d8-426e-9235-c1a0902d5843",
|
||||
children: [
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: "🔝",
|
||||
label: "High-End",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "75e709b9-d51b-4054-97e7-6fab33c2f88d",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
leftElement: "📱",
|
||||
content: "Samsung Galaxy S Series",
|
||||
rightElement: "Ctrl + 1",
|
||||
id: "5aaa9050-3e58-4fe5-9ff5-638bded6a6e2",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
leftElement: "📱",
|
||||
content: "Google Pixel",
|
||||
rightElement: "Ctrl + 2",
|
||||
id: "56e7f50f-78fc-4145-8294-e78b39de7501",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Budget",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "194d25a1-8cdd-41fa-a3a9-6f03d8a6ab37",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: "Redmi Note Series",
|
||||
id: "c8b8248a-9c43-4eea-8725-33ae0c783858",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
content: "Realme",
|
||||
id: "d61c762f-7d75-4f69-828c-24b41d2e0d9b",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "iPhones",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "51b05462-1677-4258-87ac-eb18edc0a76c",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: "iPhone 14",
|
||||
id: "0f468f54-0118-4e04-a885-ed3f650fc290",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
content: "iPhone SE",
|
||||
id: "96289d85-c2c5-424b-8157-6d39969ba118",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Laptops",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "881e7d15-e8a0-4286-9004-ecde9a1a89f4",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: "Gaming Laptops",
|
||||
id: "797859e1-50a2-4dca-93c9-1a630ef16498",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
content: "Ultrabooks",
|
||||
id: "b90933d3-aaf1-4aa7-968c-fa3d25201585",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Appliances",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "3c9d098e-a4c7-4dae-a350-557672041ebb",
|
||||
children: [
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Kitchen Appliances",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "541c57e5-6247-4c97-a988-10af0f21c21d",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: "Microwaves",
|
||||
id: "f785da1b-6f60-4411-8444-f928e7ed7e77",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
content: "Ovens",
|
||||
id: "a4d3d2a7-bafa-4b4e-b7bd-88177f6515c3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Large Appliances",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "c5a94ccc-1d42-45c4-aa22-db65816256a9",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: "Refrigerators",
|
||||
id: "21b78bc0-5012-4f80-b552-00787654581e",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
content: "Washing Machines",
|
||||
id: "2eb6eb7d-e624-4eba-88e2-521da1dc8a20",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: {
|
||||
label: "Palette",
|
||||
rightElement: <i className="fa-sharp fa-solid fa-chevron-right"></i>,
|
||||
},
|
||||
id: "34c52670-9267-47b6-a702-957c6f23a00b",
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: <div style={{ width: "400px", height: "500px" }}>test</div>,
|
||||
id: "965c81bb-e08d-4b90-954b-ea69ce33cdce",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const renderExpandableMenu = (menuItems: ExpandableMenuItemData[]) => {
|
||||
return menuItems.map((item) => {
|
||||
if (item.type === "item") {
|
||||
return (
|
||||
<ExpandableMenuItem key={item.id} withHoverEffect={typeof item.content === "string"}>
|
||||
{item.leftElement && (
|
||||
<ExpandableMenuItemLeftElement>{item.leftElement}</ExpandableMenuItemLeftElement>
|
||||
)}
|
||||
<div className="content">{item.content as any}</div>
|
||||
{item.rightElement && (
|
||||
<ExpandableMenuItemRightElement>{item.rightElement}</ExpandableMenuItemRightElement>
|
||||
)}
|
||||
</ExpandableMenuItem>
|
||||
);
|
||||
} else if (item.type === "group") {
|
||||
return (
|
||||
<ExpandableMenuItemGroup key={item.id} isOpen={item.isOpen}>
|
||||
<ExpandableMenuItemGroupTitle>
|
||||
{item.title.leftElement && (
|
||||
<ExpandableMenuItemLeftElement>{item.title.leftElement}</ExpandableMenuItemLeftElement>
|
||||
)}
|
||||
<div className="label">{item.title.label}</div>
|
||||
{item.title.rightElement && (
|
||||
<ExpandableMenuItemRightElement>{item.title.rightElement}</ExpandableMenuItemRightElement>
|
||||
)}
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
{item.children && renderExpandableMenu(item.children)}
|
||||
</ExpandableMenuItemGroup>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const DynamicNestedExpandableMenu: Story = {
|
||||
render: () => <ExpandableMenu>{renderExpandableMenu(menuData)}</ExpandableMenu>,
|
||||
};
|
||||
|
||||
export const NoIndentExpandableMenu: Story = {
|
||||
render: () => <ExpandableMenu noIndent>{renderExpandableMenu(menuData)}</ExpandableMenu>,
|
||||
};
|
200
frontend/app/element/expandablemenu.tsx
Normal file
200
frontend/app/element/expandablemenu.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
// Copyright 2024, Command Line
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { Children, ReactElement, ReactNode, cloneElement, isValidElement, useRef } from "react";
|
||||
|
||||
import "./expandablemenu.less";
|
||||
|
||||
// Define the global atom for managing open groups
|
||||
const openGroupsAtom = atom<{ [key: string]: boolean }>({});
|
||||
|
||||
type BaseExpandableMenuItem = {
|
||||
type: "item" | "group";
|
||||
id?: string;
|
||||
};
|
||||
|
||||
interface ExpandableMenuItemType extends BaseExpandableMenuItem {
|
||||
type: "item";
|
||||
leftElement?: string | ReactNode;
|
||||
rightElement?: string | ReactNode;
|
||||
content?: React.ReactNode | ((props: any) => React.ReactNode);
|
||||
}
|
||||
|
||||
interface ExpandableMenuItemGroupTitleType {
|
||||
leftElement?: string | ReactNode;
|
||||
label: string;
|
||||
rightElement?: string | ReactNode;
|
||||
}
|
||||
|
||||
interface ExpandableMenuItemGroupType extends BaseExpandableMenuItem {
|
||||
type: "group";
|
||||
title: ExpandableMenuItemGroupTitleType;
|
||||
isOpen?: boolean;
|
||||
children?: ExpandableMenuItemData[];
|
||||
}
|
||||
|
||||
type ExpandableMenuItemData = ExpandableMenuItemType | ExpandableMenuItemGroupType;
|
||||
|
||||
type ExpandableMenuProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
noIndent?: boolean;
|
||||
singleOpen?: boolean;
|
||||
};
|
||||
|
||||
const ExpandableMenu = ({ children, className, noIndent = false, singleOpen = false }: ExpandableMenuProps) => {
|
||||
return (
|
||||
<div className={clsx("expandable-menu", className, { "no-indent": noIndent })}>
|
||||
{Children.map(children, (child) => {
|
||||
if (isValidElement(child) && child.type === ExpandableMenuItemGroup) {
|
||||
return cloneElement(child as any, { singleOpen });
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpandableMenuItemProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
withHoverEffect?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("expandable-menu-item", className, {
|
||||
"with-hover-effect": withHoverEffect,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpandableMenuItemGroupTitleProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => {
|
||||
return (
|
||||
<div className={clsx("expandable-menu-item-group-title", className)} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpandableMenuItemGroupProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
singleOpen?: boolean;
|
||||
};
|
||||
|
||||
const ExpandableMenuItemGroup = ({
|
||||
children,
|
||||
className,
|
||||
isOpen,
|
||||
onToggle,
|
||||
singleOpen = false,
|
||||
}: ExpandableMenuItemGroupProps) => {
|
||||
const [openGroups, setOpenGroups] = useAtom(openGroupsAtom);
|
||||
|
||||
// Generate a unique ID for this group using useRef
|
||||
const idRef = useRef<string>();
|
||||
|
||||
if (!idRef.current) {
|
||||
// Generate a unique ID when the component is first rendered
|
||||
idRef.current = `group-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
const id = idRef.current;
|
||||
|
||||
// Determine if the component is controlled or uncontrolled
|
||||
const isControlled = isOpen !== undefined;
|
||||
|
||||
// Get the open state from global atom in uncontrolled mode
|
||||
const actualIsOpen = isControlled ? isOpen : (openGroups[id] ?? false);
|
||||
|
||||
const toggleOpen = () => {
|
||||
const newIsOpen = !actualIsOpen;
|
||||
|
||||
if (isControlled) {
|
||||
// If controlled, call the onToggle callback
|
||||
onToggle?.(newIsOpen);
|
||||
} else {
|
||||
// If uncontrolled, update global atom
|
||||
setOpenGroups((prevOpenGroups) => {
|
||||
if (singleOpen) {
|
||||
// Close all other groups and open this one
|
||||
return { [id]: newIsOpen };
|
||||
} else {
|
||||
// Toggle this group
|
||||
return { ...prevOpenGroups, [id]: newIsOpen };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderChildren = Children.map(children, (child: ReactElement) => {
|
||||
if (child && child.type === ExpandableMenuItemGroupTitle) {
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onClick: () => {
|
||||
child.props.onClick?.();
|
||||
toggleOpen();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return <div className={clsx("expandable-menu-item-group-content", { open: actualIsOpen })}>{child}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={clsx("expandable-menu-item-group", className, { open: actualIsOpen })}>{renderChildren}</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpandableMenuItemLeftElementProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => {
|
||||
return (
|
||||
<div className="expandable-menu-item-left" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpandableMenuItemRightElementProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => {
|
||||
return (
|
||||
<div className="expandable-menu-item-right" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
ExpandableMenu,
|
||||
ExpandableMenuItem,
|
||||
ExpandableMenuItemGroup,
|
||||
ExpandableMenuItemGroupTitle,
|
||||
ExpandableMenuItemLeftElement,
|
||||
ExpandableMenuItemRightElement,
|
||||
};
|
||||
export type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType };
|
54
frontend/app/element/flyoutmenu.less
Normal file
54
frontend/app/element/flyoutmenu.less
Normal file
@ -0,0 +1,54 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
max-width: 400px;
|
||||
min-width: 125px;
|
||||
padding: 2px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: #212121;
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
color: var(--main-text-color);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: -0.12px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
.ellipsis();
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
color: var(--main-text-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--button-text-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useRef } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Menu } from "./menu";
|
||||
import { FlyoutMenu } from "./flyoutmenu";
|
||||
|
||||
const items = [
|
||||
{ label: "Fruit", onClick: (e) => console.log("Clicked Option 1") },
|
||||
@ -53,8 +53,8 @@ const items = [
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Elements/Menu",
|
||||
component: Menu,
|
||||
title: "Elements/FlyoutMenu",
|
||||
component: FlyoutMenu,
|
||||
args: {
|
||||
items: [],
|
||||
children: null,
|
||||
@ -70,7 +70,7 @@ const meta = {
|
||||
description: "The contents of the menu anchor element",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Menu>;
|
||||
} satisfies Meta<typeof FlyoutMenu>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
@ -100,12 +100,12 @@ export const DefaultRendererLeftPositioned: Story = {
|
||||
style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }}
|
||||
>
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Menu {...modifiedArgs}>
|
||||
<FlyoutMenu {...modifiedArgs}>
|
||||
<Button className="grey border-radius-3 vertical-padding-6 horizontal-padding-8">
|
||||
Anchor Element
|
||||
<i className="fa-sharp fa-solid fa-angle-down" style={{ marginLeft: 4 }}></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -140,12 +140,12 @@ export const DefaultRendererRightPositioned: Story = {
|
||||
style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }}
|
||||
>
|
||||
<div style={{ position: "absolute", top: 0, right: 0 }}>
|
||||
<Menu {...modifiedArgs}>
|
||||
<FlyoutMenu {...modifiedArgs}>
|
||||
<Button className="grey border-radius-3 vertical-padding-6 horizontal-padding-8">
|
||||
Anchor Element
|
||||
<i className="fa-sharp fa-solid fa-angle-down" style={{ marginLeft: 4 }}></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -180,12 +180,12 @@ export const DefaultRendererBottomRightPositioned: Story = {
|
||||
style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }}
|
||||
>
|
||||
<div style={{ position: "absolute", bottom: 0, left: 0 }}>
|
||||
<Menu {...modifiedArgs} placement="top-end">
|
||||
<FlyoutMenu {...modifiedArgs} placement="top-end">
|
||||
<Button className="grey border-radius-3 vertical-padding-6 horizontal-padding-8">
|
||||
Anchor Element
|
||||
<i className="fa-sharp fa-solid fa-angle-down" style={{ marginLeft: 4 }}></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -224,7 +224,7 @@ export const DefaultRendererBottomLeftPositioned: Story = {
|
||||
style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }}
|
||||
>
|
||||
<div style={{ position: "absolute", bottom: 0, right: 0 }}>
|
||||
<Menu {...modifiedArgs} placement="top-end">
|
||||
<FlyoutMenu {...modifiedArgs} placement="top-end">
|
||||
<Button
|
||||
ref={anchorRef}
|
||||
className="grey border-radius-3 vertical-padding-6 horizontal-padding-8"
|
||||
@ -232,7 +232,7 @@ export const DefaultRendererBottomLeftPositioned: Story = {
|
||||
Anchor Element
|
||||
<i className="fa-sharp fa-solid fa-angle-down" style={{ marginLeft: 4 }}></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -273,12 +273,12 @@ export const CustomRenderer: Story = {
|
||||
return (
|
||||
<div className="boundary" style={{ padding: "20px", height: "300px", border: "2px solid black" }}>
|
||||
<div style={{ height: "400px" }}>
|
||||
<Menu {...modifiedArgs} renderMenu={renderMenu} renderMenuItem={renderMenuItem}>
|
||||
<FlyoutMenu {...modifiedArgs} renderMenu={renderMenu} renderMenuItem={renderMenuItem}>
|
||||
<Button className="grey border-radius-3 vertical-padding-6 horizontal-padding-8">
|
||||
Anchor Element
|
||||
<i className="fa-sharp fa-solid fa-angle-down" style={{ marginLeft: 4 }}></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -287,60 +287,3 @@ export const CustomRenderer: Story = {
|
||||
items: items,
|
||||
},
|
||||
};
|
||||
|
||||
// export const ContextMenu: Story = {
|
||||
// render: (args) => {
|
||||
// const scopeRef = useRef<HTMLDivElement>(null);
|
||||
// const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
// const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
|
||||
// const handleBlockRightClick = (e: MouseEvent) => {
|
||||
// e.preventDefault(); // Prevent the default context menu
|
||||
// setMenuPosition({ top: e.clientY, left: e.clientX });
|
||||
// setIsMenuVisible(true);
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// const blockElement = scopeRef.current;
|
||||
// if (blockElement) {
|
||||
// blockElement.addEventListener("contextmenu", handleBlockRightClick);
|
||||
// }
|
||||
|
||||
// return () => {
|
||||
// if (blockElement) {
|
||||
// blockElement.removeEventListener("contextmenu", handleBlockRightClick);
|
||||
// }
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
// const mapItemsWithClick = (items: any[]) => {
|
||||
// return items.map((item) => ({
|
||||
// ...item,
|
||||
// onClick: () => {
|
||||
// if (item.onClick) {
|
||||
// item.onClick();
|
||||
// }
|
||||
// },
|
||||
// subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
|
||||
// }));
|
||||
// };
|
||||
|
||||
// const modifiedArgs = {
|
||||
// ...args,
|
||||
// items: mapItemsWithClick(args.items),
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// ref={scopeRef}
|
||||
// className="boundary"
|
||||
// style={{ padding: "20px", height: "300px", border: "2px solid black" }}
|
||||
// >
|
||||
// {isMenuVisible && <Menu {...modifiedArgs} />}
|
||||
// </div>
|
||||
// );
|
||||
// },
|
||||
// args: {
|
||||
// items: items,
|
||||
// },
|
||||
// };
|
@ -5,7 +5,8 @@ import { FloatingPortal, type Placement, useDismiss, useFloating, useInteraction
|
||||
import clsx from "clsx";
|
||||
import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./menu.less";
|
||||
|
||||
import "./flyoutmenu.less";
|
||||
|
||||
type MenuProps = {
|
||||
items: MenuItem[];
|
||||
@ -17,7 +18,7 @@ type MenuProps = {
|
||||
renderMenuItem?: (item: MenuItem, props: any) => JSX.Element;
|
||||
};
|
||||
|
||||
const MenuComponent = memo(
|
||||
const FlyoutMenuComponent = memo(
|
||||
({ items, children, className, placement, onOpenChange, renderMenu, renderMenuItem }: MenuProps) => {
|
||||
const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({});
|
||||
const [hoveredItems, setHoveredItems] = useState<string[]>([]);
|
||||
@ -139,7 +140,6 @@ const MenuComponent = memo(
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
@ -196,7 +196,7 @@ const MenuComponent = memo(
|
||||
}
|
||||
);
|
||||
|
||||
const Menu = memo(MenuComponent) as typeof MenuComponent;
|
||||
const FlyoutMenu = memo(FlyoutMenuComponent) as typeof FlyoutMenuComponent;
|
||||
|
||||
type SubMenuProps = {
|
||||
subItems: MenuItem[];
|
||||
@ -300,4 +300,4 @@ const SubMenu = memo(
|
||||
}
|
||||
);
|
||||
|
||||
export { Menu };
|
||||
export { FlyoutMenu };
|
@ -1,3 +1,6 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useLongClick } from "@/app/hook/useLongClick";
|
||||
import { makeIconClass } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
|
@ -1,23 +1,21 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@import "../mixins.less";
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
min-height: 24px;
|
||||
min-width: 50px;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
border: 2px solid var(--form-element-border-color);
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: var(--form-element-text-color);
|
||||
background: var(--form-element-bg-color);
|
||||
border: 2px solid var(--form-element-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
|
||||
&:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
&:focus {
|
||||
border-color: var(--form-element-primary-color);
|
||||
}
|
||||
|
||||
@ -29,58 +27,67 @@
|
||||
border-color: var(--form-element-error-color);
|
||||
}
|
||||
|
||||
&-inner {
|
||||
// Include mixins
|
||||
.border-radius-mixin();
|
||||
.vertical-padding-mixin();
|
||||
.horizontal-padding-mixin();
|
||||
.font-size-mixin();
|
||||
.font-weight-mixin();
|
||||
}
|
||||
|
||||
/* Styles when an InputGroup is present */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
--inner-padding: 5px 0 5px 16px;
|
||||
|
||||
&-label {
|
||||
padding: var(--inner-padding);
|
||||
margin-bottom: -10px;
|
||||
font-size: 12.5px;
|
||||
transition: all 0.3s;
|
||||
color: var(--form-element-label-color);
|
||||
line-height: 10px;
|
||||
user-select: none;
|
||||
|
||||
&.float {
|
||||
font-size: 10px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
&.offset-left {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--form-element-border-color);
|
||||
background: var(--form-element-bg-color);
|
||||
|
||||
/* Focus style for InputGroup */
|
||||
&.focused {
|
||||
border-color: var(--form-element-primary-color);
|
||||
}
|
||||
|
||||
/* Error state for InputGroup */
|
||||
&.error {
|
||||
border-color: var(--form-element-error-color);
|
||||
}
|
||||
|
||||
/* Disabled state for InputGroup */
|
||||
&.disabled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.input-left-element,
|
||||
.input-right-element {
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: none;
|
||||
padding: var(--inner-padding);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: var(--form-element-text-color);
|
||||
line-height: 20px;
|
||||
flex-grow: 1;
|
||||
border-radius: none;
|
||||
|
||||
&.offset-left {
|
||||
padding: 5px 16px 5px 0;
|
||||
&:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
user-select: none;
|
||||
}
|
||||
&.error {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-label {
|
||||
height: 34px;
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
// Include mixins
|
||||
.border-radius-mixin();
|
||||
.font-size-mixin();
|
||||
.font-weight-mixin();
|
||||
}
|
||||
|
72
frontend/app/element/input.stories.tsx
Normal file
72
frontend/app/element/input.stories.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input, InputGroup, InputLeftElement, InputRightElement } from "./input";
|
||||
|
||||
const meta: Meta<typeof InputGroup> = {
|
||||
title: "Elements/Input",
|
||||
component: InputGroup,
|
||||
args: {
|
||||
className: "custom-input-group-class",
|
||||
},
|
||||
argTypes: {
|
||||
className: {
|
||||
description: "Custom class for input group styling",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof InputGroup>;
|
||||
|
||||
export const DefaultInput: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<div style={{ padding: "20px", height: "400px", border: "2px solid black" }}>
|
||||
<Input placeholder="Phone number" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
className: "custom-input-group-class",
|
||||
},
|
||||
};
|
||||
|
||||
export const InputWithLeftElement: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<div style={{ padding: "20px", height: "400px", border: "2px solid black" }}>
|
||||
<InputGroup {...args}>
|
||||
<InputLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-phone-volume"></i>
|
||||
</InputLeftElement>
|
||||
<Input placeholder="Phone number" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
className: "custom-input-group-class",
|
||||
},
|
||||
};
|
||||
|
||||
export const InputWithLeftAndRightElement: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<div style={{ padding: "20px", height: "400px", border: "2px solid black" }}>
|
||||
<InputGroup {...args}>
|
||||
<InputLeftElement>$</InputLeftElement>
|
||||
<Input placeholder="Enter amount" />
|
||||
<InputRightElement>
|
||||
<i className="fa-sharp fa-solid fa-check"></i>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
className: "custom-input-group-class",
|
||||
},
|
||||
};
|
@ -1,36 +1,82 @@
|
||||
import { clsx } from "clsx";
|
||||
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import clsx from "clsx";
|
||||
import React, { forwardRef, memo, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import "./input.less";
|
||||
|
||||
interface InputDecorationProps {
|
||||
startDecoration?: React.ReactNode;
|
||||
endDecoration?: React.ReactNode;
|
||||
interface InputGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InputGroup = memo(
|
||||
forwardRef<HTMLDivElement, InputGroupProps>(({ children, className }: InputGroupProps, ref) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const manageFocus = (focused: boolean) => {
|
||||
setIsFocused(focused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("input-group", className, {
|
||||
focused: isFocused,
|
||||
})}
|
||||
>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child as any, { manageFocus });
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
interface InputLeftElementProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InputLeftElement = memo(({ children, className }: InputLeftElementProps) => {
|
||||
return <div className={clsx("input-left-element", className)}>{children}</div>;
|
||||
});
|
||||
|
||||
interface InputRightElementProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InputRightElement = memo(({ children, className }: InputRightElementProps) => {
|
||||
return <div className={clsx("input-right-element", className)}>{children}</div>;
|
||||
});
|
||||
|
||||
interface InputProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<any>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
decoration?: InputDecorationProps;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>;
|
||||
inputRef?: React.MutableRefObject<any>;
|
||||
manageFocus?: (isFocused: boolean) => void;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
const Input = memo(
|
||||
forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
onChange,
|
||||
@ -39,81 +85,27 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
onBlur,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
decoration,
|
||||
required,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
disabled,
|
||||
isNumber,
|
||||
inputRef,
|
||||
manageFocus,
|
||||
}: InputProps,
|
||||
ref
|
||||
) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState(false);
|
||||
const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setFocused(Boolean(value));
|
||||
}
|
||||
}, [value]);
|
||||
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||
|
||||
const handleComponentFocus = () => {
|
||||
if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) {
|
||||
internalInputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentBlur = () => {
|
||||
if (internalInputRef.current?.contains(document.activeElement)) {
|
||||
internalInputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetInputRef = (elem: HTMLInputElement) => {
|
||||
if (inputRef) {
|
||||
inputRef.current = elem;
|
||||
}
|
||||
internalInputRef.current = elem;
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (internalInputRef.current) {
|
||||
const inputValue = internalInputRef.current.value;
|
||||
if (required && !inputValue) {
|
||||
setError(true);
|
||||
setFocused(false);
|
||||
} else {
|
||||
setError(false);
|
||||
setFocused(false);
|
||||
}
|
||||
}
|
||||
onBlur && onBlur();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleInputChange = (e: React.ChangeEvent<any>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (required && !inputValue) {
|
||||
setError(true);
|
||||
setHasContent(false);
|
||||
} else {
|
||||
setError(false);
|
||||
setHasContent(Boolean(inputValue));
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(inputValue);
|
||||
}
|
||||
@ -121,56 +113,38 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
onChange && onChange(inputValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
manageFocus?.(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
manageFocus?.(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const inputValue = value ?? internalValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("input", className, {
|
||||
focused: focused,
|
||||
error: error,
|
||||
disabled: disabled,
|
||||
"no-label": !label,
|
||||
})}
|
||||
onFocus={handleComponentFocus}
|
||||
onBlur={handleComponentBlur}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
|
||||
<div className="input-inner">
|
||||
{label && (
|
||||
<label
|
||||
className={clsx("input-inner-label", {
|
||||
float: hasContent || focused || placeholder,
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
htmlFor={label}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={clsx("input-inner-input", {
|
||||
"offset-left": decoration?.startDecoration,
|
||||
className={clsx("input", className, {
|
||||
disabled: disabled,
|
||||
})}
|
||||
ref={handleSetInputRef}
|
||||
id={label}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { Input };
|
||||
export type { InputDecorationProps, InputProps };
|
||||
export { Input, InputGroup, InputLeftElement, InputRightElement };
|
||||
export type { InputGroupProps, InputLeftElementProps, InputProps, InputRightElementProps };
|
||||
|
@ -1,21 +0,0 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.input-decoration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
color: var(--form-element-icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
.input-decoration.start-position {
|
||||
margin: 0 4px 0 16px;
|
||||
}
|
||||
|
||||
.input-decoration.end-position {
|
||||
margin: 0 16px 0 8px;
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import "./inputdecoration.less";
|
||||
|
||||
interface InputDecorationProps {
|
||||
position?: "start" | "end";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const InputDecoration = (props: InputDecorationProps) => {
|
||||
const { children, position = "end" } = props;
|
||||
return (
|
||||
<div
|
||||
className={clsx("input-decoration", {
|
||||
"start-position": position === "start",
|
||||
"end-position": position === "end",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputDecoration };
|
@ -1,54 +0,0 @@
|
||||
.menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
max-width: 400px;
|
||||
padding: 2px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: #212121;
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: -0.12px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
/* Make sure the label and the icon don't overlap */
|
||||
.label {
|
||||
flex: 1; /* Allow the label to take up available space */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 8px; /* Add some space between label and icon */
|
||||
}
|
||||
|
||||
i {
|
||||
color: var(--main-text-color);
|
||||
flex-shrink: 0; /* Prevent icon from shrinking */
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--button-text-color);
|
||||
|
||||
i {
|
||||
color: var(--button-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
103
frontend/app/element/menubutton.stories.tsx
Normal file
103
frontend/app/element/menubutton.stories.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { MenuButton } from "./menubutton";
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Fruit", onClick: (e) => console.log("Clicked Option 1") },
|
||||
{
|
||||
label: "Vegetables",
|
||||
subItems: [
|
||||
{ label: "Carrot", onClick: (e) => console.log("Clicked Option 2 -> 1") },
|
||||
{ label: "Potato", onClick: (e) => console.log("Clicked Option 2 -> 2") },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Beverages",
|
||||
subItems: [
|
||||
{ label: "Juice", onClick: (e) => console.log("Clicked Option 3 -> 1") },
|
||||
{ label: "Tea", onClick: (e) => console.log("Clicked Option 3 -> 2") },
|
||||
{
|
||||
label: "Coffee",
|
||||
subItems: [
|
||||
{ label: "Espresso", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 1") },
|
||||
{ label: "Latte", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 2") },
|
||||
{ label: "Cappuccino", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 3") },
|
||||
{
|
||||
label: "Mocha",
|
||||
subItems: [
|
||||
{ label: "Dark Chocolate", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 1") },
|
||||
{
|
||||
label: "White Chocolate",
|
||||
onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 2"),
|
||||
},
|
||||
{ label: "Milk Chocolate", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 3") },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Desserts",
|
||||
subItems: [
|
||||
{ label: "Cake", onClick: (e) => console.log("Clicked Option 4 -> 1") },
|
||||
{ label: "Ice Cream", onClick: (e) => console.log("Clicked Option 4 -> 2") },
|
||||
{ label: "Cookies", onClick: (e) => console.log("Clicked Option 4 -> 3") },
|
||||
{ label: "Brownies", onClick: (e) => console.log("Clicked Option 4 -> 4") },
|
||||
{ label: "Cupcakes", onClick: (e) => console.log("Clicked Option 4 -> 5") },
|
||||
{ label: "Donuts", onClick: (e) => console.log("Clicked Option 4 -> 6") },
|
||||
{ label: "Pie", onClick: (e) => console.log("Clicked Option 4 -> 7") },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof MenuButton> = {
|
||||
title: "Elements/MenuButton", // Updated title to reflect the component name
|
||||
component: MenuButton,
|
||||
argTypes: {
|
||||
items: { control: "object" },
|
||||
text: { control: "text" },
|
||||
title: { control: "text" },
|
||||
className: { control: "text" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const basicItems: MenuItem[] = [
|
||||
{
|
||||
label: "Profile",
|
||||
onClick: () => console.log("Profile clicked"),
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
onClick: () => console.log("Settings clicked"),
|
||||
},
|
||||
{
|
||||
label: "Logout",
|
||||
onClick: () => console.log("Logout clicked"),
|
||||
},
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
text: "Menu",
|
||||
title: "Menu Button",
|
||||
className: "",
|
||||
},
|
||||
render: (args) => <MenuButton {...args} />,
|
||||
};
|
||||
|
||||
export const WithMoreItems: Story = {
|
||||
args: {
|
||||
items: items,
|
||||
text: "Extended Menu",
|
||||
title: "Extended Menu Button",
|
||||
className: "",
|
||||
},
|
||||
render: (args) => <MenuButton {...args} />,
|
||||
};
|
@ -1,14 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import { memo, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Menu } from "./menu";
|
||||
import "./menubutton.less";
|
||||
import { FlyoutMenu } from "./flyoutmenu";
|
||||
|
||||
const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<div className={clsx("menubutton", className)}>
|
||||
<Menu items={items} onOpenChange={setIsOpen}>
|
||||
<FlyoutMenu items={items} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
className="grey border-radius-3 vertical-padding-2 horizontal-padding-2"
|
||||
style={{ borderColor: isOpen ? "var(--accent-color)" : "transparent" }}
|
||||
@ -17,7 +16,7 @@ const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps)
|
||||
<div>{text}</div>
|
||||
<i className="fa-sharp fa-solid fa-angle-down"></i>
|
||||
</Button>
|
||||
</Menu>
|
||||
</FlyoutMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
24
frontend/app/element/multilineinput.less
Normal file
24
frontend/app/element/multilineinput.less
Normal file
@ -0,0 +1,24 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.multiline-input {
|
||||
flex-grow: 1;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
line-height: 1.5;
|
||||
color: var(--form-element-text-color);
|
||||
vertical-align: top;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--form-element-border-color);
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
min-height: 26px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
112
frontend/app/element/multilineinput.stories.tsx
Normal file
112
frontend/app/element/multilineinput.stories.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { MultiLineInput } from "./multilineinput";
|
||||
|
||||
const meta: Meta<typeof MultiLineInput> = {
|
||||
title: "Elements/MultiLineInput",
|
||||
component: MultiLineInput,
|
||||
argTypes: {
|
||||
value: {
|
||||
description: "The value of the textarea.",
|
||||
control: "text",
|
||||
},
|
||||
placeholder: {
|
||||
description: "The placeholder text for the textarea.",
|
||||
control: "text",
|
||||
defaultValue: "Type a message...",
|
||||
},
|
||||
maxRows: {
|
||||
description: "Maximum number of rows the textarea can expand to.",
|
||||
control: "number",
|
||||
defaultValue: 5,
|
||||
},
|
||||
rows: {
|
||||
description: "Initial number of rows for the textarea.",
|
||||
control: "number",
|
||||
defaultValue: 1,
|
||||
},
|
||||
maxLength: {
|
||||
description: "The maximum number of characters allowed.",
|
||||
control: "number",
|
||||
defaultValue: 200,
|
||||
},
|
||||
autoFocus: {
|
||||
description: "Autofocus the input when the component mounts.",
|
||||
control: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
disabled: {
|
||||
description: "Disables the textarea if set to true.",
|
||||
control: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MultiLineInput>;
|
||||
|
||||
// Default MultiLineInput Story
|
||||
export const DefaultMultiLineInput: Story = {
|
||||
render: (args) => {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "600px",
|
||||
padding: "20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MultiLineInput {...args} value={message} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
placeholder: "Type your message...",
|
||||
rows: 1,
|
||||
maxRows: 5,
|
||||
},
|
||||
};
|
||||
|
||||
// MultiLineInput with long text
|
||||
export const MultiLineInputWithLongText: Story = {
|
||||
render: (args) => {
|
||||
const [message, setMessage] = useState("This is a long message that will expand the textarea.");
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "600px",
|
||||
padding: "20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MultiLineInput {...args} value={message} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
placeholder: "Type a long message...",
|
||||
rows: 1,
|
||||
maxRows: 10,
|
||||
},
|
||||
};
|
143
frontend/app/element/multilineinput.tsx
Normal file
143
frontend/app/element/multilineinput.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import clsx from "clsx";
|
||||
import React, { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import "./multilineinput.less";
|
||||
|
||||
interface MultiLineInputProps {
|
||||
value?: string;
|
||||
className?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
maxRows?: number;
|
||||
manageFocus?: (isFocused: boolean) => void;
|
||||
}
|
||||
|
||||
const MultiLineInput = memo(
|
||||
forwardRef<HTMLTextAreaElement, MultiLineInputProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
className,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
maxLength,
|
||||
autoFocus,
|
||||
disabled,
|
||||
rows = 1,
|
||||
maxRows = 5,
|
||||
manageFocus,
|
||||
}: MultiLineInputProps,
|
||||
ref
|
||||
) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px
|
||||
const [paddingTop, setPaddingTop] = useState(0);
|
||||
const [paddingBottom, setPaddingBottom] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement);
|
||||
|
||||
// Function to count the number of lines in the textarea value
|
||||
const countLines = (text: string) => {
|
||||
return text.split("\n").length;
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto"; // Reset height to auto first
|
||||
|
||||
const maxHeight = maxRows * lineHeight + paddingTop + paddingBottom; // Max height based on maxRows
|
||||
const currentLines = countLines(textareaRef.current.value); // Count the number of lines
|
||||
const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height
|
||||
|
||||
// If the number of lines is less than or equal to maxRows, set height accordingly
|
||||
const calculatedHeight =
|
||||
currentLines <= maxRows
|
||||
? `${lineHeight * currentLines + paddingTop + paddingBottom}px`
|
||||
: `${newHeight}px`;
|
||||
|
||||
textareaRef.current.style.height = calculatedHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInternalValue(e.target.value);
|
||||
onChange?.(e);
|
||||
|
||||
// Adjust the height of the textarea after text change
|
||||
adjustTextareaHeight();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
manageFocus?.(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
manageFocus?.(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const computedStyle = window.getComputedStyle(textareaRef.current);
|
||||
const detectedLineHeight = parseFloat(computedStyle.lineHeight);
|
||||
const detectedPaddingTop = parseFloat(computedStyle.paddingTop);
|
||||
const detectedPaddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||
|
||||
setLineHeight(detectedLineHeight);
|
||||
setPaddingTop(detectedPaddingTop);
|
||||
setPaddingBottom(detectedPaddingBottom);
|
||||
}
|
||||
}, [textareaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [value, maxRows, lineHeight, paddingTop, paddingBottom]);
|
||||
|
||||
const inputValue = value ?? internalValue;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={clsx("multiline-input", className)}
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
style={{
|
||||
overflowY:
|
||||
textareaRef.current &&
|
||||
textareaRef.current.scrollHeight > maxRows * lineHeight + paddingTop + paddingBottom
|
||||
? "auto"
|
||||
: "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { MultiLineInput };
|
||||
export type { MultiLineInputProps };
|
16
frontend/app/element/popover.less
Normal file
16
frontend/app/element/popover.less
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.popover-content {
|
||||
min-width: 100px;
|
||||
min-height: 150px;
|
||||
position: absolute;
|
||||
z-index: 1000; // TODO: put this in theme.less
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
gap: 1px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: #212121;
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
54
frontend/app/element/popover.stories.tsx
Normal file
54
frontend/app/element/popover.stories.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Popover, PopoverButton, PopoverContent } from "./popover";
|
||||
|
||||
const meta: Meta<typeof Popover> = {
|
||||
title: "Elements/Popover",
|
||||
component: Popover,
|
||||
args: {
|
||||
className: "custom-popover-class",
|
||||
},
|
||||
argTypes: {
|
||||
className: {
|
||||
description: "Custom class for popover styling",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Popover>;
|
||||
|
||||
export const DefaultPopover: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<div className="boundary" style={{ padding: "20px", height: "00px", border: "2px solid black" }}>
|
||||
<Popover {...args}>
|
||||
<PopoverButton className="ghost grey">
|
||||
<i className="fa-sharp fa-solid fa-face-smile"></i>
|
||||
</PopoverButton>
|
||||
<PopoverContent>
|
||||
<div
|
||||
style={{
|
||||
opacity: ".3",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
}}
|
||||
>
|
||||
<i className="fa-sharp fa-solid fa-shelves-empty"></i>
|
||||
<span style={{ fontSize: "11px" }}>Empty</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
className: "custom-popover-class",
|
||||
},
|
||||
};
|
142
frontend/app/element/popover.tsx
Normal file
142
frontend/app/element/popover.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { Button } from "@/element/button";
|
||||
import {
|
||||
FloatingPortal,
|
||||
offset as offsetMiddleware,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
type Placement,
|
||||
} from "@floating-ui/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
JSXElementConstructor,
|
||||
memo,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import "./popover.less";
|
||||
|
||||
interface PopoverProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
offset?: number;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const isPopoverButton = (
|
||||
element: ReactElement
|
||||
): element is ReactElement<PopoverButtonProps, JSXElementConstructor<PopoverButtonProps>> => {
|
||||
return element.type === PopoverButton;
|
||||
};
|
||||
|
||||
const isPopoverContent = (
|
||||
element: ReactElement
|
||||
): element is ReactElement<PopoverContentProps, JSXElementConstructor<PopoverContentProps>> => {
|
||||
return element.type === PopoverContent;
|
||||
};
|
||||
|
||||
const Popover = memo(({ children, className, placement = "bottom-start", offset = 3, onOpenChange }: PopoverProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleOpen = () => {
|
||||
setIsOpen((prev) => !prev);
|
||||
onOpenChange?.(!isOpen);
|
||||
};
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement,
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
middleware: [offsetMiddleware(offset)],
|
||||
});
|
||||
|
||||
const dismiss = useDismiss(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
const renderChildren = Children.map(children, (child) => {
|
||||
if (isValidElement(child)) {
|
||||
if (isPopoverButton(child)) {
|
||||
return cloneElement(child as any, {
|
||||
isActive: isOpen,
|
||||
ref: refs.setReference,
|
||||
getReferenceProps,
|
||||
onClick: toggleOpen,
|
||||
});
|
||||
}
|
||||
|
||||
if (isPopoverContent(child)) {
|
||||
return isOpen
|
||||
? cloneElement(child as any, {
|
||||
ref: refs.setFloating,
|
||||
style: floatingStyles,
|
||||
getFloatingProps,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return <div className={clsx("popover", className)}>{renderChildren}</div>;
|
||||
});
|
||||
|
||||
interface PopoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
getReferenceProps?: () => any;
|
||||
as?: keyof JSX.IntrinsicElements | React.ComponentType<any>;
|
||||
}
|
||||
|
||||
const PopoverButton = forwardRef<HTMLButtonElement | HTMLDivElement, PopoverButtonProps>(
|
||||
({ isActive, children, onClick, getReferenceProps, className, as: Component = "button", ...props }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={clsx("popover-button", className, { "is-active": isActive })}
|
||||
onClick={onClick}
|
||||
{...getReferenceProps?.()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
getFloatingProps?: () => any;
|
||||
}
|
||||
|
||||
const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
({ children, className, getFloatingProps, style, ...props }, ref) => {
|
||||
return (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("popover-content", className)}
|
||||
style={style}
|
||||
{...getFloatingProps?.()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Popover.displayName = "Popover";
|
||||
PopoverButton.displayName = "PopoverButton";
|
||||
PopoverContent.displayName = "PopoverContent";
|
||||
|
||||
export { Popover, PopoverButton, PopoverContent };
|
||||
export type { PopoverButtonProps, PopoverContentProps };
|
0
frontend/app/element/searchinput.less
Normal file
0
frontend/app/element/searchinput.less
Normal file
32
frontend/app/element/searchinput.stories.tsx
Normal file
32
frontend/app/element/searchinput.stories.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SearchInput } from "./searchinput";
|
||||
|
||||
const meta: Meta<typeof SearchInput> = {
|
||||
title: "Elements/SearchInput",
|
||||
component: SearchInput,
|
||||
argTypes: {
|
||||
className: {
|
||||
description: "Custom class for styling the input group",
|
||||
control: { type: "text" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchInput>;
|
||||
|
||||
export const DefaultSearchInput: Story = {
|
||||
render: (args) => {
|
||||
const handleSearch = () => {
|
||||
console.log("Search triggered");
|
||||
};
|
||||
|
||||
return <SearchInput />;
|
||||
},
|
||||
args: {
|
||||
className: "custom-search-input",
|
||||
},
|
||||
};
|
20
frontend/app/element/searchinput.tsx
Normal file
20
frontend/app/element/searchinput.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Input, InputGroup, InputRightElement } from "./input";
|
||||
|
||||
const SearchInput = () => {
|
||||
return (
|
||||
<InputGroup className="search-input-group">
|
||||
<Input placeholder="Search..." />
|
||||
<InputRightElement>
|
||||
<Button className="search-button ghost grey">
|
||||
<i className="fa-sharp fa-solid fa-magnifying-glass"></i>
|
||||
</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { SearchInput };
|
@ -7,3 +7,262 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.border-radius-mixin() {
|
||||
&.border-radius-2 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
&.border-radius-3 {
|
||||
border-radius: 3px;
|
||||
}
|
||||
&.border-radius-4 {
|
||||
border-radius: 4px;
|
||||
}
|
||||
&.border-radius-5 {
|
||||
border-radius: 5px;
|
||||
}
|
||||
&.border-radius-6 {
|
||||
border-radius: 6px;
|
||||
}
|
||||
&.border-radius-7 {
|
||||
border-radius: 7px;
|
||||
}
|
||||
&.border-radius-8 {
|
||||
border-radius: 8px;
|
||||
}
|
||||
&.border-radius-9 {
|
||||
border-radius: 9px;
|
||||
}
|
||||
&.border-radius-10 {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-padding-mixin() {
|
||||
&.vertical-padding-0 {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
&.vertical-padding-1 {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
&.vertical-padding-2 {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
&.vertical-padding-3 {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
&.vertical-padding-4 {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
&.vertical-padding-5 {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
&.vertical-padding-6 {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
&.vertical-padding-7 {
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
&.vertical-padding-8 {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
&.vertical-padding-9 {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
&.vertical-padding-10 {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-padding-mixin() {
|
||||
&.horizontal-padding-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
&.horizontal-padding-1 {
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
}
|
||||
&.horizontal-padding-2 {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
&.horizontal-padding-3 {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
&.horizontal-padding-4 {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
&.horizontal-padding-5 {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
&.horizontal-padding-6 {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
&.horizontal-padding-7 {
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
}
|
||||
&.horizontal-padding-8 {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
&.horizontal-padding-9 {
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
&.horizontal-padding-10 {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.font-size-mixin() {
|
||||
&.font-size-10 {
|
||||
font-size: 10px;
|
||||
}
|
||||
&.font-size-11 {
|
||||
font-size: 11px;
|
||||
}
|
||||
&.font-size-12 {
|
||||
font-size: 12px;
|
||||
}
|
||||
&.font-size-13 {
|
||||
font-size: 13px;
|
||||
}
|
||||
&.font-size-14 {
|
||||
font-size: 14px;
|
||||
}
|
||||
&.font-size-15 {
|
||||
font-size: 15px;
|
||||
}
|
||||
&.font-size-16 {
|
||||
font-size: 16px;
|
||||
}
|
||||
&.font-size-17 {
|
||||
font-size: 17px;
|
||||
}
|
||||
&.font-size-18 {
|
||||
font-size: 18px;
|
||||
}
|
||||
&.font-size-19 {
|
||||
font-size: 19px;
|
||||
}
|
||||
&.font-size-20 {
|
||||
font-size: 20px;
|
||||
}
|
||||
&.font-size-21 {
|
||||
font-size: 21px;
|
||||
}
|
||||
&.font-size-22 {
|
||||
font-size: 22px;
|
||||
}
|
||||
&.font-size-23 {
|
||||
font-size: 23px;
|
||||
}
|
||||
&.font-size-24 {
|
||||
font-size: 24px;
|
||||
}
|
||||
&.font-size-25 {
|
||||
font-size: 25px;
|
||||
}
|
||||
&.font-size-26 {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.font-weight-mixin() {
|
||||
&.font-weight-100 {
|
||||
font-weight: 100;
|
||||
}
|
||||
&.font-weight-200 {
|
||||
font-weight: 200;
|
||||
}
|
||||
&.font-weight-300 {
|
||||
font-weight: 300;
|
||||
}
|
||||
&.font-weight-400 {
|
||||
font-weight: 400;
|
||||
}
|
||||
&.font-weight-500 {
|
||||
font-weight: 500;
|
||||
}
|
||||
&.font-weight-600 {
|
||||
font-weight: 600;
|
||||
}
|
||||
&.font-weight-700 {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-dims-mixin() {
|
||||
&.size-xs {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 7px; // 18px * (20 / 50)
|
||||
|
||||
.status-indicator {
|
||||
width: 5px; // scaled indicator size
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-sm {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 11px; // 18px * (30 / 50)
|
||||
|
||||
.status-indicator {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-md {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px; // 18px * (40 / 50)
|
||||
|
||||
.status-indicator {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-lg {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: 16px; // 18px * (45 / 50)
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-xl {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 18px; // base size
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Input } from "@/app/element/input";
|
||||
import { InputDecoration } from "@/app/element/inputdecoration";
|
||||
import { Input, InputGroup, InputRightElement } from "@/app/element/input";
|
||||
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
|
||||
import { makeIconClass } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
@ -103,13 +102,13 @@ const TypeAheadModal = ({
|
||||
const width = domRect?.width ?? 0;
|
||||
const height = domRect?.height ?? 0;
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const realInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputGroupRef = useRef<HTMLDivElement>(null);
|
||||
const suggestionsWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!modalRef.current || !inputRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) {
|
||||
if (!modalRef.current || !inputGroupRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -124,7 +123,7 @@ const TypeAheadModal = ({
|
||||
const suggestionsWrapperStyles = window.getComputedStyle(suggestionsWrapperRef.current);
|
||||
const suggestionsWrapperMarginTop = parseFloat(suggestionsWrapperStyles.marginTop) || 0;
|
||||
|
||||
const inputHeight = inputRef.current.getBoundingClientRect().height;
|
||||
const inputHeight = inputGroupRef.current.getBoundingClientRect().height;
|
||||
let suggestionsTotalHeight = 0;
|
||||
|
||||
const suggestionItems = suggestionsRef.current.children;
|
||||
@ -177,7 +176,7 @@ const TypeAheadModal = ({
|
||||
useLayoutEffect(() => {
|
||||
if (giveFocusRef) {
|
||||
giveFocusRef.current = () => {
|
||||
realInputRef.current?.focus();
|
||||
inputRef.current?.focus();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@ -216,21 +215,18 @@ const TypeAheadModal = ({
|
||||
ref={modalRef}
|
||||
className={clsx("type-ahead-modal", className, { "has-suggestions": suggestions?.length > 0 })}
|
||||
>
|
||||
<InputGroup ref={inputGroupRef}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
inputRef={realInputRef}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={label}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<i className="fa-regular fa-magnifying-glass"></i>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<i className="fa-regular fa-magnifying-glass"></i>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<div
|
||||
ref={suggestionsWrapperRef}
|
||||
className="suggestions-wrapper"
|
||||
|
@ -14,6 +14,7 @@ import { debounce } from "throttle-debounce";
|
||||
import { Tab } from "./tab";
|
||||
import "./tabbar.less";
|
||||
import { UpdateStatusBanner } from "./updatebanner";
|
||||
import { WorkspaceSwitcher } from "./workspaceswitcher";
|
||||
|
||||
const TAB_DEFAULT_WIDTH = 130;
|
||||
const TAB_MIN_WIDTH = 100;
|
||||
@ -536,12 +537,12 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
|
||||
<i className="fa fa-ellipsis" />
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
|
||||
<WindowDrag ref={draggerLeftRef} className="left" />
|
||||
{appMenuButton}
|
||||
{devLabel}
|
||||
{isDev ? <WorkspaceSwitcher></WorkspaceSwitcher> : null}
|
||||
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
|
||||
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
|
||||
{tabIds.map((tabId, index) => {
|
||||
|
211
frontend/app/tab/workspaceswitcher.less
Normal file
211
frontend/app/tab/workspaceswitcher.less
Normal file
@ -0,0 +1,211 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.workspace-switcher-button {
|
||||
display: flex;
|
||||
height: 26px;
|
||||
padding: 0px 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
margin-top: 6px;
|
||||
margin-right: 13px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.workspace-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-left,
|
||||
.icon-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
max-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-switcher-content {
|
||||
min-height: auto;
|
||||
display: flex;
|
||||
width: 256px;
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgb(35, 35, 35);
|
||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.8);
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
width: 100%;
|
||||
padding: 6px 8px 0px;
|
||||
}
|
||||
|
||||
.expandable-menu-item {
|
||||
margin: 3px 8px;
|
||||
}
|
||||
|
||||
.expandable-menu-item-group {
|
||||
margin: 0 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 4px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.expandable-menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item-group {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
.menu-group-title-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.open:not(:first-child) {
|
||||
background-color: rgb(30, 30, 30);
|
||||
border: 1px solid rgb(41, 41, 41);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.expandable-menu-item-group-title:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item,
|
||||
.expandable-menu-item-group-title {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
padding: 5px 8px;
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item-group-title {
|
||||
height: 29px;
|
||||
padding: 0;
|
||||
|
||||
.left-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-icon-selector {
|
||||
.input {
|
||||
margin: 5px 0 10px;
|
||||
}
|
||||
|
||||
.color-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(15px, 15px)
|
||||
); // Ensures each color circle has a fixed 14px size
|
||||
grid-gap: 18.5px; // Space between items
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
|
||||
.color-circle {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
// Border offset outward
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
&.selected:before {
|
||||
border-color: white; // Highlight for the selected circle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(16px, 16px)
|
||||
); // Ensures each color circle has a fixed 14px size
|
||||
grid-column-gap: 17.5px; // Space between items
|
||||
grid-row-gap: 13px; // Space between items
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
|
||||
.icon-item {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&.selected {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-ws-btn-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
padding: 3px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
431
frontend/app/tab/workspaceswitcher.tsx
Normal file
431
frontend/app/tab/workspaceswitcher.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
// Copyright 2024, Command Line
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/element/button";
|
||||
import {
|
||||
ExpandableMenu,
|
||||
ExpandableMenuItem,
|
||||
ExpandableMenuItemData,
|
||||
ExpandableMenuItemGroup,
|
||||
ExpandableMenuItemGroupTitle,
|
||||
ExpandableMenuItemGroupTitleType,
|
||||
ExpandableMenuItemLeftElement,
|
||||
ExpandableMenuItemRightElement,
|
||||
} from "@/element/expandablemenu";
|
||||
import { Input } from "@/element/input";
|
||||
import { Popover, PopoverButton, PopoverContent } from "@/element/popover";
|
||||
import { makeIconClass } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { colord } from "colord";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import WorkspaceSVG from "../asset/workspace.svg";
|
||||
|
||||
import "./workspaceswitcher.less";
|
||||
|
||||
interface ColorSelectorProps {
|
||||
colors: string[];
|
||||
selectedColor?: string;
|
||||
onSelect: (color: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
|
||||
const handleColorClick = (color: string) => {
|
||||
onSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("color-selector", className)}>
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
className={clsx("color-circle", { selected: selectedColor === color })}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => handleColorClick(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface IconSelectorProps {
|
||||
icons: string[];
|
||||
selectedIcon?: string;
|
||||
onSelect: (icon: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => {
|
||||
const handleIconClick = (icon: string) => {
|
||||
onSelect(icon);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("icon-selector", className)}>
|
||||
{icons.map((icon) => {
|
||||
const iconClass = makeIconClass(icon, false);
|
||||
return (
|
||||
<i
|
||||
key={icon}
|
||||
className={clsx(iconClass, "icon-item", { selected: selectedIcon === icon })}
|
||||
onClick={() => handleIconClick(icon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ColorAndIconSelectorProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
focusInput: boolean;
|
||||
onTitleChange: (newTitle: string) => void;
|
||||
onColorChange: (newColor: string) => void;
|
||||
onIconChange: (newIcon: string) => void;
|
||||
onDeleteWorkspace: () => void;
|
||||
}
|
||||
const ColorAndIconSelector = memo(
|
||||
({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
focusInput,
|
||||
onTitleChange,
|
||||
onColorChange,
|
||||
onIconChange,
|
||||
onDeleteWorkspace,
|
||||
}: ColorAndIconSelectorProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusInput && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [focusInput]);
|
||||
|
||||
return (
|
||||
<div className="color-icon-selector">
|
||||
<Input ref={inputRef} className="vertical-padding-3" onChange={onTitleChange} value={title} autoFocus />
|
||||
<ColorSelector
|
||||
selectedColor={color}
|
||||
colors={["#e91e63", "#8bc34a", "#ff9800", "#ffc107", "#03a9f4", "#3f51b5", "#f44336"]}
|
||||
onSelect={onColorChange}
|
||||
/>
|
||||
<IconSelector
|
||||
selectedIcon={icon}
|
||||
icons={[
|
||||
"triangle",
|
||||
"star",
|
||||
"cube",
|
||||
"gem",
|
||||
"chess-knight",
|
||||
"heart",
|
||||
"plane",
|
||||
"rocket",
|
||||
"shield-cat",
|
||||
"paw-simple",
|
||||
"umbrella",
|
||||
"graduation-cap",
|
||||
"mug-hot",
|
||||
"circle",
|
||||
]}
|
||||
onSelect={onIconChange}
|
||||
/>
|
||||
<div className="delete-ws-btn-wrapper">
|
||||
<Button className="ghost grey font-size-12" onClick={onDeleteWorkspace}>
|
||||
Delete workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface WorkspaceDataType {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// Define the global Jotai atom for menuData
|
||||
const workspaceData: WorkspaceDataType[] = [
|
||||
{
|
||||
id: "596e76eb-d87d-425e-9f6e-1519069ee446",
|
||||
icon: "",
|
||||
label: "Default",
|
||||
color: "",
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
id: "596e76eb-d87d-425e-9f6e-1519069ee447",
|
||||
icon: "shield-cat",
|
||||
label: "Cat Space",
|
||||
color: "#e91e63",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "596e76eb-d87d-425e-9f6e-1519069ee448",
|
||||
icon: "paw-simple",
|
||||
label: "Bear Space",
|
||||
color: "#ffc107",
|
||||
isActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const menuDataAtom = atom<WorkspaceDataType[]>(workspaceData);
|
||||
|
||||
const WorkspaceSwitcher = () => {
|
||||
const [menuData, setMenuData] = useAtom(menuDataAtom);
|
||||
|
||||
const handleTitleChange = (id: string, newTitle: string) => {
|
||||
// This should call a service
|
||||
setMenuData((prevMenuData) =>
|
||||
prevMenuData.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
label: newTitle,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleColorChange = (id: string, newColor: string) => {
|
||||
// This should call a service
|
||||
setMenuData((prevMenuData) =>
|
||||
prevMenuData.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
color: newColor,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleIconChange = (id: string, newIcon: string) => {
|
||||
// This should call a service
|
||||
setMenuData((prevMenuData) =>
|
||||
prevMenuData.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
icon: newIcon,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const setActiveWorkspace = (id: string) => {
|
||||
// This should call a service
|
||||
setMenuData((prevMenuData) =>
|
||||
prevMenuData.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
isActive: false,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddNewWorkspace = () => {
|
||||
// This should call a service
|
||||
const id = `group-${Math.random().toString(36).substr(2, 9)}`;
|
||||
setMenuData((prevMenuData) => {
|
||||
const updatedMenuData = prevMenuData.map((item) => ({
|
||||
...item,
|
||||
isActive: false,
|
||||
}));
|
||||
|
||||
const newWorkspace = {
|
||||
id,
|
||||
icon: "circle",
|
||||
label: "New Workspace",
|
||||
color: "#8bc34a",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
return [...updatedMenuData, newWorkspace];
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = (id: string) => {
|
||||
console.log("got here!!!");
|
||||
// This should call a service
|
||||
setMenuData((prevMenuData) => {
|
||||
const updatedMenuData = prevMenuData.filter((item) => item.id !== id);
|
||||
console.log("updatedMenuData", updatedMenuData);
|
||||
|
||||
const isAnyActive = updatedMenuData.some((item) => item.isActive);
|
||||
|
||||
if (!isAnyActive && updatedMenuData.length > 0) {
|
||||
updatedMenuData[0] = { ...updatedMenuData[0], isActive: true };
|
||||
}
|
||||
|
||||
return updatedMenuData;
|
||||
});
|
||||
};
|
||||
|
||||
const activeWorkspace = menuData.find((workspace) => workspace.isActive);
|
||||
|
||||
const data = menuData.map((item): ExpandableMenuItemData => {
|
||||
const { id, icon, label, color, isActive } = item;
|
||||
const title: ExpandableMenuItemGroupTitleType = { label };
|
||||
const leftElement = icon ? (
|
||||
<i className={clsx("left-icon", makeIconClass(icon, false))} style={{ color: color }}></i>
|
||||
) : null;
|
||||
title.leftElement = leftElement;
|
||||
title.rightElement = isActive ? <i className="fa-sharp fa-solid fa-check" style={{ color: color }}></i> : null;
|
||||
|
||||
if (label === "Default") {
|
||||
return {
|
||||
id,
|
||||
type: "group",
|
||||
title: {
|
||||
leftElement: <WorkspaceSVG></WorkspaceSVG>,
|
||||
label: "Default",
|
||||
rightElement: isActive ? <i className="fa-sharp fa-solid fa-check"></i> : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type: "group",
|
||||
title,
|
||||
isOpen: isActive,
|
||||
children: [
|
||||
{
|
||||
type: "item",
|
||||
content: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<ColorAndIconSelector
|
||||
title={label}
|
||||
icon={icon}
|
||||
color={color}
|
||||
focusInput={isOpen}
|
||||
onTitleChange={(title) => handleTitleChange(id, title)}
|
||||
onColorChange={(color) => handleColorChange(id, color)}
|
||||
onIconChange={(icon) => handleIconChange(id, icon)}
|
||||
onDeleteWorkspace={() => handleDeleteWorkspace(id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const modWorkspaceColor =
|
||||
activeWorkspace.label === "Default"
|
||||
? "rgba(0, 0, 0, .2)"
|
||||
: colord(activeWorkspace.color).alpha(0.1).toRgbString();
|
||||
|
||||
const renderExpandableMenu = (menuItems: ExpandableMenuItemData[], parentIsOpen?: boolean) => {
|
||||
return menuItems.map((item, index) => {
|
||||
if (item.type === "item") {
|
||||
let contentElement;
|
||||
if (typeof item.content === "function") {
|
||||
contentElement = item.content({ isOpen: parentIsOpen });
|
||||
} else {
|
||||
contentElement = item.content;
|
||||
}
|
||||
return (
|
||||
<ExpandableMenuItem key={item.id ?? index} withHoverEffect={false}>
|
||||
{item.leftElement && (
|
||||
<ExpandableMenuItemLeftElement>{item.leftElement}</ExpandableMenuItemLeftElement>
|
||||
)}
|
||||
<div className="content">{contentElement}</div>
|
||||
{item.rightElement && (
|
||||
<ExpandableMenuItemRightElement>{item.rightElement}</ExpandableMenuItemRightElement>
|
||||
)}
|
||||
</ExpandableMenuItem>
|
||||
);
|
||||
} else if (item.type === "group") {
|
||||
return (
|
||||
<ExpandableMenuItemGroup
|
||||
key={item.id}
|
||||
isOpen={item.isOpen}
|
||||
className={clsx({ "is-active": item.id === activeWorkspace.id })}
|
||||
>
|
||||
<ExpandableMenuItemGroupTitle onClick={() => setActiveWorkspace(item.id)}>
|
||||
<div
|
||||
className="menu-group-title-wrapper"
|
||||
style={{
|
||||
backgroundColor: item.id === activeWorkspace.id ? modWorkspaceColor : "transparent",
|
||||
}}
|
||||
>
|
||||
{item.title.leftElement && (
|
||||
<ExpandableMenuItemLeftElement>
|
||||
{item.title.leftElement}
|
||||
</ExpandableMenuItemLeftElement>
|
||||
)}
|
||||
<div className="label">{item.title.label}</div>
|
||||
{item.title.rightElement && (
|
||||
<ExpandableMenuItemRightElement>
|
||||
{item.title.rightElement}
|
||||
</ExpandableMenuItemRightElement>
|
||||
)}
|
||||
</div>
|
||||
</ExpandableMenuItemGroupTitle>
|
||||
{item.children && item.children.length > 0 && renderExpandableMenu(item.children, item.isOpen)}
|
||||
</ExpandableMenuItemGroup>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
let workspaceIcon = (
|
||||
<i className={makeIconClass(activeWorkspace.icon, false)} style={{ color: activeWorkspace.color }}></i>
|
||||
);
|
||||
if (activeWorkspace.label == "Default") {
|
||||
workspaceIcon = <WorkspaceSVG></WorkspaceSVG>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover className="workspace-switcher-popover">
|
||||
<PopoverButton className="workspace-switcher-button grey" as="div">
|
||||
<span className="workspace-icon">{workspaceIcon}</span>
|
||||
{/* <span className="divider" />
|
||||
<span className="icon-right">
|
||||
<ThunderSVG></ThunderSVG>
|
||||
</span> */}
|
||||
</PopoverButton>
|
||||
<PopoverContent className="workspace-switcher-content">
|
||||
<div className="title">Switch workspace</div>
|
||||
<OverlayScrollbarsComponent className={"scrollable"} options={{ scrollbars: { autoHide: "leave" } }}>
|
||||
<ExpandableMenu noIndent singleOpen>
|
||||
{renderExpandableMenu(data)}
|
||||
</ExpandableMenu>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
<div className="actions">
|
||||
<ExpandableMenuItem onClick={() => handleAddNewWorkspace()}>
|
||||
<ExpandableMenuItemLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-plus"></i>
|
||||
</ExpandableMenuItemLeftElement>
|
||||
<div className="content">New workspace</div>
|
||||
</ExpandableMenuItem>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { WorkspaceSwitcher };
|
30
frontend/app/view/chat/channels.less
Normal file
30
frontend/app/view/chat/channels.less
Normal file
@ -0,0 +1,30 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.channel-list {
|
||||
width: 180px;
|
||||
background-color: rgba(255, 255, 255, 0.025);
|
||||
overflow-x: hidden;
|
||||
padding: 5px;
|
||||
|
||||
.menu-item-button {
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.nested-list {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
color: rgb(from var(--main-text-color) r g b / 0.7);
|
||||
}
|
||||
|
||||
.has-children .menu-item-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.is-open .menu-item-text {
|
||||
color: var(--main-text-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
13
frontend/app/view/chat/channels.tsx
Normal file
13
frontend/app/view/chat/channels.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { CollapsibleMenu } from "@/app/element/collapsiblemenu";
|
||||
import { memo } from "react";
|
||||
|
||||
import "./channels.less";
|
||||
|
||||
const Channels = memo(({ channels }: { channels: MenuItem[] }) => {
|
||||
return <CollapsibleMenu className="channel-list" items={channels}></CollapsibleMenu>;
|
||||
});
|
||||
|
||||
export { Channels };
|
31
frontend/app/view/chat/chat.less
Normal file
31
frontend/app/view/chat/chat.less
Normal file
@ -0,0 +1,31 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.chat-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
.message-wrapper {
|
||||
flex-grow: 1; // Make the ChatMessages take up available height
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; // Ensure content doesn't overflow
|
||||
}
|
||||
|
||||
.chatbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
70
frontend/app/view/chat/chat.tsx
Normal file
70
frontend/app/view/chat/chat.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ChatMessage, ChatMessages } from "@/app/view/chat/chatmessages";
|
||||
import { UserStatus } from "@/app/view/chat/userlist";
|
||||
import * as jotai from "jotai";
|
||||
import { memo } from "react";
|
||||
import { Channels } from "./channels";
|
||||
import { ChatBox } from "./chatbox";
|
||||
import { channels, messages, users } from "./data";
|
||||
import { UserList } from "./userlist";
|
||||
|
||||
import "./chat.less";
|
||||
|
||||
class ChatModel {
|
||||
viewType: string;
|
||||
channels: MenuItem[];
|
||||
users: UserStatus[];
|
||||
messagesAtom: jotai.PrimitiveAtom<ChatMessage[]>;
|
||||
|
||||
constructor(blockId: string) {
|
||||
this.viewType = "chat";
|
||||
this.channels = channels;
|
||||
this.users = users;
|
||||
this.messagesAtom = jotai.atom(messages);
|
||||
}
|
||||
|
||||
addMessageAtom = jotai.atom(null, (get, set, newMessage: ChatMessage) => {
|
||||
const currentMessages = get(this.messagesAtom);
|
||||
set(this.messagesAtom, [...currentMessages, newMessage]);
|
||||
});
|
||||
}
|
||||
|
||||
function makeChatModel(blockId: string): ChatModel {
|
||||
return new ChatModel(blockId);
|
||||
}
|
||||
|
||||
interface ChatProps {
|
||||
model: ChatModel;
|
||||
}
|
||||
|
||||
const Chat = memo(({ model }: ChatProps) => {
|
||||
const { channels, users } = model;
|
||||
const messages = jotai.useAtomValue(model.messagesAtom);
|
||||
const [, appendMessage] = jotai.useAtom(model.addMessageAtom);
|
||||
|
||||
const handleSendMessage = (message: string) => {
|
||||
const newMessage: ChatMessage = {
|
||||
id: `${Date.now()}`,
|
||||
username: "currentUser",
|
||||
message: message,
|
||||
};
|
||||
appendMessage(newMessage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
<Channels channels={channels}></Channels>
|
||||
<div className="chat-section">
|
||||
<div className="message-wrapper">
|
||||
<ChatMessages messages={messages}></ChatMessages>
|
||||
</div>
|
||||
<ChatBox onSendMessage={(message: string) => handleSendMessage(message)} />
|
||||
</div>
|
||||
<UserList users={users}></UserList>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { Chat, ChatModel, makeChatModel };
|
78
frontend/app/view/chat/chatbox.tsx
Normal file
78
frontend/app/view/chat/chatbox.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { EmojiPalette, type EmojiItem } from "@/app/element/emojipalette";
|
||||
import { InputGroup } from "@/app/element/input";
|
||||
import { MultiLineInput } from "@/app/element/multilineinput";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import React, { memo, useRef, useState } from "react";
|
||||
import { throttle } from "throttle-debounce";
|
||||
|
||||
interface ChatBoxProps {
|
||||
onSendMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const ChatBox = memo(({ onSendMessage }: ChatBoxProps) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const multiLineInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => {
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Enter") && !waveEvent.shift && message.trim() !== "") {
|
||||
onSendMessage(message);
|
||||
setMessage("");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emojiItem: EmojiItem) => {
|
||||
if (multiLineInputRef.current) {
|
||||
const { selectionStart, selectionEnd } = multiLineInputRef.current;
|
||||
const currentValue = multiLineInputRef.current.value;
|
||||
|
||||
// Insert emoji at the current cursor position
|
||||
const newValue =
|
||||
currentValue.substring(0, selectionStart) + emojiItem.emoji + currentValue.substring(selectionEnd);
|
||||
|
||||
// Update the message state and textarea value
|
||||
setMessage(newValue);
|
||||
|
||||
// Set the textarea value manually
|
||||
multiLineInputRef.current.value = newValue;
|
||||
|
||||
// Move cursor after the inserted emoji
|
||||
const cursorPosition = selectionStart + emojiItem.emoji.length;
|
||||
|
||||
// Use setTimeout to ensure the cursor positioning happens after rendering the new value
|
||||
throttle(0, () => {
|
||||
if (multiLineInputRef.current) {
|
||||
multiLineInputRef.current.selectionStart = multiLineInputRef.current.selectionEnd = cursorPosition;
|
||||
multiLineInputRef.current.focus(); // Make sure the textarea remains focused
|
||||
}
|
||||
})();
|
||||
|
||||
// Trigger onChange manually
|
||||
multiLineInputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InputGroup className="chatbox">
|
||||
<MultiLineInput
|
||||
ref={multiLineInputRef}
|
||||
className="input"
|
||||
value={message}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<EmojiPalette placement="top-end" onSelect={handleEmojiSelect} />
|
||||
</InputGroup>
|
||||
);
|
||||
});
|
||||
|
||||
export { ChatBox };
|
51
frontend/app/view/chat/chatmessages.less
Normal file
51
frontend/app/view/chat/chatmessages.less
Normal file
@ -0,0 +1,51 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.chat-messages {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-user-icon {
|
||||
height: 1em; /* Make user icon height match the text height */
|
||||
width: 1em; /* Keep the icon proportional */
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chat-username {
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
line-height: 1.4; /* Ensure alignment with the first line of the message */
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-text img {
|
||||
height: 1em; /* Make inline images (rendered via markdown) match the text height */
|
||||
width: auto; /* Keep the aspect ratio of images */
|
||||
margin: 0 4px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chat-emoji {
|
||||
margin: 0 2px;
|
||||
font-size: 1em; /* Match emoji size with the text height */
|
||||
}
|
90
frontend/app/view/chat/chatmessages.stories.tsx
Normal file
90
frontend/app/view/chat/chatmessages.stories.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ChatMessages } from "./chatmessages";
|
||||
import "./chatmessages.less";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
username: string;
|
||||
message: string;
|
||||
color?: string;
|
||||
userIcon?: string;
|
||||
messageIcon?: string;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Elements/ChatMessages",
|
||||
component: ChatMessages,
|
||||
args: {
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
username: "User1",
|
||||
message: "Hello everyone! 👋",
|
||||
color: "#ff4500",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
username: "User2",
|
||||
message: "Check this out: ![cool icon](https://via.placeholder.com/20)",
|
||||
color: "#1e90ff",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
username: "User3",
|
||||
message: "This is a simple text message without icons.",
|
||||
color: "#32cd32",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
username: "User4",
|
||||
message: "🎉 👏 Great job!",
|
||||
color: "#ff6347",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
username: "User5",
|
||||
message: "Look at this cool icon: Isn't it awesome? ![cool icon](https://via.placeholder.com/20)",
|
||||
color: "#8a2be2",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
],
|
||||
},
|
||||
argTypes: {
|
||||
messages: {
|
||||
description: "Array of chat messages to be displayed",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ChatMessages>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Messages: Story = {
|
||||
render: (args) => (
|
||||
<div>
|
||||
<ChatMessages {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ScrollableMessages: Story = {
|
||||
render: (args) => (
|
||||
<div style={{ height: "100%", overflow: "hidden" }}>
|
||||
<ChatMessages {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
messages: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
username: `User${i + 1}`,
|
||||
message: `This is message number ${i + 1}.`,
|
||||
color: i % 2 === 0 ? "#ff6347" : "#1e90ff",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
})),
|
||||
},
|
||||
};
|
60
frontend/app/view/chat/chatmessages.tsx
Normal file
60
frontend/app/view/chat/chatmessages.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Markdown } from "@/app/element/markdown";
|
||||
import clsx from "clsx";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
|
||||
import "./chatmessages.less";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
username: string;
|
||||
message: string;
|
||||
color?: string;
|
||||
userIcon?: string;
|
||||
}
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ChatMessages = memo(({ messages, className }: ChatMessagesProps) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const overlayScrollRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<OverlayScrollbarsComponent
|
||||
ref={overlayScrollRef}
|
||||
className={clsx("chat-messages", className)}
|
||||
options={{ scrollbars: { autoHide: "leave" } }}
|
||||
>
|
||||
{messages.map(({ id, username, message, color, userIcon }) => (
|
||||
<div key={id} className="chat-message">
|
||||
{userIcon && <img src={userIcon} alt="user icon" className="chat-user-icon" />}
|
||||
<span className="chat-username" style={{ color: color || "var(--main-text-color)" }}>
|
||||
{username}:
|
||||
</span>
|
||||
<span className="chat-text">
|
||||
<Markdown scrollable={false} text={message}></Markdown>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
});
|
||||
|
||||
ChatMessages.displayName = "ChatMessages";
|
||||
|
||||
export { ChatMessages };
|
225
frontend/app/view/chat/data.tsx
Normal file
225
frontend/app/view/chat/data.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { ChatMessage } from "@/app/view/chat/chatmessages";
|
||||
import { UserStatus } from "@/app/view/chat/userlist";
|
||||
|
||||
export const channels: MenuItem[] = [
|
||||
{
|
||||
label: "Aurora Streams",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Aurora Streams clicked"),
|
||||
},
|
||||
{
|
||||
label: "Crimson Oasis",
|
||||
onClick: () => console.log("Crimson Oasis clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Golden Dunes",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Golden Dunes clicked"),
|
||||
},
|
||||
{
|
||||
label: "Emerald Springs",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Emerald Springs clicked"),
|
||||
},
|
||||
{
|
||||
label: "Ruby Cascades",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Ruby Cascades clicked"),
|
||||
},
|
||||
{
|
||||
label: "Sapphire Falls",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Sapphire Falls clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Velvet Horizon",
|
||||
onClick: () => console.log("Velvet Horizon clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Amber Skies",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Amber Skies clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Mystic Meadows",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Mystic Meadows clicked"),
|
||||
},
|
||||
{
|
||||
label: "Celestial Grove",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Celestial Grove clicked"),
|
||||
},
|
||||
{
|
||||
label: "Twilight Whisper",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Twilight Whisper clicked"),
|
||||
},
|
||||
{
|
||||
label: "Starlit Haven",
|
||||
onClick: () => console.log("Starlit Haven clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Moonlit Trail",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Moonlit Trail clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Silver Mist",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Silver Mist clicked"),
|
||||
},
|
||||
{
|
||||
label: "Eclipse Haven",
|
||||
onClick: () => console.log("Eclipse Haven clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Obsidian Wave",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Obsidian Wave clicked"),
|
||||
},
|
||||
{
|
||||
label: "Ivory Shore",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Ivory Shore clicked"),
|
||||
},
|
||||
{
|
||||
label: "Azure Tide",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Azure Tide clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Dragon's Peak",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Dragon's Peak clicked"),
|
||||
},
|
||||
{
|
||||
label: "Seraph's Wing",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Seraph's Wing clicked"),
|
||||
},
|
||||
{
|
||||
label: "Frozen Abyss",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Frozen Abyss clicked"),
|
||||
},
|
||||
{
|
||||
label: "Radiant Blossom",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Radiant Blossom clicked"),
|
||||
},
|
||||
{
|
||||
label: "Whispering Pines",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Whispering Pines clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Cedar Haven",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Cedar Haven clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Scarlet Veil",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Scarlet Veil clicked"),
|
||||
},
|
||||
{
|
||||
label: "Onyx Spire",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Onyx Spire clicked"),
|
||||
},
|
||||
{
|
||||
label: "Violet Enclave",
|
||||
onClick: () => console.log("Violet Enclave clicked"),
|
||||
subItems: [
|
||||
{
|
||||
label: "Indigo Haven",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Indigo Haven clicked"),
|
||||
},
|
||||
{
|
||||
label: "Amethyst Hollow",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Amethyst Hollow clicked"),
|
||||
},
|
||||
{
|
||||
label: "Crimson Glow",
|
||||
icon: "#",
|
||||
onClick: () => console.log("Crimson Glow clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const users: UserStatus[] = [
|
||||
{
|
||||
label: "John Doe",
|
||||
status: "online",
|
||||
avatarUrl: "https://via.placeholder.com/50",
|
||||
onClick: () => console.log("John Doe clicked"),
|
||||
},
|
||||
{
|
||||
label: "Jane Smith",
|
||||
status: "busy",
|
||||
onClick: () => console.log("Jane Smith clicked"),
|
||||
},
|
||||
{
|
||||
label: "Robert Brown",
|
||||
status: "away",
|
||||
avatarUrl: "https://via.placeholder.com/50",
|
||||
onClick: () => console.log("Robert Brown clicked"),
|
||||
},
|
||||
{
|
||||
label: "Alice Lambert",
|
||||
status: "offline",
|
||||
onClick: () => console.log("Alice Lambert clicked"),
|
||||
},
|
||||
];
|
||||
|
||||
export const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "1",
|
||||
username: "User1",
|
||||
message: "Hello everyone! 👋",
|
||||
color: "#ff4500",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
username: "User2",
|
||||
message: "Check this out: ![cool icon](https://via.placeholder.com/20)",
|
||||
color: "#1e90ff",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
username: "User3",
|
||||
message: "This is a simple text message without icons.",
|
||||
color: "#32cd32",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
username: "User4",
|
||||
message: "🎉 👏 Great job!",
|
||||
color: "#ff6347",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
username: "User5",
|
||||
message: "Look at this cool icon: Isn't it awesome? ![cool icon](https://via.placeholder.com/20)",
|
||||
color: "#8a2be2",
|
||||
userIcon: "https://via.placeholder.com/50",
|
||||
},
|
||||
];
|
36
frontend/app/view/chat/userlist.less
Normal file
36
frontend/app/view/chat/userlist.less
Normal file
@ -0,0 +1,36 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
max-width: 250px;
|
||||
background-color: rgba(255, 255, 255, 0.025);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.user-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.user-status-item:hover {
|
||||
background-color: var(--button-grey-hover-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-status-icon {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-status-text {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
58
frontend/app/view/chat/userlist.stories.tsx
Normal file
58
frontend/app/view/chat/userlist.stories.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { UserList } from "./userlist";
|
||||
|
||||
import "./userlist.less";
|
||||
|
||||
export interface UserStatus {
|
||||
text: string;
|
||||
status: "online" | "busy" | "away" | "offline";
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Elements/UserList",
|
||||
component: UserList,
|
||||
args: {
|
||||
users: [
|
||||
{
|
||||
label: "John Doe",
|
||||
status: "online",
|
||||
onClick: () => console.log("John Doe clicked"),
|
||||
},
|
||||
{
|
||||
label: "Jane Smith",
|
||||
status: "busy",
|
||||
onClick: () => console.log("Jane Smith clicked"),
|
||||
},
|
||||
{
|
||||
label: "Robert Brown",
|
||||
status: "away",
|
||||
onClick: () => console.log("Robert Brown clicked"),
|
||||
},
|
||||
{
|
||||
label: "Alice Lambert",
|
||||
status: "offline",
|
||||
onClick: () => console.log("Alice Lambert clicked"),
|
||||
},
|
||||
],
|
||||
},
|
||||
argTypes: {
|
||||
users: {
|
||||
description: "Array of user statuses to be displayed",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof UserList>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<div>
|
||||
<UserList {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
38
frontend/app/view/chat/userlist.tsx
Normal file
38
frontend/app/view/chat/userlist.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import clsx from "clsx";
|
||||
import { memo } from "react";
|
||||
import { Avatar } from "../../element/avatar";
|
||||
import "./userlist.less";
|
||||
|
||||
export interface UserStatus {
|
||||
label: string;
|
||||
status: "online" | "busy" | "away" | "offline";
|
||||
onClick: () => void;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface UserListProps {
|
||||
users: UserStatus[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UserList = memo(({ users, className }: UserListProps) => {
|
||||
return (
|
||||
<div className={clsx("user-list", className)}>
|
||||
{users.map(({ label, status, onClick, avatarUrl }, index) => (
|
||||
<div key={index} className={clsx("user-status-item", status)} onClick={onClick}>
|
||||
<div className="user-status-icon">
|
||||
<Avatar name={label} status={status} className="size-sm" imageUrl={avatarUrl} />
|
||||
</div>
|
||||
<div className="user-status-text">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UserList.displayName = "UserList";
|
||||
|
||||
export { UserList };
|
1
frontend/types/custom.d.ts
vendored
1
frontend/types/custom.d.ts
vendored
@ -243,6 +243,7 @@ declare global {
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
icon?: string | React.ReactNode;
|
||||
subItems?: MenuItem[];
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
};
|
||||
|
@ -98,6 +98,7 @@
|
||||
"base64-js": "^1.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"color": "^4.2.3",
|
||||
"colord": "^2.9.3",
|
||||
"css-tree": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"debug": "^4.3.7",
|
||||
|
@ -4302,6 +4302,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"colord@npm:^2.9.3":
|
||||
version: 2.9.3
|
||||
resolution: "colord@npm:2.9.3"
|
||||
checksum: 10c0/9699e956894d8996b28c686afe8988720785f476f59335c80ce852ded76ab3ebe252703aec53d9bef54f6219aea6b960fb3d9a8300058a1d0c0d4026460cd110
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"colorspace@npm:1.1.x":
|
||||
version: 1.1.4
|
||||
resolution: "colorspace@npm:1.1.4"
|
||||
@ -11717,6 +11724,7 @@ __metadata:
|
||||
base64-js: "npm:^1.5.1"
|
||||
clsx: "npm:^2.1.1"
|
||||
color: "npm:^4.2.3"
|
||||
colord: "npm:^2.9.3"
|
||||
css-tree: "npm:^3.0.0"
|
||||
dayjs: "npm:^1.11.13"
|
||||
debug: "npm:^4.3.7"
|
||||
|
Loading…
Reference in New Issue
Block a user