diff --git a/frontend/app/asset/thunder.svg b/frontend/app/asset/thunder.svg new file mode 100644 index 000000000..67bce33f9 --- /dev/null +++ b/frontend/app/asset/thunder.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/app/asset/workspace.svg b/frontend/app/asset/workspace.svg new file mode 100644 index 000000000..220153c89 --- /dev/null +++ b/frontend/app/asset/workspace.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/app/element/avatar.less b/frontend/app/element/avatar.less new file mode 100644 index 000000000..49158bbc1 --- /dev/null +++ b/frontend/app/element/avatar.less @@ -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(); +} diff --git a/frontend/app/element/avatar.stories.tsx b/frontend/app/element/avatar.stories.tsx new file mode 100644 index 000000000..5a3a167f1 --- /dev/null +++ b/frontend/app/element/avatar.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +// 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", + }, +}; diff --git a/frontend/app/element/avatar.tsx b/frontend/app/element/avatar.tsx new file mode 100644 index 000000000..34b20bc90 --- /dev/null +++ b/frontend/app/element/avatar.tsx @@ -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 ( +
+ {imageUrl ? ( + {`${name}'s + ) : ( +
{getInitials(name)}
+ )} +
+
+ ); +}); + +Avatar.displayName = "Avatar"; + +export { Avatar }; diff --git a/frontend/app/element/button.less b/frontend/app/element/button.less index 254c75942..97580de10 100644 --- a/frontend/app/element/button.less +++ b/frontend/app/element/button.less @@ -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(); } diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx index a85088da9..cd8447b78 100644 --- a/frontend/app/element/button.tsx +++ b/frontend/app/element/button.tsx @@ -2,44 +2,45 @@ // 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 { className?: string; children?: ReactNode; + as?: keyof JSX.IntrinsicElements | React.ComponentType; } const Button = memo( - forwardRef(({ children, disabled, className = "", ...props }: ButtonProps, ref) => { - const btnRef = useRef(null); - useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); + forwardRef( + ({ children, disabled, className = "", as: Component = "button", ...props }: ButtonProps, ref) => { + const btnRef = useRef(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' + const categoryClassName = containsButtonCategory ? className : `solid ${className}`; - // 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' - const categoryClassName = containsButtonCategory ? className : `solid ${className}`; + // Check if the className contains any of the color options: green, grey, red, or yellow + const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); + // If no color is present, default to 'green' + const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; - // Check if the className contains any of the color options: green, grey, red, or yellow - const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); - // If no color is present, default to 'green' - const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; - - return ( - - ); - }) + return ( + + {children} + + ); + } + ) ); Button.displayName = "Button"; diff --git a/frontend/app/element/collapsiblemenu.less b/frontend/app/element/collapsiblemenu.less new file mode 100644 index 000000000..0a37113f5 --- /dev/null +++ b/frontend/app/element/collapsiblemenu.less @@ -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; +} diff --git a/frontend/app/element/collapsiblemenu.stories.tsx b/frontend/app/element/collapsiblemenu.stories.tsx new file mode 100644 index 000000000..a8fb98656 --- /dev/null +++ b/frontend/app/element/collapsiblemenu.stories.tsx @@ -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 = { + title: "Elements/CollapsibleMenu", + component: CollapsibleMenu, + argTypes: { + items: { control: "object" }, + renderItem: { control: false }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Container style for limiting the width to 360px +const Container = (props: any) => ( +
+ {props.children} +
+); + +const basicItems = [ + { + label: "Inbox", + icon: , + onClick: () => console.log("Inbox clicked"), + }, + { + label: "Sent Mail", + icon: , + onClick: () => console.log("Sent Mail clicked"), + }, + { + label: "Drafts", + icon: , + onClick: () => console.log("Drafts clicked"), + }, +]; + +const nestedItems = [ + { + label: "Inbox", + icon: , + onClick: () => console.log("Inbox clicked"), + subItems: [ + { + label: "Starred", + icon: , + onClick: () => console.log("Starred clicked"), + }, + { + label: "Important", + icon: , + onClick: () => console.log("Important clicked"), + }, + ], + }, + { + label: "Sent Mail", + icon: , + onClick: () => console.log("Sent Mail clicked"), + }, + { + label: "Drafts", + icon: , + onClick: () => console.log("Drafts clicked"), + }, +]; + +const customRenderItem = ( + item: MenuItem, + isOpen: boolean, + handleClick: (e: React.MouseEvent, item: MenuItem, itemKey: string) => void +) => ( +
+ handleClick(e, item, `${item.label}`)}> + {item.icon} + + handleClick(e, item, `${item.label}`)}> + {item.label} + + {item.subItems && } +
+); + +export const Default: Story = { + args: { + items: basicItems, + }, + render: (args) => ( + + + + ), +}; + +export const NestedList: Story = { + args: { + items: nestedItems, + }, + render: (args) => ( + + + + ), +}; + +export const CustomRender: Story = { + args: { + items: nestedItems, + renderItem: customRenderItem, + }, + render: (args) => ( + + + + ), +}; + +export const WithClickHandlers: Story = { + args: { + items: basicItems, + }, + render: (args) => ( + + + + ), +}; + +export const NestedWithClickHandlers: Story = { + args: { + items: nestedItems, + }, + render: (args) => ( + + + + ), +}; + +const avatarItems = [ + { + label: "John Doe", + icon: , + onClick: () => console.log("John Doe clicked"), + }, + { + label: "Jane Smith", + icon: , + onClick: () => console.log("Jane Smith clicked"), + }, + { + label: "Robert Brown", + icon: , + onClick: () => console.log("Robert Brown clicked"), + }, + { + label: "Alice Lambert", + icon: , + onClick: () => console.log("Alice Lambert clicked"), + }, +]; diff --git a/frontend/app/element/collapsiblemenu.tsx b/frontend/app/element/collapsiblemenu.tsx new file mode 100644 index 000000000..6dd3385ce --- /dev/null +++ b/frontend/app/element/collapsiblemenu.tsx @@ -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, 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, 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 ( +
  • + {renderItem ? ( + renderItem(item, isOpen, (e) => handleClick(e, item, itemKey)) + ) : ( +
    handleClick(e, item, itemKey)}> +
    + {item.icon &&
    {item.icon}
    } +
    {item.label}
    +
    + {hasChildren && ( + + )} +
    + )} + {hasChildren && ( +
      + {item.subItems.map((child, childIndex) => + renderListItem(child, childIndex, `${path}-${index}`) + )} +
    + )} +
  • + ); + }; + + return ( +
      + {items.map((item, index) => renderListItem(item, index, "root"))} +
    + ); +}); + +CollapsibleMenu.displayName = "CollapsibleMenu"; + +export { CollapsibleMenu }; diff --git a/frontend/app/element/emojipalette.less b/frontend/app/element/emojipalette.less new file mode 100644 index 000000000..b45118589 --- /dev/null +++ b/frontend/app/element/emojipalette.less @@ -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; +} diff --git a/frontend/app/element/emojipalette.stories.tsx b/frontend/app/element/emojipalette.stories.tsx new file mode 100644 index 000000000..076229282 --- /dev/null +++ b/frontend/app/element/emojipalette.stories.tsx @@ -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 = { + 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; + +export const DefaultEmojiPalette: Story = { + render: (args) => { + return ( +
    + +
    + ); + }, + args: { + className: "custom-emoji-palette-class", + }, +}; diff --git a/frontend/app/element/emojipalette.tsx b/frontend/app/element/emojipalette.tsx new file mode 100644 index 000000000..f0bf45bc0 --- /dev/null +++ b/frontend/app/element/emojipalette.tsx @@ -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 ( +
    + + + + + + + + + + + +
    + {filteredEmojis.length > 0 ? ( + filteredEmojis.map((item, index) => ( + + )) + ) : ( +
    No emojis found
    + )} +
    +
    +
    +
    + ); +}); + +EmojiPalette.displayName = "EmojiPalette"; + +export { EmojiPalette }; +export type { EmojiItem }; diff --git a/frontend/app/element/expandablemenu.less b/frontend/app/element/expandablemenu.less new file mode 100644 index 000000000..949c8349b --- /dev/null +++ b/frontend/app/element/expandablemenu.less @@ -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 +} diff --git a/frontend/app/element/expandablemenu.stories.tsx b/frontend/app/element/expandablemenu.stories.tsx new file mode 100644 index 000000000..2daf75a1b --- /dev/null +++ b/frontend/app/element/expandablemenu.stories.tsx @@ -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; + +export const Default: Story = { + render: () => ( + + + ๐Ÿ  +
    Dashboard
    + Ctrl + D +
    + + Settings + + ๐Ÿ‘ค +
    Profile
    +
    + + ๐Ÿ”’ +
    Account
    +
    +
    + + More + + Submenu + + ๐Ÿ“„ +
    Item 1
    +
    + + ๐Ÿ“„ +
    Item 2
    +
    +
    +
    +
    + ), +}; + +export const NestedExpandableMenu: Story = { + render: () => ( + + + ๐Ÿ  +
    Home
    +
    + + + ๐Ÿ“ +
    Categories
    + {">"} +
    + + + ๐Ÿ“ฑ +
    Electronics
    +
    + + + ๐Ÿ“ฑ +
    Mobile Phones
    +
    + + + ๐Ÿค– +
    Android Phones
    +
    + + + ๐Ÿ” +
    High-End
    +
    + + ๐Ÿ“ฑ +
    Samsung Galaxy S Series
    + Ctrl + 1 +
    + + ๐Ÿ“ฑ +
    Google Pixel
    + Ctrl + 2 +
    +
    + + Budget + Redmi Note Series + Realme + +
    + + iPhones + iPhone 14 + iPhone SE + +
    + + Laptops + Gaming Laptops + Ultrabooks + +
    + + Appliances + + Kitchen Appliances + Microwaves + Ovens + + + Large Appliances + Refrigerators + Washing Machines + + + Palette + +
    test
    +
    +
    +
    +
    +
    + ), +}; + +const menuData: ExpandableMenuItemData[] = [ + { + type: "item", + leftElement: "๐Ÿ ", + content: "Home", + id: "16830f20-b3b9-42bb-8cc9-db6f409651d8", + }, + { + type: "group", + title: { + leftElement: "๐Ÿ“", + label: "Categories", + rightElement: , + }, + isOpen: true, + id: "4564f119-645e-448c-80b7-2f40f887e670", + children: [ + { + type: "group", + title: { + leftElement: "๐Ÿ“ฑ", + label: "Electronics", + rightElement: , + }, + id: "596e76eb-d87d-425e-9f6e-1519069ee447", + children: [ + { + type: "group", + title: { + leftElement: "๐Ÿ“ฑ", + label: "Mobile Phones", + rightElement: , + }, + id: "0dbb9dff-dad3-4a5a-a6b1-53fea2d811c6", + children: [ + { + type: "group", + title: { + leftElement: "๐Ÿค–", + label: "Android Phones", + rightElement: , + }, + id: "7cc2a2df-37d8-426e-9235-c1a0902d5843", + children: [ + { + type: "group", + title: { + leftElement: "๐Ÿ”", + label: "High-End", + rightElement: , + }, + 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: , + }, + 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: , + }, + 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: , + }, + 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: , + }, + id: "3c9d098e-a4c7-4dae-a350-557672041ebb", + children: [ + { + type: "group", + title: { + label: "Kitchen Appliances", + rightElement: , + }, + 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: , + }, + 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: , + }, + id: "34c52670-9267-47b6-a702-957c6f23a00b", + children: [ + { + type: "item", + content:
    test
    , + id: "965c81bb-e08d-4b90-954b-ea69ce33cdce", + }, + ], + }, + ], + }, + ], + }, +]; + +const renderExpandableMenu = (menuItems: ExpandableMenuItemData[]) => { + return menuItems.map((item) => { + if (item.type === "item") { + return ( + + {item.leftElement && ( + {item.leftElement} + )} +
    {item.content as any}
    + {item.rightElement && ( + {item.rightElement} + )} +
    + ); + } else if (item.type === "group") { + return ( + + + {item.title.leftElement && ( + {item.title.leftElement} + )} +
    {item.title.label}
    + {item.title.rightElement && ( + {item.title.rightElement} + )} +
    + {item.children && renderExpandableMenu(item.children)} +
    + ); + } + }); +}; + +export const DynamicNestedExpandableMenu: Story = { + render: () => {renderExpandableMenu(menuData)}, +}; + +export const NoIndentExpandableMenu: Story = { + render: () => {renderExpandableMenu(menuData)}, +}; diff --git a/frontend/app/element/expandablemenu.tsx b/frontend/app/element/expandablemenu.tsx new file mode 100644 index 000000000..92c84a2c4 --- /dev/null +++ b/frontend/app/element/expandablemenu.tsx @@ -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 ( +
    + {Children.map(children, (child) => { + if (isValidElement(child) && child.type === ExpandableMenuItemGroup) { + return cloneElement(child as any, { singleOpen }); + } + return child; + })} +
    + ); +}; + +type ExpandableMenuItemProps = { + children: ReactNode; + className?: string; + withHoverEffect?: boolean; + onClick?: () => void; +}; + +const ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => { + return ( +
    + {children} +
    + ); +}; + +type ExpandableMenuItemGroupTitleProps = { + children: ReactNode; + className?: string; + onClick?: () => void; +}; + +const ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => { + return ( +
    + {children} +
    + ); +}; + +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(); + + 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
    {child}
    ; + } + }); + + return ( +
    {renderChildren}
    + ); +}; + +type ExpandableMenuItemLeftElementProps = { + children: ReactNode; + onClick?: () => void; +}; + +const ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => { + return ( +
    + {children} +
    + ); +}; + +type ExpandableMenuItemRightElementProps = { + children: ReactNode; + onClick?: () => void; +}; + +const ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => { + return ( +
    + {children} +
    + ); +}; + +export { + ExpandableMenu, + ExpandableMenuItem, + ExpandableMenuItemGroup, + ExpandableMenuItemGroupTitle, + ExpandableMenuItemLeftElement, + ExpandableMenuItemRightElement, +}; +export type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType }; diff --git a/frontend/app/element/flyoutmenu.less b/frontend/app/element/flyoutmenu.less new file mode 100644 index 000000000..a4dcc7d16 --- /dev/null +++ b/frontend/app/element/flyoutmenu.less @@ -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; + } +} diff --git a/frontend/app/element/menu.stories.tsx b/frontend/app/element/flyoutmenu.stories.tsx similarity index 79% rename from frontend/app/element/menu.stories.tsx rename to frontend/app/element/flyoutmenu.stories.tsx index e27b9717a..7466c7fb9 100644 --- a/frontend/app/element/menu.stories.tsx +++ b/frontend/app/element/flyoutmenu.stories.tsx @@ -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; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -100,12 +100,12 @@ export const DefaultRendererLeftPositioned: Story = { style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }} >
    - + - +
    ); @@ -140,12 +140,12 @@ export const DefaultRendererRightPositioned: Story = { style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }} >
    - + - +
    ); @@ -180,12 +180,12 @@ export const DefaultRendererBottomRightPositioned: Story = { style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }} >
    - + - +
    ); @@ -224,7 +224,7 @@ export const DefaultRendererBottomLeftPositioned: Story = { style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }} >
    - + - +
    ); @@ -273,12 +273,12 @@ export const CustomRenderer: Story = { return (
    - + - +
    ); @@ -287,60 +287,3 @@ export const CustomRenderer: Story = { items: items, }, }; - -// export const ContextMenu: Story = { -// render: (args) => { -// const scopeRef = useRef(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 ( -//
    -// {isMenuVisible && } -//
    -// ); -// }, -// args: { -// items: items, -// }, -// }; diff --git a/frontend/app/element/menu.tsx b/frontend/app/element/flyoutmenu.tsx similarity index 98% rename from frontend/app/element/menu.tsx rename to frontend/app/element/flyoutmenu.tsx index d8aff3273..9510da501 100644 --- a/frontend/app/element/menu.tsx +++ b/frontend/app/element/flyoutmenu.tsx @@ -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([]); @@ -139,7 +140,6 @@ const MenuComponent = memo( > {children} - {isOpen && (
    = { + 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; + +export const DefaultInput: Story = { + render: (args) => { + return ( +
    + +
    + ); + }, + args: { + className: "custom-input-group-class", + }, +}; + +export const InputWithLeftElement: Story = { + render: (args) => { + return ( +
    + + + + + + +
    + ); + }, + args: { + className: "custom-input-group-class", + }, +}; + +export const InputWithLeftAndRightElement: Story = { + render: (args) => { + return ( +
    + + $ + + + + + +
    + ); + }, + args: { + className: "custom-input-group-class", + }, +}; diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx index 9b550a53d..76fa56de5 100644 --- a/frontend/app/element/input.tsx +++ b/frontend/app/element/input.tsx @@ -1,176 +1,150 @@ -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(({ children, className }: InputGroupProps, ref) => { + const [isFocused, setIsFocused] = useState(false); + + const manageFocus = (focused: boolean) => { + setIsFocused(focused); + }; + + return ( +
    + {React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child as any, { manageFocus }); + } + return child; + })} +
    + ); + }) +); + +interface InputLeftElementProps { + children: React.ReactNode; + className?: string; +} + +const InputLeftElement = memo(({ children, className }: InputLeftElementProps) => { + return
    {children}
    ; +}); + +interface InputRightElementProps { + children: React.ReactNode; + className?: string; +} + +const InputRightElement = memo(({ children, className }: InputRightElementProps) => { + return
    {children}
    ; +}); + interface InputProps { - label?: string; value?: string; className?: string; onChange?: (value: string) => void; - onKeyDown?: (event: React.KeyboardEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; onFocus?: () => void; onBlur?: () => void; placeholder?: string; defaultValue?: string; - decoration?: InputDecorationProps; required?: boolean; maxLength?: number; autoFocus?: boolean; disabled?: boolean; isNumber?: boolean; - inputRef?: React.MutableRefObject; + inputRef?: React.MutableRefObject; + manageFocus?: (isFocused: boolean) => void; } -const Input = forwardRef( - ( - { - label, - value, - className, - onChange, - onKeyDown, - onFocus, - onBlur, - placeholder, - defaultValue = "", - decoration, - required, - maxLength, - autoFocus, - disabled, - isNumber, - inputRef, - }: 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(null); +const Input = memo( + forwardRef( + ( + { + value, + className, + onChange, + onKeyDown, + onFocus, + onBlur, + placeholder, + defaultValue = "", + required, + maxLength, + autoFocus, + disabled, + isNumber, + manageFocus, + }: InputProps, + ref + ) => { + const [internalValue, setInternalValue] = useState(defaultValue); + const inputRef = useRef(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 handleInputChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; - 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); + if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { + return; } - } - onBlur && onBlur(); - }; - const handleInputChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; + if (value === undefined) { + setInternalValue(inputValue); + } - if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { - return; - } + onChange && onChange(inputValue); + }; - if (required && !inputValue) { - setError(true); - setHasContent(false); - } else { - setError(false); - setHasContent(Boolean(inputValue)); - } + const handleFocus = () => { + manageFocus?.(true); + onFocus?.(); + }; - if (value === undefined) { - setInternalValue(inputValue); - } + const handleBlur = () => { + manageFocus?.(false); + onBlur?.(); + }; - onChange && onChange(inputValue); - }; + const inputValue = value ?? internalValue; - const inputValue = value ?? internalValue; - - return ( -
    - {decoration?.startDecoration && <>{decoration.startDecoration}} -
    - {label && ( - - )} - -
    - {decoration?.endDecoration && <>{decoration.endDecoration}} -
    - ); - } + return ( + + ); + } + ) ); -export { Input }; -export type { InputDecorationProps, InputProps }; +export { Input, InputGroup, InputLeftElement, InputRightElement }; +export type { InputGroupProps, InputLeftElementProps, InputProps, InputRightElementProps }; diff --git a/frontend/app/element/inputdecoration.less b/frontend/app/element/inputdecoration.less deleted file mode 100644 index bf4366f92..000000000 --- a/frontend/app/element/inputdecoration.less +++ /dev/null @@ -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; -} diff --git a/frontend/app/element/inputdecoration.tsx b/frontend/app/element/inputdecoration.tsx deleted file mode 100644 index 1a92773d0..000000000 --- a/frontend/app/element/inputdecoration.tsx +++ /dev/null @@ -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 ( -
    - {children} -
    - ); -}; - -export { InputDecoration }; diff --git a/frontend/app/element/menu.less b/frontend/app/element/menu.less deleted file mode 100644 index 12b3e1c6b..000000000 --- a/frontend/app/element/menu.less +++ /dev/null @@ -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); - } - } - } -} diff --git a/frontend/app/element/menubutton.stories.tsx b/frontend/app/element/menubutton.stories.tsx new file mode 100644 index 000000000..ca3a4fcbc --- /dev/null +++ b/frontend/app/element/menubutton.stories.tsx @@ -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 = { + 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; + +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) => , +}; + +export const WithMoreItems: Story = { + args: { + items: items, + text: "Extended Menu", + title: "Extended Menu Button", + className: "", + }, + render: (args) => , +}; diff --git a/frontend/app/element/menubutton.tsx b/frontend/app/element/menubutton.tsx index 95cf4bba0..54d5b769f 100644 --- a/frontend/app/element/menubutton.tsx +++ b/frontend/app/element/menubutton.tsx @@ -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 (
    - +
    - +
    ); }; diff --git a/frontend/app/element/multilineinput.less b/frontend/app/element/multilineinput.less new file mode 100644 index 000000000..0296eeddc --- /dev/null +++ b/frontend/app/element/multilineinput.less @@ -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; + } +} diff --git a/frontend/app/element/multilineinput.stories.tsx b/frontend/app/element/multilineinput.stories.tsx new file mode 100644 index 000000000..32bee54a5 --- /dev/null +++ b/frontend/app/element/multilineinput.stories.tsx @@ -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 = { + 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; + +// Default MultiLineInput Story +export const DefaultMultiLineInput: Story = { + render: (args) => { + const [message, setMessage] = useState(""); + + const handleChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + }; + + return ( +
    + +
    + ); + }, + 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) => { + setMessage(e.target.value); + }; + + return ( +
    + +
    + ); + }, + args: { + placeholder: "Type a long message...", + rows: 1, + maxRows: 10, + }, +}; diff --git a/frontend/app/element/multilineinput.tsx b/frontend/app/element/multilineinput.tsx new file mode 100644 index 000000000..881cbffd6 --- /dev/null +++ b/frontend/app/element/multilineinput.tsx @@ -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) => void; + onKeyDown?: (e: React.KeyboardEvent) => 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( + ( + { + value, + className, + onChange, + onKeyDown, + onFocus, + onBlur, + placeholder, + defaultValue = "", + maxLength, + autoFocus, + disabled, + rows = 1, + maxRows = 5, + manageFocus, + }: MultiLineInputProps, + ref + ) => { + const textareaRef = useRef(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) => { + 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 ( +