workspace switcher + new elements (in storybook) (#1148)

This commit is contained in:
Red J Adaya 2024-10-28 04:22:06 +08:00 committed by GitHub
parent 7e6f96348f
commit 55c8e5a213
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 4225 additions and 589 deletions

View 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

View 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

View 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();
}

View 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",
},
};

View 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 };

View File

@ -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();
}

View File

@ -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";

View 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;
}

View 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"),
},
];

View 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 };

View 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;
}

View 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",
},
};

View 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 };

View 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
}

View 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>,
};

View 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 };

View 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;
}
}

View File

@ -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,
// },
// };

View File

@ -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 };

View File

@ -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";

View File

@ -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();
}

View 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",
},
};

View File

@ -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 };

View File

@ -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;
}

View File

@ -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 };

View File

@ -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);
}
}
}
}

View 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} />,
};

View File

@ -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>
);
};

View 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;
}
}

View 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,
},
};

View 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 };

View 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);
}

View 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",
},
};

View 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 };

View File

View 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",
},
};

View 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 };

View File

@ -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;
}
}
}

View File

@ -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"

View File

@ -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) => {

View 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);
}
}

View 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 };

View 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;
}
}

View 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 };

View 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;
}
}
}
}

View 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 };

View 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 };

View 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 */
}

View 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",
})),
},
};

View 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 };

View 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",
},
];

View 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;
}

View 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>
),
};

View 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 };

View File

@ -243,6 +243,7 @@ declare global {
type MenuItem = {
label: string;
icon?: string | React.ReactNode;
subItems?: MenuItem[];
onClick?: (e: React.MouseEvent<any>) => void;
};

View File

@ -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",

View File

@ -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"