base
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Accordion } from "./accordion";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Accordion",
|
||||
component: Accordion.Root,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { disable: true },
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
subcomponents: {
|
||||
Item: Accordion.Item,
|
||||
Trigger: Accordion.Trigger,
|
||||
Content: Accordion.Content,
|
||||
},
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
} satisfies Meta<typeof Accordion.Root>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>What is Plane?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
Plane is an open-source project management tool designed for developers and teams to plan, track, and manage
|
||||
their work efficiently.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>How do I get started?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
You can get started by signing up for an account, creating your first workspace, and inviting your team
|
||||
members to collaborate.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Trigger>Is it free to use?</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
Plane offers both free and paid plans. The free plan includes essential features for small teams, while paid
|
||||
plans unlock advanced functionality.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleOpen: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root defaultValue={["item-1"]} className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Section 1</Accordion.Trigger>
|
||||
<Accordion.Content>Content for section 1. Only one section can be open at a time.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Section 2</Accordion.Trigger>
|
||||
<Accordion.Content>Content for section 2.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Trigger>Section 3</Accordion.Trigger>
|
||||
<Accordion.Content>Content for section 3.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllowMultiple: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root allowMultiple defaultValue={["item-1", "item-2"]} className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>First Section</Accordion.Trigger>
|
||||
<Accordion.Content>Multiple sections can be open at the same time.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>Second Section</Accordion.Trigger>
|
||||
<Accordion.Content>This section is also open by default.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Trigger>Third Section</Accordion.Trigger>
|
||||
<Accordion.Content>You can open this section while keeping the others open.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDisabledItem: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>Enabled Section</Accordion.Trigger>
|
||||
<Accordion.Content>This section can be toggled.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2" disabled>
|
||||
<Accordion.Trigger>Disabled Section</Accordion.Trigger>
|
||||
<Accordion.Content>This content cannot be accessed.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Trigger>Another Enabled Section</Accordion.Trigger>
|
||||
<Accordion.Content>This section can also be toggled.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomIcon: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform group-data-[panel-open]:rotate-180"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Custom Chevron Icon
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
This accordion uses a custom chevron icon instead of the default plus icon.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform group-data-[panel-open]:rotate-180"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Another Section
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>All items in this accordion use the custom icon.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AsChildTrigger: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Accordion.Root className="w-96">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger asChild>
|
||||
<button className="bg-blue-500 hover:bg-blue-600 w-full rounded-md px-4 py-2 text-left text-on-color">
|
||||
Custom Button Trigger
|
||||
</button>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
When using asChild, you can completely customize the trigger element without the default icon wrapper.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger asChild>
|
||||
<button className="bg-green-500 hover:bg-green-600 w-full rounded-md px-4 py-2 text-left text-on-color">
|
||||
Another Custom Trigger
|
||||
</button>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>This gives you full control over the trigger styling and behavior.</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Accordion as BaseAccordion } from "@base-ui-components/react";
|
||||
|
||||
import { PlusIcon } from "../icons";
|
||||
|
||||
export interface AccordionRootProps {
|
||||
defaultValue?: string[];
|
||||
allowMultiple?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface AccordionItemProps {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface AccordionTriggerProps {
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export interface AccordionContentProps {
|
||||
className?: string;
|
||||
contentWrapperClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AccordionRoot({ defaultValue = [], allowMultiple = false, className = "", children }: AccordionRootProps) {
|
||||
return (
|
||||
<BaseAccordion.Root defaultValue={defaultValue} openMultiple={allowMultiple} className={`text-14 ${className}`}>
|
||||
{children}
|
||||
</BaseAccordion.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({ value, disabled, className = "", children }: AccordionItemProps) {
|
||||
return (
|
||||
<BaseAccordion.Item value={value} disabled={disabled} className={`relative ${className}`}>
|
||||
{children}
|
||||
</BaseAccordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className = "",
|
||||
icon = <PlusIcon aria-hidden="true" className="transition-all ease-out group-data-[panel-open]:rotate-45" />,
|
||||
iconClassName = "",
|
||||
children,
|
||||
asChild = false,
|
||||
}: AccordionTriggerProps) {
|
||||
return (
|
||||
<BaseAccordion.Header>
|
||||
{asChild ? (
|
||||
<BaseAccordion.Trigger className={`w-full py-2 ${className}`}>{children}</BaseAccordion.Trigger>
|
||||
) : (
|
||||
<BaseAccordion.Trigger className={`flex w-full items-center justify-between gap-2 py-2 ${className}`}>
|
||||
{children}
|
||||
<span aria-hidden="true" className={`flex-shrink-0 ${iconClassName}`}>
|
||||
{icon}
|
||||
</span>
|
||||
</BaseAccordion.Trigger>
|
||||
)}
|
||||
</BaseAccordion.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({ className = "", contentWrapperClassName = "", children }: AccordionContentProps) {
|
||||
return (
|
||||
<BaseAccordion.Panel
|
||||
className={`h-[var(--accordion-panel-height)] overflow-hidden transition-[height] ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 ${className}`}
|
||||
>
|
||||
<div className={`py-2 ${contentWrapperClassName}`}>{children}</div>
|
||||
</BaseAccordion.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export const Accordion = {
|
||||
Root: AccordionRoot,
|
||||
Item: AccordionItem,
|
||||
Trigger: AccordionTrigger,
|
||||
Content: AccordionContent,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./accordion";
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AnimatedCounter } from "./animated-counter";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AnimatedCounter",
|
||||
component: AnimatedCounter,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
size: "md",
|
||||
count: 0,
|
||||
},
|
||||
} satisfies Meta<typeof AnimatedCounter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render(args) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-600 shadow-md rounded-lg px-4 py-2 font-medium text-on-color transition-colors focus:ring-2 focus:ring-danger-strong focus:ring-offset-2 focus:outline-none"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1))}
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<div className="bg-gray-50 border-gray-200 flex h-12 min-w-[60px] items-center justify-center rounded-lg border">
|
||||
<AnimatedCounter {...args} count={count} />
|
||||
</div>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 shadow-md rounded-lg px-4 py-2 font-medium text-on-color transition-colors focus:ring-2 focus:ring-success-strong focus:ring-offset-2 focus:outline-none"
|
||||
onClick={() => setCount((prev) => prev + 1)}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render() {
|
||||
const [count, setCount] = useState(42);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="rounded-sm bg-layer-1 px-3 py-1 text-13 hover:bg-surface-2"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1))}
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<button
|
||||
className="rounded-sm bg-layer-1 px-3 py-1 text-13 hover:bg-surface-2"
|
||||
onClick={() => setCount((prev) => prev + 1)}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-13 text-placeholder">Small:</span>
|
||||
<div className="flex h-8 min-w-[40px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-13 text-placeholder">Medium:</span>
|
||||
<div className="flex h-10 min-w-[50px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-13 text-placeholder">Large:</span>
|
||||
<div className="flex h-12 min-w-[60px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeNumbers: Story = {
|
||||
render() {
|
||||
const [count, setCount] = useState(1234567);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-600 rounded-sm px-3 py-1 text-13 text-on-color"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1000))}
|
||||
>
|
||||
-1000
|
||||
</button>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 rounded-sm px-3 py-1 text-13 text-on-color"
|
||||
onClick={() => setCount((prev) => prev + 1000)}
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-12 min-w-[100px] items-center justify-center rounded-lg border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Countdown: Story = {
|
||||
render() {
|
||||
const [count, setCount] = useState(10);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning && count > 0) {
|
||||
const timer = setTimeout(() => setCount((prev) => prev - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (count === 0) {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [count, isRunning]);
|
||||
|
||||
const handleStart = () => {
|
||||
setCount(10);
|
||||
setIsRunning(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-16 min-w-[60px] items-center justify-center rounded-lg border-2 border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="lg" className="text-20" />
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg bg-accent-primary px-6 py-2 font-medium text-on-color hover:bg-accent-primary/80"
|
||||
onClick={handleStart}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? "Counting..." : "Start Countdown"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LiveCounter: Story = {
|
||||
render() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
const timer = setInterval(() => setCount((prev) => prev + 1), 500);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [isRunning]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-16 min-w-[80px] items-center justify-center rounded-lg border-2 border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="lg" className="text-20" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 rounded-sm px-4 py-2 font-medium text-on-color"
|
||||
onClick={() => setIsRunning(true)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-600 rounded-sm px-4 py-2 font-medium text-on-color"
|
||||
onClick={() => setIsRunning(false)}
|
||||
disabled={!isRunning}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-500 hover:bg-gray-600 rounded-sm px-4 py-2 font-medium text-on-color"
|
||||
onClick={() => {
|
||||
setIsRunning(false);
|
||||
setCount(0);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleCounters: Story = {
|
||||
render() {
|
||||
const [likes, setLikes] = useState(42);
|
||||
const [comments, setComments] = useState(15);
|
||||
const [shares, setShares] = useState(8);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="max-w-md rounded-lg border border-subtle p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium">Engagement Stats</h3>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
<div className="text-13 text-placeholder">Likes</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-sm bg-layer-1 hover:bg-surface-2"
|
||||
onClick={() => setLikes((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex h-10 min-w-[40px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={likes} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
<div className="text-13 text-placeholder">Comments</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-sm bg-layer-1 hover:bg-surface-2"
|
||||
onClick={() => setComments((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex h-10 min-w-[40px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={comments} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
<div className="text-13 text-placeholder">Shares</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-sm bg-layer-1 hover:bg-surface-2"
|
||||
onClick={() => setShares((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex h-10 min-w-[40px] items-center justify-center rounded-sm border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={shares} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InBadge: Story = {
|
||||
render() {
|
||||
const [notifications, setNotifications] = useState(3);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<button className="rounded-lg border border-subtle bg-layer-1 px-4 py-2">Notifications</button>
|
||||
<div className="bg-red-500 absolute -top-2 -right-2 flex h-6 min-w-[24px] items-center justify-center rounded-full px-1.5 text-on-color">
|
||||
<AnimatedCounter count={notifications} size="sm" className="text-11 font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-sm bg-accent-primary px-4 py-2 text-on-color hover:bg-accent-primary/80"
|
||||
onClick={() => setNotifications((prev) => prev + 1)}
|
||||
>
|
||||
Add Notification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FastAnimation: Story = {
|
||||
render() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const incrementFast = () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
setTimeout(() => setCount((prev) => prev + 1), i * 50);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-12 min-w-[60px] items-center justify-center rounded-lg border border-subtle bg-layer-1">
|
||||
<AnimatedCounter count={count} size="lg" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded-sm bg-accent-primary px-4 py-2 text-on-color hover:bg-accent-primary/80"
|
||||
onClick={incrementFast}
|
||||
>
|
||||
+10 Fast
|
||||
</button>
|
||||
<button className="rounded-sm bg-layer-1 px-4 py-2 hover:bg-surface-2" onClick={() => setCount(0)}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface AnimatedCounterProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-11",
|
||||
md: "text-13",
|
||||
lg: "text-14",
|
||||
};
|
||||
|
||||
export function AnimatedCounter({ count, className, size = "md" }: AnimatedCounterProps) {
|
||||
// states
|
||||
const [displayCount, setDisplayCount] = useState(count);
|
||||
const [prevCount, setPrevCount] = useState(count);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [direction, setDirection] = useState<"up" | "down" | null>(null);
|
||||
const [animationKey, setAnimationKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (count !== prevCount) {
|
||||
setDirection(count > prevCount ? "up" : "down");
|
||||
setIsAnimating(true);
|
||||
setAnimationKey((prev) => prev + 1);
|
||||
|
||||
// Update the display count immediately, animation will show the transition
|
||||
setDisplayCount(count);
|
||||
|
||||
// End animation after CSS transition
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
setDirection(null);
|
||||
setPrevCount(count);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [count, prevCount]);
|
||||
|
||||
const sizeClass = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex min-w-2 items-center justify-center overflow-hidden", sizeClass)}>
|
||||
{/* Previous number sliding out */}
|
||||
{isAnimating && (
|
||||
<span
|
||||
key={`prev-${animationKey}`}
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center font-medium",
|
||||
"animate-slide-out",
|
||||
direction === "up" && "[--slide-out-dir:-100%]",
|
||||
direction === "down" && "[--slide-out-dir:100%]",
|
||||
sizeClass,
|
||||
{
|
||||
"animate-fade-out animate-slide-out": isAnimating && direction === "up",
|
||||
"animate-fade-out animate-slide-out-down": isAnimating && direction === "down",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{prevCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* New number sliding in */}
|
||||
<span
|
||||
key={`current-${animationKey}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center font-medium",
|
||||
!isAnimating && "opacity-100",
|
||||
sizeClass,
|
||||
{
|
||||
"animate-slide-in-from-bottom": isAnimating && direction === "up",
|
||||
"animate-slide-in-from-top": isAnimating && direction === "down",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { AnimatedCounter } from "./animated-counter";
|
||||
export type { AnimatedCounterProps } from "./animated-counter";
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Avatar } from "./avatar";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Avatar",
|
||||
component: Avatar,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
name: "John Doe",
|
||||
src: "https://i.pravatar.cc/150?img=1",
|
||||
},
|
||||
} satisfies Meta<typeof Avatar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithName: Story = {
|
||||
args: {
|
||||
name: "Jane Smith",
|
||||
src: "https://i.pravatar.cc/150?img=5",
|
||||
},
|
||||
};
|
||||
|
||||
export const Fallback: Story = {
|
||||
args: {
|
||||
name: "Alice Johnson",
|
||||
src: "invalid-url",
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackWithCustomColor: Story = {
|
||||
args: {
|
||||
name: "Bob Wilson",
|
||||
src: "invalid-url",
|
||||
fallbackBackgroundColor: "#3b82f6",
|
||||
fallbackTextColor: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackWithCustomText: Story = {
|
||||
args: {
|
||||
fallbackText: "AB",
|
||||
src: "invalid-url",
|
||||
fallbackBackgroundColor: "#10b981",
|
||||
fallbackTextColor: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
name: "Small Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=2",
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
name: "Medium Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=3",
|
||||
size: "md",
|
||||
},
|
||||
};
|
||||
|
||||
export const Base: Story = {
|
||||
args: {
|
||||
name: "Base Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=4",
|
||||
size: "base",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
name: "Large Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=6",
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const CircleShape: Story = {
|
||||
args: {
|
||||
name: "Circle Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=7",
|
||||
shape: "circle",
|
||||
},
|
||||
};
|
||||
|
||||
export const SquareShape: Story = {
|
||||
args: {
|
||||
name: "Square Avatar",
|
||||
src: "https://i.pravatar.cc/150?img=8",
|
||||
shape: "square",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name="Small" src="https://i.pravatar.cc/150?img=10" size="sm" />
|
||||
<Avatar name="Medium" src="https://i.pravatar.cc/150?img=11" size="md" />
|
||||
<Avatar name="Base" src="https://i.pravatar.cc/150?img=12" size="base" />
|
||||
<Avatar name="Large" src="https://i.pravatar.cc/150?img=13" size="lg" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllShapes: Story = {
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name="Circle" src="https://i.pravatar.cc/150?img=14" shape="circle" />
|
||||
<Avatar name="Square" src="https://i.pravatar.cc/150?img=15" shape="square" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackVariations: Story = {
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name="Alice" src="invalid-url" fallbackBackgroundColor="#ef4444" fallbackTextColor="#ffffff" />
|
||||
<Avatar name="Bob" src="invalid-url" fallbackBackgroundColor="#f59e0b" fallbackTextColor="#ffffff" />
|
||||
<Avatar name="Charlie" src="invalid-url" fallbackBackgroundColor="#10b981" fallbackTextColor="#ffffff" />
|
||||
<Avatar name="David" src="invalid-url" fallbackBackgroundColor="#3b82f6" fallbackTextColor="#ffffff" />
|
||||
<Avatar name="Eve" src="invalid-url" fallbackBackgroundColor="#8b5cf6" fallbackTextColor="#ffffff" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AvatarGroup: Story = {
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div className="flex -space-x-2">
|
||||
<Avatar name="User 1" src="https://i.pravatar.cc/150?img=20" size="md" className="ring-2 ring-white" />
|
||||
<Avatar name="User 2" src="https://i.pravatar.cc/150?img=21" size="md" className="ring-2 ring-white" />
|
||||
<Avatar name="User 3" src="https://i.pravatar.cc/150?img=22" size="md" className="ring-2 ring-white" />
|
||||
<Avatar name="User 4" src="https://i.pravatar.cc/150?img=23" size="md" className="ring-2 ring-white" />
|
||||
<Avatar
|
||||
fallbackText="+5"
|
||||
src="invalid-url"
|
||||
size="md"
|
||||
fallbackBackgroundColor="#6b7280"
|
||||
fallbackTextColor="#ffffff"
|
||||
className="ring-2 ring-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui-components/react/avatar";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
||||
|
||||
type Props = {
|
||||
name?: string; //The name of the avatar which will be displayed on the tooltip
|
||||
fallbackBackgroundColor?: string; //The background color if the avatar image fails to load
|
||||
fallbackText?: string;
|
||||
fallbackTextColor?: string; //The text color if the avatar image fails to load
|
||||
showTooltip?: boolean;
|
||||
size?: TAvatarSize; //The size of the avatars
|
||||
shape?: "circle" | "square";
|
||||
src?: string; //The source of the avatar image
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the size details based on the size prop
|
||||
* @param size The size of the avatar
|
||||
* @returns The size details
|
||||
*/
|
||||
export const getSizeInfo = (size: TAvatarSize) => {
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return {
|
||||
avatarSize: "h-4 w-4",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "md":
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "base":
|
||||
return {
|
||||
avatarSize: "h-6 w-6",
|
||||
fontSize: "text-13",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
case "lg":
|
||||
return {
|
||||
avatarSize: "h-7 w-7",
|
||||
fontSize: "text-13",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the border radius based on the shape prop
|
||||
* @param shape The shape of the avatar
|
||||
* @returns The border radius
|
||||
*/
|
||||
export const getBorderRadius = (shape: "circle" | "square") => {
|
||||
switch (shape) {
|
||||
case "circle":
|
||||
return "rounded-full";
|
||||
case "square":
|
||||
return "rounded-sm";
|
||||
default:
|
||||
return "rounded-full";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the value is a valid number
|
||||
* @param value The value to check
|
||||
* @returns Whether the value is a valid number or not
|
||||
*/
|
||||
export const isAValidNumber = (value: unknown): value is number => typeof value === "number" && !Number.isNaN(value);
|
||||
|
||||
export function Avatar(props: Props) {
|
||||
const {
|
||||
name,
|
||||
fallbackBackgroundColor,
|
||||
fallbackText,
|
||||
fallbackTextColor,
|
||||
size = "md",
|
||||
shape = "circle",
|
||||
src,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
// get size details based on the size prop
|
||||
const sizeInfo = getSizeInfo(size);
|
||||
|
||||
const fallbackLetter = name?.[0]?.toUpperCase() ?? fallbackText ?? "?";
|
||||
return (
|
||||
<div
|
||||
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
})}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AvatarPrimitive.Root className={cn("h-full w-full", getBorderRadius(shape), className)}>
|
||||
<AvatarPrimitive.Image src={src} width="48" height="48" />
|
||||
<AvatarPrimitive.Fallback
|
||||
className={cn(sizeInfo.fontSize, "grid h-full w-full place-items-center", getBorderRadius(shape), className)}
|
||||
style={{
|
||||
backgroundColor: fallbackBackgroundColor ?? "var(--background-color-accent-primary)",
|
||||
color: fallbackTextColor ?? "var(--text-color-on-color)",
|
||||
}}
|
||||
>
|
||||
{fallbackLetter}
|
||||
</AvatarPrimitive.Fallback>
|
||||
</AvatarPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./avatar";
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Badge",
|
||||
component: Badge,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: "Badge",
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: {
|
||||
variant: "neutral",
|
||||
children: "Neutral Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Brand: Story = {
|
||||
args: {
|
||||
variant: "brand",
|
||||
children: "Brand Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: "warning",
|
||||
children: "Warning Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: "success",
|
||||
children: "Success Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: "danger",
|
||||
children: "Danger Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
children: "Small Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Base: Story = {
|
||||
args: {
|
||||
size: "base",
|
||||
children: "Base Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: "lg",
|
||||
children: "Large Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPrependIcon: Story = {
|
||||
args: {
|
||||
prependIcon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
),
|
||||
children: "With Prepend Icon",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAppendIcon: Story = {
|
||||
args: {
|
||||
appendIcon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
),
|
||||
children: "With Append Icon",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-16 font-semibold">Primary Variants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="brand">Brand</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="danger">Danger</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="base">Base</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
import type { BadgeProps } from "./helper";
|
||||
import { getBadgeIconStyling, badgeVariants } from "./helper";
|
||||
|
||||
const Badge = React.forwardRef(function Badge(props: BadgeProps, ref: React.ForwardedRef<HTMLSpanElement>) {
|
||||
const { variant = "neutral", size = "base", prependIcon = null, appendIcon = null, children, ...rest } = props;
|
||||
|
||||
const badgeIconStyle = getBadgeIconStyling(size ?? "base");
|
||||
|
||||
return (
|
||||
<span ref={ref} className={cn(badgeVariants({ variant, size }))} {...rest}>
|
||||
{prependIcon && React.cloneElement(prependIcon, { className: cn("shrink-0", badgeIconStyle), strokeWidth: 2 })}
|
||||
{children}
|
||||
{appendIcon && React.cloneElement(appendIcon, { className: cn("shrink-0", badgeIconStyle), strokeWidth: 2 })}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Badge.displayName = "plane-ui-badge";
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const badgeVariants = cva("inline-flex items-center justify-center gap-1 whitespace-nowrap transition-colors", {
|
||||
variants: {
|
||||
variant: {
|
||||
neutral: "bg-layer-3 text-tertiary",
|
||||
brand: "bg-accent-subtle-hover text-accent-primary",
|
||||
warning: "bg-warning-subtle text-warning-primary",
|
||||
success: "bg-success-subtle-1 text-success-primary",
|
||||
danger: "bg-danger-subtle text-danger-primary",
|
||||
},
|
||||
size: {
|
||||
sm: "h-4 rounded-sm px-1 text-caption-sm-medium",
|
||||
base: "h-5 rounded-md px-1.5 text-caption-sm-medium",
|
||||
lg: "h-6 rounded-md px-2 text-caption-md-medium",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "neutral",
|
||||
size: "base",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeProps = Omit<React.HTMLAttributes<HTMLSpanElement>, "className"> &
|
||||
VariantProps<typeof badgeVariants> & {
|
||||
appendIcon?: React.ReactElement;
|
||||
prependIcon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type TBadgeVariant = NonNullable<BadgeProps["variant"]>;
|
||||
export type TBadgeSize = NonNullable<BadgeProps["size"]>;
|
||||
|
||||
const badgeIconStyling: Record<TBadgeSize, string> = {
|
||||
sm: "size-3.5",
|
||||
base: "size-3.5",
|
||||
lg: "size-4",
|
||||
};
|
||||
|
||||
export function getBadgeIconStyling(size: TBadgeSize): string {
|
||||
return badgeIconStyling[size];
|
||||
}
|
||||
|
||||
export function getBadgeStyling(variant: TBadgeVariant, size: TBadgeSize): string {
|
||||
return badgeVariants({ variant, size });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { Badge } from "./badge";
|
||||
export { getBadgeStyling } from "./helper";
|
||||
export type { BadgeProps, TBadgeVariant, TBadgeSize } from "./helper";
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Banner } from "./banner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Banner",
|
||||
component: Banner,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["success", "error", "warning", "info"],
|
||||
description: "Visual variant of the banner",
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Banner message text",
|
||||
},
|
||||
icon: {
|
||||
control: false,
|
||||
description: "Icon element to display before the title",
|
||||
},
|
||||
action: {
|
||||
control: false,
|
||||
description: "Action element(s) to display on the right side",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Banner>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Sample icons for different variants
|
||||
function SuccessIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-success-primary"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-danger-primary"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-yellow-600"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-600"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseButton({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="rounded-sm p-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-text-secondary"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Stories
|
||||
// ============================================================================
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "This is an interactive banner. Use the controls to customize it.",
|
||||
icon: <InfoIcon />,
|
||||
dismissible: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Variants
|
||||
// ============================================================================
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: "success",
|
||||
title: "Operation completed successfully",
|
||||
icon: <SuccessIcon />,
|
||||
action: <CloseButton />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: "error",
|
||||
title: "An error occurred while processing your request",
|
||||
icon: <ErrorIcon />,
|
||||
action: <CloseButton />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Your session will expire in 5 minutes",
|
||||
icon: <WarningIcon />,
|
||||
action: <CloseButton />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "New features are available. Check out what's new!",
|
||||
icon: <InfoIcon />,
|
||||
action: <CloseButton />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "../utils";
|
||||
import type { TBannerVariant } from "./helper";
|
||||
import {
|
||||
getBannerStyling,
|
||||
getBannerTitleStyling,
|
||||
getBannerActionStyling,
|
||||
getBannerDismissStyling,
|
||||
getBannerDismissIconStyling,
|
||||
} from "./helper";
|
||||
|
||||
export interface BannerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Visual variant of the banner */
|
||||
variant?: TBannerVariant;
|
||||
/** Icon to display before the title */
|
||||
icon?: React.ReactNode;
|
||||
/** Banner title/message */
|
||||
title?: React.ReactNode;
|
||||
/** Action elements to display on the right side */
|
||||
action?: React.ReactNode;
|
||||
/** Whether the banner can be dismissed */
|
||||
dismissible?: boolean;
|
||||
/** Callback when banner is dismissed */
|
||||
onDismiss?: () => void;
|
||||
/** Whether to show the banner */
|
||||
visible?: boolean;
|
||||
/** Animation duration for show/hide */
|
||||
animationDuration?: number;
|
||||
}
|
||||
|
||||
export const Banner = React.forwardRef(function Banner(
|
||||
{
|
||||
icon,
|
||||
title,
|
||||
action,
|
||||
variant = "info",
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
visible = true,
|
||||
animationDuration = 200,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BannerProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
// Handle dismissal
|
||||
const handleDismiss = () => {
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if not visible
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get styling using helper functions
|
||||
const containerStyling = getBannerStyling(variant);
|
||||
const iconStyling = "flex items-center justify-center flex-shrink-0 size-5";
|
||||
const titleStyling = getBannerTitleStyling();
|
||||
const actionStyling = getBannerActionStyling();
|
||||
const dismissStyling = getBannerDismissStyling();
|
||||
const dismissIconStyling = getBannerDismissIconStyling();
|
||||
|
||||
// Render custom icon component if provided
|
||||
const renderIcon = () => {
|
||||
if (icon) {
|
||||
return <div className={cn(iconStyling)}>{icon}</div>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render dismiss button if dismissible
|
||||
const renderDismissButton = () => {
|
||||
if (!dismissible) return null;
|
||||
|
||||
return (
|
||||
<button onClick={handleDismiss} className={cn(dismissStyling)} aria-label="Dismiss banner">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(dismissIconStyling)}
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(containerStyling, className)}
|
||||
style={{
|
||||
transitionDuration: `${animationDuration}ms`,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Left side: Icon and Title */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{renderIcon()}
|
||||
{title && <div className={cn(titleStyling)}>{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Right side: Actions */}
|
||||
{(action || dismissible) && (
|
||||
<div className={cn(actionStyling)}>
|
||||
{action}
|
||||
{renderDismissButton()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Banner.displayName = "Banner";
|
||||
|
||||
// Export variant types for external use
|
||||
export type BannerVariant = TBannerVariant;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export type TBannerVariant = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface IBannerStyling {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export const bannerSizeStyling = {
|
||||
container: "py-3 px-6 h-12",
|
||||
icon: "w-5 h-5",
|
||||
title: "text-13",
|
||||
action: "gap-2",
|
||||
};
|
||||
|
||||
// TODO: update this with new color once its implemented
|
||||
// Banner variant styling
|
||||
export const bannerStyling: IBannerStyling = {
|
||||
success: "bg-success-subtle",
|
||||
error: "bg-danger-subtle",
|
||||
warning: "bg-yellow-500/10",
|
||||
info: "bg-blue-500/10",
|
||||
};
|
||||
|
||||
// Base banner styles
|
||||
export const bannerBaseStyles = "flex items-center justify-between w-full transition-all duration-200";
|
||||
|
||||
// Get banner container styling
|
||||
export const getBannerStyling = (variant: TBannerVariant): string => {
|
||||
const variantStyles = bannerStyling[variant];
|
||||
const sizeStyles = bannerSizeStyling.container;
|
||||
|
||||
return `${bannerBaseStyles} ${variantStyles} ${sizeStyles}`;
|
||||
};
|
||||
|
||||
// Get title styling
|
||||
export const getBannerTitleStyling = (): string =>
|
||||
`font-medium text-secondary flex-1 min-w-0 ${bannerSizeStyling.title}`;
|
||||
|
||||
// Get action container styling
|
||||
export const getBannerActionStyling = (): string => `flex items-center flex-shrink-0 ${bannerSizeStyling.action}`;
|
||||
|
||||
// Get dismiss button styling
|
||||
export const getBannerDismissStyling = (): string =>
|
||||
"rounded-sm p-1 hover:bg-surface-2 transition-colors flex-shrink-0";
|
||||
|
||||
// Get dismiss icon styling
|
||||
export const getBannerDismissIconStyling = (): string => "text-secondary";
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { Banner } from "./banner";
|
||||
export type { BannerProps, BannerVariant } from "./banner";
|
||||
export type { TBannerVariant } from "./helper";
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Button",
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: "Button",
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
children: "Primary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorFill: Story = {
|
||||
args: {
|
||||
variant: "error-fill",
|
||||
children: "Error Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorOutline: Story = {
|
||||
args: {
|
||||
variant: "error-outline",
|
||||
children: "Error Outline Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: "secondary",
|
||||
children: "Secondary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Tertiary: Story = {
|
||||
args: {
|
||||
variant: "tertiary",
|
||||
children: "Tertiary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
children: "Ghost Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
variant: "link",
|
||||
children: "Link Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
children: "Small Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Base: Story = {
|
||||
args: {
|
||||
size: "base",
|
||||
children: "Base Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: "lg",
|
||||
children: "Large Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
size: "xl",
|
||||
children: "Extra Large Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
children: "Loading Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: "Disabled Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPrependIcon: Story = {
|
||||
args: {
|
||||
prependIcon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
),
|
||||
children: "With Prepend Icon",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAppendIcon: Story = {
|
||||
args: {
|
||||
appendIcon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
),
|
||||
children: "With Append Icon",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-16 font-semibold">Primary Variants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="error-fill">Error Fill</Button>
|
||||
<Button variant="error-outline">Error Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="tertiary">Tertiary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="base">Base</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="xl">Extra Large</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-16 font-semibold">Button States</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button>Default</Button>
|
||||
<Button loading>Loading</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
import type { ButtonProps } from "./helper";
|
||||
import { getIconStyling, buttonVariants } from "./helper";
|
||||
|
||||
const Button = React.forwardRef(function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
variant = "primary",
|
||||
size = "base",
|
||||
className = "",
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
prependIcon = null,
|
||||
appendIcon = null,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonIconStyle = getIconStyling(size ?? "base");
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{prependIcon && React.cloneElement(prependIcon, { className: cn("shrink-0", buttonIconStyle), strokeWidth: 2 })}
|
||||
{children}
|
||||
{appendIcon && React.cloneElement(appendIcon, { className: cn("shrink-0", buttonIconStyle), strokeWidth: 2 })}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "plane-ui-button";
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-1 whitespace-nowrap transition-colors focus-visible:outline-none disabled:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"bg-accent-primary text-on-color hover:bg-accent-primary-hover active:bg-accent-primary-active disabled:bg-layer-disabled disabled:text-on-color-disabled",
|
||||
"error-fill":
|
||||
"bg-danger-primary text-on-color hover:bg-danger-primary-hover active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled",
|
||||
"error-outline":
|
||||
"border border-danger-strong bg-layer-2 text-danger-secondary hover:bg-danger-subtle active:bg-danger-subtle-hover disabled:border-subtle-1 disabled:bg-layer-2 disabled:text-disabled",
|
||||
secondary:
|
||||
"border border-strong bg-layer-2 text-secondary shadow-raised-100 hover:bg-layer-2-hover active:bg-layer-2-active disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled",
|
||||
tertiary:
|
||||
"bg-layer-3 text-secondary hover:bg-layer-3-hover active:bg-layer-3-active disabled:bg-layer-transparent disabled:text-disabled",
|
||||
ghost:
|
||||
"bg-layer-transparent text-secondary hover:bg-layer-transparent-hover focus:bg-layer-transparent-active active:bg-layer-transparent-active disabled:bg-layer-transparent disabled:text-disabled",
|
||||
link: "px-0 text-link-primary underline hover:text-link-primary-hover focus:text-link-primary-hover active:text-link-primary-hover disabled:text-disabled",
|
||||
},
|
||||
size: {
|
||||
sm: "h-5 rounded-sm px-1.5 text-caption-md-medium",
|
||||
base: "h-6 rounded-md px-2 text-body-xs-medium",
|
||||
lg: "h-7 rounded-md px-2 text-body-xs-medium",
|
||||
xl: "h-8 rounded-md px-2 text-body-sm-medium",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "base",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
appendIcon?: React.ReactElement;
|
||||
loading?: boolean;
|
||||
prependIcon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type TButtonVariant = NonNullable<ButtonProps["variant"]>;
|
||||
export type TButtonSize = NonNullable<ButtonProps["size"]>;
|
||||
|
||||
const buttonIconStyling: Record<TButtonSize, string> = {
|
||||
sm: "size-3.5",
|
||||
base: "size-3.5",
|
||||
lg: "size-4",
|
||||
xl: "size-4 ",
|
||||
};
|
||||
|
||||
export function getIconStyling(size: TButtonSize): string {
|
||||
return buttonIconStyling[size];
|
||||
}
|
||||
|
||||
export function getButtonStyling(variant: TButtonVariant, size: TButtonSize): string {
|
||||
return buttonVariants({ variant, size });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { Button } from "./button";
|
||||
export { getButtonStyling } from "./helper";
|
||||
export type { ButtonProps, TButtonVariant, TButtonSize } from "./helper";
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { Calendar } from "./root";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Calendar",
|
||||
component: Calendar,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
args: {
|
||||
showOutsideDays: true,
|
||||
},
|
||||
} satisfies Meta<typeof Calendar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SingleDate: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar {...args} mode="single" selected={date} onSelect={setDate} className="rounded-md border" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleDates: Story = {
|
||||
render(args) {
|
||||
const [dates, setDates] = useState<Date[] | undefined>([
|
||||
new Date(2024, 0, 15),
|
||||
new Date(2024, 0, 20),
|
||||
new Date(2024, 0, 25),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar {...args} mode="multiple" selected={dates} onSelect={setDates} className="rounded-md border" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const RangeSelection: Story = {
|
||||
render(args) {
|
||||
const [range, setRange] = useState<DateRange | undefined>({
|
||||
from: new Date(2024, 0, 10),
|
||||
to: new Date(2024, 0, 20),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar {...args} mode="range" selected={range} onSelect={setRange} className="rounded-md border" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledDates: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const disabledDays = [new Date(2024, 0, 5), new Date(2024, 0, 12), new Date(2024, 0, 19), new Date(2024, 0, 26)];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
disabled={disabledDays}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledWeekends: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
disabled={(date) => date.getDay() === 0 || date.getDay() === 6}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MinMaxDates: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const today = new Date();
|
||||
const tenDaysAgo = new Date(today);
|
||||
tenDaysAgo.setDate(today.getDate() - 10);
|
||||
const tenDaysFromNow = new Date(today);
|
||||
tenDaysFromNow.setDate(today.getDate() + 10);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
disabled={(date) => date < tenDaysAgo || date > tenDaysFromNow}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WeekStartsOnMonday: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
weekStartsOn={1}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutOutsideDays: Story = {
|
||||
render(args) {
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
showOutsideDays={false}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TwoMonths: Story = {
|
||||
render(args) {
|
||||
const [range, setRange] = useState<DateRange | undefined>({
|
||||
from: new Date(2024, 0, 10),
|
||||
to: new Date(2024, 1, 15),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar
|
||||
{...args}
|
||||
mode="range"
|
||||
selected={range}
|
||||
onSelect={setRange}
|
||||
numberOfMonths={2}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Uncontrolled: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar mode="single" defaultMonth={new Date(2024, 0)} showOutsideDays className="rounded-md border" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
export type { Matcher, DateRange } from "react-day-picker";
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { enUS, ru } from "date-fns/locale";
|
||||
import { ChevronLeftIcon } from "../icons/arrows/chevron-left";
|
||||
|
||||
import { cn } from "../utils";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
const getCalendarLocale = () => {
|
||||
const localeCode =
|
||||
typeof document !== "undefined" && document.documentElement.lang
|
||||
? document.documentElement.lang
|
||||
: typeof navigator !== "undefined"
|
||||
? navigator.language
|
||||
: "en-US";
|
||||
|
||||
return localeCode.toLowerCase().startsWith("ru") ? ru : enUS;
|
||||
};
|
||||
|
||||
export function Calendar({ className, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const thirtyYearsAgoFirstDay = new Date(currentYear - 30, 0, 1);
|
||||
const thirtyYearsFromNowFirstDay = new Date(currentYear + 30, 11, 31);
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
locale={getCalendarLocale()}
|
||||
weekStartsOn={props.weekStartsOn}
|
||||
components={{
|
||||
Chevron: ({ className, ...props }) => (
|
||||
<ChevronLeftIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
{ "rotate-180": props.orientation === "right", "-rotate-90": props.orientation === "down" },
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
startMonth={thirtyYearsAgoFirstDay}
|
||||
endMonth={thirtyYearsFromNowFirstDay}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Card, ECardVariant, ECardSpacing, ECardDirection } from "./card";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Card",
|
||||
component: Card,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Card Title</h3>
|
||||
<p className="text-gray-600 text-13">This is a default card with shadow and large spacing.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithShadow: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITH_SHADOW,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Card with Shadow</h3>
|
||||
<p className="text-gray-600 text-13">Hover over this card to see the shadow effect.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutShadow: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITHOUT_SHADOW,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Card without Shadow</h3>
|
||||
<p className="text-gray-600 text-13">This card has no shadow effect on hover.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallSpacing: Story = {
|
||||
args: {
|
||||
spacing: ECardSpacing.SM,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Small Spacing</h3>
|
||||
<p className="text-gray-600 text-13">This card uses small spacing (p-4).</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSpacing: Story = {
|
||||
args: {
|
||||
spacing: ECardSpacing.LG,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Large Spacing</h3>
|
||||
<p className="text-gray-600 text-13">This card uses large spacing (p-6).</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ColumnDirection: Story = {
|
||||
args: {
|
||||
direction: ECardDirection.COLUMN,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-16 font-semibold">Column Direction</h3>
|
||||
<p className="text-gray-600 text-13">Content is arranged vertically.</p>
|
||||
<button className="bg-blue-500 rounded-sm px-4 py-2 text-on-color">Action</button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const RowDirection: Story = {
|
||||
args: {
|
||||
direction: ECardDirection.ROW,
|
||||
children: (
|
||||
<>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-blue-500 h-12 w-12 rounded-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-16 font-semibold">Row Direction</h3>
|
||||
<p className="text-gray-600 text-13">Content is arranged horizontally.</p>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProductCard: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITH_SHADOW,
|
||||
spacing: ECardSpacing.LG,
|
||||
direction: ECardDirection.COLUMN,
|
||||
children: (
|
||||
<>
|
||||
<div className="bg-gray-200 h-48 w-full rounded-sm" />
|
||||
<h3 className="text-18 font-bold">Product Name</h3>
|
||||
<p className="text-gray-600 text-13">A brief description of the product goes here.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-16 font-semibold">$99.99</span>
|
||||
<button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">Add to Cart</button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const UserCard: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITH_SHADOW,
|
||||
spacing: ECardSpacing.LG,
|
||||
direction: ECardDirection.ROW,
|
||||
children: (
|
||||
<>
|
||||
<div className="bg-blue-500 h-16 w-16 flex-shrink-0 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-16 font-semibold">John Doe</h3>
|
||||
<p className="text-gray-600 text-13">Software Engineer</p>
|
||||
<p className="text-gray-500 text-11">john.doe@example.com</p>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const NotificationCard: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITHOUT_SHADOW,
|
||||
spacing: ECardSpacing.SM,
|
||||
direction: ECardDirection.COLUMN,
|
||||
children: (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<h4 className="font-semibold">New Message</h4>
|
||||
<span className="text-gray-500 text-11">2m ago</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-13">You have received a new message from Alice.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card variant={ECardVariant.WITH_SHADOW}>
|
||||
<h3 className="font-semibold">With Shadow</h3>
|
||||
<p className="text-gray-600 text-13">Hover to see the shadow effect</p>
|
||||
</Card>
|
||||
<Card variant={ECardVariant.WITHOUT_SHADOW}>
|
||||
<h3 className="font-semibold">Without Shadow</h3>
|
||||
<p className="text-gray-600 text-13">No shadow on hover</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSpacings: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card spacing={ECardSpacing.SM}>
|
||||
<h3 className="font-semibold">Small Spacing (p-4)</h3>
|
||||
<p className="text-gray-600 text-13">Compact padding</p>
|
||||
</Card>
|
||||
<Card spacing={ECardSpacing.LG}>
|
||||
<h3 className="font-semibold">Large Spacing (p-6)</h3>
|
||||
<p className="text-gray-600 text-13">More generous padding</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllDirections: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card direction={ECardDirection.COLUMN}>
|
||||
<h3 className="font-semibold">Column Direction</h3>
|
||||
<p className="text-gray-600 text-13">Vertical layout</p>
|
||||
<button className="bg-blue-500 w-fit rounded-sm px-4 py-2 text-on-color">Button</button>
|
||||
</Card>
|
||||
<Card direction={ECardDirection.ROW}>
|
||||
<div className="bg-blue-500 h-12 w-12 flex-shrink-0 rounded-sm" />
|
||||
<div>
|
||||
<h3 className="font-semibold">Row Direction</h3>
|
||||
<p className="text-gray-600 text-13">Horizontal layout</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils/classname";
|
||||
import type { TCardDirection, TCardSpacing, TCardVariant } from "./helper";
|
||||
import { ECardDirection, ECardSpacing, ECardVariant, getCardStyle } from "./helper";
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: TCardVariant;
|
||||
spacing?: TCardSpacing;
|
||||
direction?: TCardDirection;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = React.forwardRef(function Card(props: CardProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const {
|
||||
variant = ECardVariant.WITH_SHADOW,
|
||||
direction = ECardDirection.COLUMN,
|
||||
className = "",
|
||||
spacing = ECardSpacing.LG,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const style = getCardStyle(variant, spacing, direction);
|
||||
return (
|
||||
<div ref={ref} className={cn(style, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Card.displayName = "plane-ui-card";
|
||||
|
||||
export { Card, ECardVariant, ECardSpacing, ECardDirection };
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum ECardVariant {
|
||||
WITHOUT_SHADOW = "without-shadow",
|
||||
WITH_SHADOW = "with-shadow",
|
||||
}
|
||||
export enum ECardDirection {
|
||||
ROW = "row",
|
||||
COLUMN = "column",
|
||||
}
|
||||
export enum ECardSpacing {
|
||||
SM = "sm",
|
||||
LG = "lg",
|
||||
}
|
||||
export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW;
|
||||
export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN;
|
||||
export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG;
|
||||
|
||||
export interface ICardProperties {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = "bg-surface-1 rounded-lg border-[0.5px] border-subtle w-full flex flex-col";
|
||||
export const containerStyle: ICardProperties = {
|
||||
[ECardVariant.WITHOUT_SHADOW]: "",
|
||||
[ECardVariant.WITH_SHADOW]: "hover:shadow-raised-200 duration-300",
|
||||
};
|
||||
export const spacings = {
|
||||
[ECardSpacing.SM]: "p-4",
|
||||
[ECardSpacing.LG]: "p-6",
|
||||
};
|
||||
export const directions = {
|
||||
[ECardDirection.ROW]: "flex-row space-x-3",
|
||||
[ECardDirection.COLUMN]: "flex-col space-y-3",
|
||||
};
|
||||
export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) =>
|
||||
DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./card";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||
import type { TAreaChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
export const AreaChart = React.memo(function AreaChart<K extends string, T extends string>(
|
||||
props: TAreaChartProps<K, T>
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
areas,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className,
|
||||
legend,
|
||||
margin,
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
customTicks,
|
||||
showTooltip = true,
|
||||
comparisonLine,
|
||||
} = props;
|
||||
// states
|
||||
const [activeArea, setActiveArea] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const area of areas) {
|
||||
keys.push(area.key);
|
||||
labels[area.key] = area.label;
|
||||
colors[area.key] = area.fill;
|
||||
}
|
||||
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [areas]);
|
||||
|
||||
const renderAreas = useMemo(
|
||||
() =>
|
||||
areas.map((area) => (
|
||||
<Area
|
||||
key={area.key}
|
||||
type={area.smoothCurves ? "monotone" : "linear"}
|
||||
dataKey={area.key}
|
||||
stackId={area.stackId}
|
||||
fill={area.fill}
|
||||
opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1}
|
||||
fillOpacity={area.fillOpacity}
|
||||
strokeOpacity={area.strokeOpacity}
|
||||
stroke={area.strokeColor}
|
||||
strokeWidth={2}
|
||||
style={area.style}
|
||||
dot={
|
||||
area.showDot
|
||||
? {
|
||||
fill: area.fill,
|
||||
fillOpacity: 1,
|
||||
}
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
stroke: area.fill,
|
||||
}}
|
||||
onMouseEnter={() => setActiveArea(area.key)}
|
||||
onMouseLeave={() => setActiveArea(null)}
|
||||
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||
/>
|
||||
)),
|
||||
[activeLegend, areas]
|
||||
);
|
||||
|
||||
// create comparison line data for straight line from origin to last point
|
||||
const comparisonLineData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
// get the last data point
|
||||
const lastPoint = data[data.length - 1];
|
||||
// for the y-value in the last point, use its yAxis key value
|
||||
const lastYValue = lastPoint[yAxis.key] ?? 0;
|
||||
// create data for a straight line that has points at each x-axis position
|
||||
return data.map((item, index) => {
|
||||
// calculate the y value for this point on the straight line
|
||||
// using linear interpolation between (0,0) and (last_x, last_y)
|
||||
const ratio = index / (data.length - 1);
|
||||
const interpolatedValue = ratio * lastYValue;
|
||||
|
||||
return {
|
||||
[xAxis.key]: item[xAxis.key],
|
||||
comparisonLine: interpolatedValue,
|
||||
};
|
||||
});
|
||||
}, [data, xAxis.key, yAxis.key]);
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid stroke="var(--border-color-subtle)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.x || CustomXAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
xAxis.label && {
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
yAxis.label && {
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: yAxis.offset ?? -24,
|
||||
dx: yAxis.dx ?? -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.y || CustomYAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
formatter={(value) => itemLabels[value]}
|
||||
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--text-color-tertiary)",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activeArea}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderAreas}
|
||||
{comparisonLine && (
|
||||
<Line
|
||||
data={comparisonLineData}
|
||||
type="linear"
|
||||
dataKey="comparisonLine"
|
||||
stroke={comparisonLine.strokeColor}
|
||||
fill={comparisonLine.strokeColor}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={comparisonLine.dashedLine ? "4 4" : "none"}
|
||||
activeDot={false}
|
||||
legendType="none"
|
||||
name="Comparison line"
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AreaChart.displayName = "AreaChart";
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import type { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
|
||||
import { cn } from "../../utils/classname";
|
||||
|
||||
// Constants
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
|
||||
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
|
||||
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
|
||||
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
|
||||
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
|
||||
const DEFAULT_BAR_FILL_COLOR = "#000000"; // Default color when fill is a function - black
|
||||
|
||||
// Types
|
||||
interface TShapeProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataKey: string;
|
||||
payload: any;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface TBarProps extends TShapeProps {
|
||||
fill: string;
|
||||
stackKeys: string[];
|
||||
textClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
showTopBorderRadius?: boolean;
|
||||
showBottomBorderRadius?: boolean;
|
||||
dotted?: boolean;
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
currentKey: T
|
||||
): number => {
|
||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
const getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
|
||||
M${x},${y + topRadius}
|
||||
Q${x},${y} ${x + topRadius},${y}
|
||||
L${x + width - topRadius},${y}
|
||||
Q${x + width},${y} ${x + width},${y + topRadius}
|
||||
L${x + width},${y + height - bottomRadius}
|
||||
Q${x + width},${y + height} ${x + width - bottomRadius},${y + height}
|
||||
L${x + bottomRadius},${y + height}
|
||||
Q${x},${y + height} ${x},${y + height - bottomRadius}
|
||||
Z
|
||||
`;
|
||||
|
||||
function PercentageText({
|
||||
x,
|
||||
y,
|
||||
percentage,
|
||||
className,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
|
||||
{percentage}%
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
// Base Components
|
||||
const CustomBar = React.memo(function CustomBar(props: TBarProps) {
|
||||
const {
|
||||
opacity,
|
||||
fill,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
dataKey,
|
||||
stackKeys,
|
||||
payload,
|
||||
textClassName,
|
||||
showPercentage,
|
||||
showTopBorderRadius,
|
||||
showBottomBorderRadius,
|
||||
} = props;
|
||||
|
||||
if (!height) return null;
|
||||
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||
const textY = y + height - TEXT_PADDING_Y;
|
||||
|
||||
const showText =
|
||||
showPercentage &&
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||
currentBarPercentage !== undefined &&
|
||||
!Number.isNaN(currentBarPercentage);
|
||||
|
||||
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
|
||||
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
|
||||
fill={fill}
|
||||
opacity={opacity}
|
||||
style={{
|
||||
transition: "opacity 200ms",
|
||||
fill: fill,
|
||||
}}
|
||||
/>
|
||||
{showText && (
|
||||
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
const CustomBarLollipop = React.memo(function CustomBarLollipop(props: TBarProps) {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props;
|
||||
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x + width / 2}
|
||||
y1={y + height}
|
||||
x2={x + width / 2}
|
||||
y2={y}
|
||||
stroke={fill}
|
||||
strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={dotted ? "4 4" : "0"}
|
||||
/>
|
||||
<circle cx={x + width / 2} cy={y} r={DEFAULT_LOLLIPOP_CIRCLE_RADIUS} fill={fill} stroke="none" />
|
||||
{showPercentage && (
|
||||
<PercentageText x={x + width / 2} y={y} percentage={currentBarPercentage} className={textClassName} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
// Shape Variants
|
||||
/**
|
||||
* Factory function to create shape variants with consistent props
|
||||
* @param Component - The base component to render
|
||||
* @param factoryProps - Additional props to pass to the component
|
||||
* @returns A function that creates the shape with proper props
|
||||
*/
|
||||
const createShapeVariant =
|
||||
(Component: React.ComponentType<TBarProps>, factoryProps?: Partial<TBarProps>) =>
|
||||
(shapeProps: TShapeProps, bar: TBarItem<string>, stackKeys: string[]): React.ReactNode => {
|
||||
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...shapeProps}
|
||||
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
showTopBorderRadius={!!showTopBorderRadius}
|
||||
showBottomBorderRadius={!!showBottomBorderRadius}
|
||||
{...factoryProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { DEFAULT_BAR_FILL_COLOR };
|
||||
|
||||
export const barShapeVariants: Record<
|
||||
TBarChartShapeVariant,
|
||||
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => React.ReactNode
|
||||
> = {
|
||||
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
|
||||
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
|
||||
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
|
||||
};
|
||||
|
||||
// Display names
|
||||
CustomBar.displayName = "CustomBar";
|
||||
CustomBarLollipop.displayName = "CustomBarLollipop";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
BarChart as CoreBarChart,
|
||||
Bar,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||
import type { TBarChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
import { barShapeVariants, DEFAULT_BAR_FILL_COLOR } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(function BarChart<K extends string, T extends string>(props: TBarChartProps<K, T>) {
|
||||
const {
|
||||
data,
|
||||
bars,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className,
|
||||
legend,
|
||||
margin,
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
customTicks,
|
||||
showTooltip = true,
|
||||
customTooltipContent,
|
||||
} = props;
|
||||
// states
|
||||
const [activeBar, setActiveBar] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const { stackKeys, stackLabels } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const bar of bars) {
|
||||
keys.push(bar.key);
|
||||
labels[bar.key] = bar.label;
|
||||
}
|
||||
|
||||
return { stackKeys: keys, stackLabels: labels };
|
||||
}, [bars]);
|
||||
|
||||
// get bar color dynamically based on payload
|
||||
const getBarColor = useCallback(
|
||||
(payload: Record<string, string>[], barKey: string) => {
|
||||
const bar = bars.find((b) => b.key === barKey);
|
||||
if (!bar) return DEFAULT_BAR_FILL_COLOR;
|
||||
|
||||
if (typeof bar.fill === "function") {
|
||||
const payloadItem = payload?.find((item) => item.dataKey === barKey);
|
||||
if (payloadItem?.payload) {
|
||||
try {
|
||||
return bar.fill(payloadItem.payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return DEFAULT_BAR_FILL_COLOR;
|
||||
}
|
||||
} else {
|
||||
return DEFAULT_BAR_FILL_COLOR; // fallback color when no payload data
|
||||
}
|
||||
} else {
|
||||
return bar.fill;
|
||||
}
|
||||
},
|
||||
[bars]
|
||||
);
|
||||
|
||||
// get all bar colors
|
||||
const getAllBarColors = useCallback(
|
||||
(payload: any[]) => {
|
||||
const colors: Record<string, string> = {};
|
||||
for (const bar of bars) {
|
||||
colors[bar.key] = getBarColor(payload, bar.key);
|
||||
}
|
||||
return colors;
|
||||
},
|
||||
[bars, getBarColor]
|
||||
);
|
||||
|
||||
const renderBars = useMemo(
|
||||
() =>
|
||||
bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.key}
|
||||
dataKey={bar.key}
|
||||
stackId={bar.stackId}
|
||||
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
|
||||
shape={(shapeProps: any) => {
|
||||
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
|
||||
const node = shapeVariant(shapeProps, bar, stackKeys);
|
||||
return React.isValidElement(node) ? node : <>{node}</>;
|
||||
}}
|
||||
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||
onMouseEnter={() => setActiveBar(bar.key)}
|
||||
onMouseLeave={() => setActiveBar(null)}
|
||||
fill={getBarColor(data, bar.key)}
|
||||
/>
|
||||
)),
|
||||
[activeLegend, stackKeys, bars, getBarColor, data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreBarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<CartesianGrid stroke="var(--border-color-subtle)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.x || CustomXAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: xAxis.dy ?? 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: yAxis.offset ?? -24,
|
||||
dx: yAxis.dx ?? -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.y || CustomYAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
formatter={(value) => stackLabels[value]}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
fill: "var(--alpha-black-300)",
|
||||
className: "bg-layer-1 cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => {
|
||||
if (customTooltipContent) return customTooltipContent({ active, label, payload });
|
||||
return (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
activeKey={activeBar}
|
||||
itemKeys={stackKeys}
|
||||
itemLabels={stackLabels}
|
||||
itemDotColors={getAllBarColors(payload || [])}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</CoreBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BarChart.displayName = "BarChart";
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { LegendProps } from "recharts";
|
||||
// plane imports
|
||||
import type { TChartLegend } from "@plane/types";
|
||||
import { cn } from "../../utils/classname";
|
||||
|
||||
export const getLegendProps = (args: TChartLegend): LegendProps => {
|
||||
const { align, layout, verticalAlign } = args;
|
||||
return {
|
||||
layout,
|
||||
align,
|
||||
verticalAlign,
|
||||
wrapperStyle: {
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
...(layout === "vertical"
|
||||
? {
|
||||
top: 0,
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}
|
||||
: {
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}),
|
||||
...args.wrapperStyles,
|
||||
},
|
||||
content: <CustomLegend {...args} />,
|
||||
};
|
||||
};
|
||||
|
||||
const CustomLegend = React.forwardRef(function CustomLegend(
|
||||
props: React.ComponentProps<"div"> &
|
||||
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
|
||||
TChartLegend,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
|
||||
|
||||
if (!payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("vertical-scrollbar flex scrollbar-sm items-center overflow-scroll px-4", {
|
||||
"max-h-full flex-col items-start py-4": layout === "vertical",
|
||||
})}
|
||||
>
|
||||
{payload.map((item, index) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn("flex items-center gap-1.5 text-13 font-medium whitespace-nowrap text-tertiary", {
|
||||
"px-2": layout === "horizontal",
|
||||
"py-2": layout === "vertical",
|
||||
"pt-0 pl-0": index === 0,
|
||||
"pr-0 pb-0": index === payload.length - 1,
|
||||
"cursor-pointer": !!props.onClick,
|
||||
})}
|
||||
onClick={(e) => onClick?.(item, index, e)}
|
||||
onMouseEnter={(e) => onMouseEnter?.(item, index, e)}
|
||||
onMouseLeave={(e) => onMouseLeave?.(item, index, e)}
|
||||
>
|
||||
<div
|
||||
className="size-2 flex-shrink-0 rounded-xs"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
{/* @ts-expect-error recharts types are not up to date */}
|
||||
{formatter?.(item.value, { value: item.value }, index) ?? item.payload?.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CustomLegend.displayName = "CustomLegend";
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
|
||||
// Common classnames
|
||||
const AXIS_TICK_CLASSNAME = "fill-tertiary text-13";
|
||||
|
||||
export const CustomXAxisTick = React.memo(function CustomXAxisTick({ x, y, payload, getLabel }: any) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{getLabel ? getLabel(payload.value) : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomXAxisTick.displayName = "CustomXAxisTick";
|
||||
|
||||
export const CustomYAxisTick = React.memo(function CustomYAxisTick({ x, y, payload }: any) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text dx={-10} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
CustomYAxisTick.displayName = "CustomYAxisTick";
|
||||
|
||||
export const CustomRadarAxisTick = React.memo(function CustomRadarAxisTick({
|
||||
x,
|
||||
y,
|
||||
payload,
|
||||
getLabel,
|
||||
cx,
|
||||
cy,
|
||||
offset = 16,
|
||||
}: any) {
|
||||
// Calculate direction vector from center to tick
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
// Normalize and apply offset
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const normX = dx / length;
|
||||
const normY = dy / length;
|
||||
const labelX = x + normX * offset;
|
||||
const labelY = y + normY * offset;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${labelX},${labelY})`}>
|
||||
<text y={0} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{getLabel ? getLabel(payload.value) : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomRadarAxisTick.displayName = "CustomRadarAxisTick";
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
import { Card, ECardSpacing } from "../../card";
|
||||
import { cn } from "../../utils/classname";
|
||||
|
||||
type Props = {
|
||||
active: boolean | undefined;
|
||||
activeKey?: string | null;
|
||||
label: string | undefined;
|
||||
payload: Payload<ValueType, NameType>[] | undefined;
|
||||
itemKeys: string[];
|
||||
itemLabels: Record<string, string>;
|
||||
itemDotColors: Record<string, string>;
|
||||
};
|
||||
|
||||
export const CustomTooltip = React.memo(function CustomTooltip(props: Props) {
|
||||
const { active, activeKey, label, payload, itemKeys, itemLabels, itemDotColors } = props;
|
||||
// derived values
|
||||
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
||||
|
||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="vertical-scrollbar flex scrollbar-sm max-h-[40vh] w-[12rem] flex-col overflow-y-scroll"
|
||||
spacing={ECardSpacing.SM}
|
||||
>
|
||||
<p className="flex-shrink-0 truncate border-b border-subtle pb-2 text-11 font-medium text-primary">{label}</p>
|
||||
{filteredPayload.map((item) => {
|
||||
if (!item.dataKey) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.dataKey}
|
||||
className={cn("flex items-center gap-2 text-11 transition-opacity", {
|
||||
"opacity-20": activeKey && item.dataKey !== activeKey,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{itemDotColors[item?.dataKey] && (
|
||||
<div
|
||||
className="size-2 flex-shrink-0 rounded-xs"
|
||||
style={{
|
||||
backgroundColor: itemDotColors[item?.dataKey],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-tertiary">{itemLabels[item?.dataKey]}:</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-medium text-secondary">{item?.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
LineChart as CoreLineChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||
import type { TLineChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
export const LineChart = React.memo(function LineChart<K extends string, T extends string>(
|
||||
props: TLineChartProps<K, T>
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
lines,
|
||||
margin,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className,
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
customTicks,
|
||||
legend,
|
||||
showTooltip = true,
|
||||
customTooltipContent,
|
||||
} = props;
|
||||
// states
|
||||
const [activeLine, setActiveLine] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
keys.push(line.key);
|
||||
labels[line.key] = line.label;
|
||||
colors[line.key] = line.stroke;
|
||||
}
|
||||
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [lines]);
|
||||
|
||||
const renderLines = useMemo(
|
||||
() =>
|
||||
lines.map((line) => (
|
||||
<Line
|
||||
key={line.key}
|
||||
dataKey={line.key}
|
||||
type={line.smoothCurves ? "monotone" : "linear"}
|
||||
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||
opacity={!!activeLegend && activeLegend !== line.key ? 0.1 : 1}
|
||||
fill={line.fill}
|
||||
stroke={line.stroke}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={line.dashedLine ? "4 4" : "none"}
|
||||
dot={
|
||||
line.showDot
|
||||
? {
|
||||
fill: line.fill,
|
||||
fillOpacity: 1,
|
||||
}
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
stroke: line.fill,
|
||||
}}
|
||||
onMouseEnter={() => setActiveLine(line.key)}
|
||||
onMouseLeave={() => setActiveLine(null)}
|
||||
/>
|
||||
)),
|
||||
[activeLegend, lines]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreLineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid stroke="var(--border-color-subtle)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.x || CustomXAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
xAxis.label && {
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
yAxis.label && {
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: yAxis.dx ?? -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.y || CustomYAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
formatter={(value) => itemLabels[value]}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--text-color-tertiary)",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => {
|
||||
if (customTooltipContent) return customTooltipContent({ active, label, payload });
|
||||
return (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activeLine}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderLines}
|
||||
</CoreLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
LineChart.displayName = "LineChart";
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Sector } from "recharts";
|
||||
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
|
||||
|
||||
export const CustomActiveShape = React.memo(function CustomActiveShape(props: PieSectorDataItem) {
|
||||
const { cx, cy, cornerRadius, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
cornerRadius={cornerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
cornerRadius={cornerRadius}
|
||||
innerRadius={(outerRadius ?? 0) + 6}
|
||||
outerRadius={(outerRadius ?? 0) + 10}
|
||||
fill={fill}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import type { TPieChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomActiveShape } from "./active-shape";
|
||||
import { CustomPieChartTooltip } from "./tooltip";
|
||||
|
||||
export const PieChart = React.memo(function PieChart<K extends string, T extends string>(props: TPieChartProps<K, T>) {
|
||||
const {
|
||||
data,
|
||||
dataKey,
|
||||
cells,
|
||||
className,
|
||||
innerRadius,
|
||||
legend,
|
||||
margin,
|
||||
outerRadius,
|
||||
showTooltip = true,
|
||||
showLabel,
|
||||
customLabel,
|
||||
centerLabel,
|
||||
cornerRadius,
|
||||
paddingAngle,
|
||||
tooltipLabel,
|
||||
} = props;
|
||||
// states
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
const renderCells = useMemo(
|
||||
() =>
|
||||
cells.map((cell, index) => (
|
||||
<Cell
|
||||
key={cell.key}
|
||||
className="transition-opacity duration-200"
|
||||
fill={cell.fill}
|
||||
opacity={!!activeLegend && activeLegend !== cell.key ? 0.1 : 1}
|
||||
style={{
|
||||
outline: "none",
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(null)}
|
||||
/>
|
||||
)),
|
||||
[activeLegend, cells]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CorePieChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
>
|
||||
<Pie
|
||||
activeIndex={activeIndex === null ? undefined : activeIndex}
|
||||
onMouseLeave={() => setActiveIndex(null)}
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
blendStroke
|
||||
activeShape={<CustomActiveShape />}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
cornerRadius={cornerRadius}
|
||||
paddingAngle={paddingAngle}
|
||||
labelLine={false}
|
||||
label={
|
||||
showLabel
|
||||
? ({ payload, ...props }) => (
|
||||
<text
|
||||
className="text-sm font-medium transition-opacity duration-200"
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
textAnchor={props.textAnchor}
|
||||
dominantBaseline={props.dominantBaseline}
|
||||
fill="var(--text-color-secondary)"
|
||||
opacity={!!activeLegend && activeLegend !== payload.key ? 0.1 : 1}
|
||||
>
|
||||
{customLabel?.(payload.count) ?? payload.count}
|
||||
</text>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderCells}
|
||||
{centerLabel && (
|
||||
<Label
|
||||
value={centerLabel.text}
|
||||
fill={centerLabel.fill}
|
||||
position="center"
|
||||
opacity={activeLegend ? 0.1 : 1}
|
||||
style={centerLabel.style}
|
||||
className={centerLabel.className}
|
||||
/>
|
||||
)}
|
||||
</Pie>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => {
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
const key: string | undefined = payload.payload?.key;
|
||||
if (!key) return;
|
||||
setActiveLegend(key);
|
||||
setActiveIndex(null);
|
||||
}}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "bg-layer-1-hover cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const cellData = cells.find((c) => c.key === payload[0].payload.key);
|
||||
if (!cellData) return null;
|
||||
const label = tooltipLabel
|
||||
? typeof tooltipLabel === "function"
|
||||
? tooltipLabel(payload[0]?.payload?.payload)
|
||||
: tooltipLabel
|
||||
: dataKey;
|
||||
return <CustomPieChartTooltip dotColor={cellData.fill} label={label} payload={payload} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CorePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
PieChart.displayName = "PieChart";
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "../../card";
|
||||
|
||||
type Props = {
|
||||
dotColor?: string;
|
||||
label: string;
|
||||
payload: Payload<ValueType, NameType>[];
|
||||
};
|
||||
|
||||
export const CustomPieChartTooltip = React.memo(function CustomPieChartTooltip(props: Props) {
|
||||
const { dotColor, label, payload } = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="vertical-scrollbar flex scrollbar-sm max-h-[40vh] w-[12rem] flex-col overflow-y-scroll"
|
||||
spacing={ECardSpacing.SM}
|
||||
>
|
||||
<p className="flex-shrink-0 truncate border-b border-subtle pb-2 text-11 font-medium text-primary">{label}</p>
|
||||
{payload?.map((item) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-11 capitalize">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<div
|
||||
className="size-2 flex-shrink-0 rounded-xs"
|
||||
style={{
|
||||
backgroundColor: dotColor,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-tertiary">{item?.name}:</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-medium text-secondary">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as CoreRadarChart,
|
||||
ResponsiveContainer,
|
||||
PolarAngleAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import type { TRadarChartProps } from "@plane/types";
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomRadarAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
function RadarChart<T extends string, K extends string>(props: TRadarChartProps<T, K>) {
|
||||
const { data, radars, margin, showTooltip, legend, className, angleAxis } = props;
|
||||
|
||||
// states
|
||||
const [, setActiveIndex] = useState<number | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const radar of radars) {
|
||||
keys.push(radar.key);
|
||||
labels[radar.key] = radar.name;
|
||||
colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000";
|
||||
}
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [radars]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreRadarChart cx="50%" cy="50%" outerRadius="80%" data={data} margin={margin}>
|
||||
<PolarGrid stroke="var(--border-color-subtle)" />
|
||||
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--text-color-tertiary)",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activeLegend}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => {
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
const key: string | undefined = payload.payload?.key;
|
||||
if (!key) return;
|
||||
setActiveLegend(key);
|
||||
setActiveIndex(null);
|
||||
}}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{radars.map((radar) => (
|
||||
<Radar
|
||||
key={radar.key}
|
||||
name={radar.name}
|
||||
dataKey={radar.key}
|
||||
stroke={radar.stroke}
|
||||
fill={radar.fill}
|
||||
fillOpacity={radar.fillOpacity}
|
||||
dot={radar.dot}
|
||||
/>
|
||||
))}
|
||||
</CoreRadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadarChart };
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
ScatterChart as CoreScatterChart,
|
||||
Legend,
|
||||
Scatter,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||
import type { TScatterChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
export const ScatterChart = React.memo(function ScatterChart<K extends string, T extends string>(
|
||||
props: TScatterChartProps<K, T>
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
scatterPoints,
|
||||
margin,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className,
|
||||
customTicks,
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
legend,
|
||||
showTooltip = true,
|
||||
customTooltipContent,
|
||||
} = props;
|
||||
// states
|
||||
const [activePoint, setActivePoint] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
//derived values
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const point of scatterPoints) {
|
||||
keys.push(point.key);
|
||||
labels[point.key] = point.label;
|
||||
colors[point.key] = point.fill;
|
||||
}
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [scatterPoints]);
|
||||
|
||||
const renderPoints = useMemo(
|
||||
() =>
|
||||
scatterPoints.map((point) => (
|
||||
<Scatter
|
||||
key={point.key}
|
||||
dataKey={point.key}
|
||||
fill={point.fill}
|
||||
stroke={point.stroke}
|
||||
opacity={!!activeLegend && activeLegend !== point.key ? 0.1 : 1}
|
||||
onMouseEnter={() => setActivePoint(point.key)}
|
||||
onMouseLeave={() => setActivePoint(null)}
|
||||
/>
|
||||
)),
|
||||
[activeLegend, scatterPoints]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreScatterChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid stroke="var(--border-color-subtle)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.x || CustomXAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
xAxis.label && {
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
yAxis.label && {
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: yAxis.dx ?? -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tick={(props) => {
|
||||
const TickComponent = customTicks?.y || CustomYAxisTick;
|
||||
return <TickComponent {...props} />;
|
||||
}}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
formatter={(value) => itemLabels[value]}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--text-color-tertiary)",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) =>
|
||||
customTooltipContent ? (
|
||||
customTooltipContent({ active, label, payload })
|
||||
) : (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activePoint}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{renderPoints}
|
||||
</CoreScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ScatterChart.displayName = "ScatterChart";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import type { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
|
||||
import { cn } from "../../utils/classname";
|
||||
|
||||
const LAYOUT = {
|
||||
PADDING: 2,
|
||||
RADIUS: 6,
|
||||
TEXT: {
|
||||
PADDING_LEFT: 8,
|
||||
PADDING_RIGHT: 8,
|
||||
VERTICAL_OFFSET: 20,
|
||||
ELLIPSIS_OFFSET: -4,
|
||||
FONT_SIZES: {
|
||||
SM: 12.6,
|
||||
XS: 10.8,
|
||||
},
|
||||
},
|
||||
ICON: {
|
||||
SIZE: 16,
|
||||
GAP: 6,
|
||||
},
|
||||
MIN_DIMENSIONS: {
|
||||
HEIGHT_FOR_BOTH: 42,
|
||||
HEIGHT_FOR_TOP: 35,
|
||||
HEIGHT_FOR_DOTS: 20,
|
||||
WIDTH_FOR_ICON: 30,
|
||||
WIDTH_FOR_DOTS: 15,
|
||||
},
|
||||
};
|
||||
|
||||
const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7;
|
||||
|
||||
const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => {
|
||||
const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
|
||||
const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM);
|
||||
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
|
||||
|
||||
// First check if we can show icon
|
||||
const canShowIcon = hasIcon && effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_ICON;
|
||||
|
||||
// If we can't even show icon, check if we can show dots
|
||||
if (!canShowIcon) {
|
||||
return {
|
||||
showIcon: false,
|
||||
showName: effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS,
|
||||
nameTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// We can show icon, now check if we have space for name
|
||||
const availableWidthForName = effectiveWidth - (canShowIcon ? iconWidth : 0) - totalPadding;
|
||||
const canShowFullName = availableWidthForName >= nameWidth;
|
||||
|
||||
return {
|
||||
showIcon: canShowIcon,
|
||||
showName: availableWidthForName > 0,
|
||||
nameTruncated: !canShowFullName,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateBottomSectionConfig = (
|
||||
effectiveWidth: number,
|
||||
effectiveHeight: number,
|
||||
value: number | undefined,
|
||||
label: string | undefined
|
||||
): TBottomSectionConfig => {
|
||||
// If height is not enough for bottom section
|
||||
if (effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_BOTH) {
|
||||
return {
|
||||
show: false,
|
||||
showValue: false,
|
||||
showLabel: false,
|
||||
labelTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate widths
|
||||
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
|
||||
const valueWidth = value ? calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) : 0;
|
||||
const labelWidth = label ? calculateContentWidth(label, LAYOUT.TEXT.FONT_SIZES.XS) + 4 : 0; // 4px for spacing
|
||||
const availableWidth = effectiveWidth - totalPadding;
|
||||
|
||||
// If we can't even show value
|
||||
if (availableWidth < Math.max(valueWidth, LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS)) {
|
||||
return {
|
||||
show: true,
|
||||
showValue: false,
|
||||
showLabel: false,
|
||||
labelTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If we can show value but not full label
|
||||
const canShowFullLabel = availableWidth >= valueWidth + labelWidth;
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showValue: true,
|
||||
showLabel: true,
|
||||
labelTruncated: !canShowFullLabel,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateVisibility = (
|
||||
width: number,
|
||||
height: number,
|
||||
hasIcon: boolean,
|
||||
name: string,
|
||||
value: number | undefined,
|
||||
label: string | undefined
|
||||
): TContentVisibility => {
|
||||
const effectiveWidth = width - LAYOUT.PADDING * 2;
|
||||
const effectiveHeight = height - LAYOUT.PADDING * 2;
|
||||
|
||||
// If extremely small, show only dots
|
||||
if (
|
||||
effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_DOTS ||
|
||||
effectiveWidth < LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS
|
||||
) {
|
||||
return {
|
||||
top: { showIcon: false, showName: false, nameTruncated: false },
|
||||
bottom: { show: false, showValue: false, showLabel: false, labelTruncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon);
|
||||
const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label);
|
||||
|
||||
return {
|
||||
top: topSection,
|
||||
bottom: bottomSection,
|
||||
};
|
||||
};
|
||||
|
||||
const truncateText = (text: string | number, maxWidth: number, fontSize: number, reservedWidth: number = 0): string => {
|
||||
const availableWidth = maxWidth - reservedWidth;
|
||||
if (availableWidth <= 0) return "";
|
||||
|
||||
const avgCharWidth = fontSize * 0.7;
|
||||
const maxChars = Math.floor(availableWidth / avgCharWidth);
|
||||
const stringText = String(text);
|
||||
|
||||
if (maxChars <= 3) return "";
|
||||
if (stringText.length <= maxChars) return stringText;
|
||||
return `${stringText.slice(0, maxChars - 3)}...`;
|
||||
};
|
||||
|
||||
export function CustomTreeMapContent({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
fillColor,
|
||||
fillClassName,
|
||||
textClassName,
|
||||
icon,
|
||||
}: any) {
|
||||
const dimensions = useMemo(() => {
|
||||
const pX = x + LAYOUT.PADDING;
|
||||
const pY = y + LAYOUT.PADDING;
|
||||
const pWidth = Math.max(0, width - LAYOUT.PADDING * 2);
|
||||
const pHeight = Math.max(0, height - LAYOUT.PADDING * 2);
|
||||
return { pX, pY, pWidth, pHeight };
|
||||
}, [x, y, width, height]);
|
||||
|
||||
const visibility = useMemo(
|
||||
() => calculateVisibility(width, height, !!icon, name, value, label),
|
||||
[width, height, icon, name, value, label]
|
||||
);
|
||||
|
||||
if (!name || width <= 0 || height <= 0) return null;
|
||||
|
||||
const renderContent = () => {
|
||||
const { pX, pY, pWidth, pHeight } = dimensions;
|
||||
const { top, bottom } = visibility;
|
||||
|
||||
const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT;
|
||||
const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Background shape */}
|
||||
<path
|
||||
d={`
|
||||
M${pX + LAYOUT.RADIUS},${pY}
|
||||
L${pX + pWidth - LAYOUT.RADIUS},${pY}
|
||||
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + LAYOUT.RADIUS}
|
||||
L${pX + pWidth},${pY + pHeight - LAYOUT.RADIUS}
|
||||
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - LAYOUT.RADIUS},${pY + pHeight}
|
||||
L${pX + LAYOUT.RADIUS},${pY + pHeight}
|
||||
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - LAYOUT.RADIUS}
|
||||
L${pX},${pY + LAYOUT.RADIUS}
|
||||
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
|
||||
`}
|
||||
className={cn("transition-colors duration-200 hover:opacity-90", fillClassName)}
|
||||
fill={fillColor ?? "currentColor"}
|
||||
/>
|
||||
|
||||
{/* Top section */}
|
||||
<g>
|
||||
{top.showIcon && icon && (
|
||||
<foreignObject
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT}
|
||||
y={pY + LAYOUT.TEXT.PADDING_LEFT}
|
||||
width={LAYOUT.ICON.SIZE}
|
||||
height={LAYOUT.ICON.SIZE}
|
||||
className={textClassName || "text-tertiary"}
|
||||
>
|
||||
{React.cloneElement(icon, {
|
||||
className: cn("size-4", icon?.props?.className),
|
||||
"aria-hidden": true,
|
||||
})}
|
||||
</foreignObject>
|
||||
)}
|
||||
{top.showName && (
|
||||
<text
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT + iconSpace}
|
||||
y={pY + LAYOUT.TEXT.VERTICAL_OFFSET}
|
||||
textAnchor="start"
|
||||
className={cn("tracking-wider text-13 font-light select-none", textClassName || "text-tertiary")}
|
||||
fill="currentColor"
|
||||
>
|
||||
{top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Bottom section */}
|
||||
{bottom.show && (
|
||||
<g>
|
||||
{bottom.showValue && value !== undefined && (
|
||||
<text
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT}
|
||||
y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
|
||||
textAnchor="start"
|
||||
className={cn("tracking-wider text-13 font-light select-none", textClassName || "text-tertiary")}
|
||||
fill="currentColor"
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
{bottom.showLabel && label && (
|
||||
<tspan dx={4}>
|
||||
{bottom.labelTruncated
|
||||
? truncateText(
|
||||
label,
|
||||
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.SM) - 4,
|
||||
LAYOUT.TEXT.FONT_SIZES.SM
|
||||
)
|
||||
: label}
|
||||
</tspan>
|
||||
)}
|
||||
{!bottom.showLabel && label && <tspan dx={4}>...</tspan>}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={width} height={height} fill="transparent" />
|
||||
{renderContent()}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import type { TreeMapChartProps } from "@plane/types";
|
||||
// local imports
|
||||
import { cn } from "../../utils/classname";
|
||||
import { CustomTreeMapContent } from "./map-content";
|
||||
import { TreeMapTooltip } from "./tooltip";
|
||||
|
||||
export const TreeMapChart = React.memo(function TreeMapChart(props: TreeMapChartProps) {
|
||||
const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props;
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Treemap
|
||||
data={data}
|
||||
nameKey="name"
|
||||
dataKey="value"
|
||||
stroke="transparent"
|
||||
className="cursor-pointer bg-layer-1"
|
||||
content={<CustomTreeMapContent />}
|
||||
animationEasing="ease-out"
|
||||
isUpdateAnimationActive={isAnimationActive}
|
||||
animationBegin={100}
|
||||
animationDuration={500}
|
||||
>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
content={({ active, payload }) => <TreeMapTooltip active={active} payload={payload} />}
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "bg-layer-1 cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeMapChart.displayName = "TreeMapChart";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "../../card";
|
||||
|
||||
interface TreeMapTooltipProps {
|
||||
active: boolean | undefined;
|
||||
payload: any[] | undefined;
|
||||
}
|
||||
|
||||
export const TreeMapTooltip = React.memo(function TreeMapTooltip({ active, payload }: TreeMapTooltipProps) {
|
||||
if (!active || !payload || !payload[0]?.payload) return null;
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col space-y-1.5" spacing={ECardSpacing.SM}>
|
||||
<div className="flex items-center gap-2 border-b border-subtle pb-2.5">
|
||||
{data?.icon}
|
||||
<p className="text-11 font-medium text-primary capitalize">{data?.name}</p>
|
||||
</div>
|
||||
<span className="text-11 font-medium text-secondary">
|
||||
{data?.value.toLocaleString()}
|
||||
{data.label && ` ${data.label}`}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
TreeMapTooltip.displayName = "TreeMapTooltip";
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { ChevronDownIcon } from "../icons/arrows/chevron-down";
|
||||
import { Collapsible } from "./collapsible";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Collapsible",
|
||||
component: Collapsible.CollapsibleRoot,
|
||||
subcomponents: {
|
||||
CollapsibleTrigger: Collapsible.CollapsibleTrigger,
|
||||
CollapsibleContent: Collapsible.CollapsibleContent,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: null,
|
||||
isOpen: false,
|
||||
onToggle: () => {},
|
||||
},
|
||||
render(args) {
|
||||
const [{ isOpen }, updateArgs] = useArgs();
|
||||
const toggleOpen = () => updateArgs({ isOpen: !isOpen });
|
||||
|
||||
return (
|
||||
<Collapsible.CollapsibleRoot {...args} isOpen={isOpen} onToggle={toggleOpen} className="w-96">
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">Click to toggle</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 rounded-md border p-4">
|
||||
<p className="text-13">This is the collapsible content that can be shown or hidden.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof Collapsible.CollapsibleRoot>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const DefaultOpen: Story = {
|
||||
args: { isOpen: true },
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setIsOpen(true)} className="bg-blue-500 rounded-sm px-4 py-2 text-13 text-on-color">
|
||||
Open
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} className="bg-gray-500 rounded-sm px-4 py-2 text-13 text-on-color">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="bg-green-500 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.CollapsibleRoot isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} className="w-96">
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">Controlled Collapsible</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 rounded-md border p-4">
|
||||
<p className="text-13">This collapsible is controlled by external state.</p>
|
||||
<p className="mt-2 text-13">Current state: {isOpen ? "Open" : "Closed"}</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedContent: Story = {
|
||||
render(args) {
|
||||
const [isOpen, setIsOpen] = useState(args.isOpen);
|
||||
return (
|
||||
<Collapsible.CollapsibleRoot {...args} isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} className="w-96">
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">Collapsible with Nested Content</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 space-y-2 rounded-md border p-4">
|
||||
<h4 className="font-semibold">Section 1</h4>
|
||||
<p className="text-13">This is some content in the first section.</p>
|
||||
<h4 className="font-semibold">Section 2</h4>
|
||||
<p className="text-13">This is some content in the second section.</p>
|
||||
<ul className="list-inside list-disc text-13">
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
<li>Item 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render(args) {
|
||||
const [isOpen, setIsOpen] = useState(args.isOpen);
|
||||
return (
|
||||
<Collapsible.CollapsibleRoot {...args} isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} className="w-96">
|
||||
<Collapsible.CollapsibleTrigger className="from-purple-500 to-pink-500 shadow-lg hover:shadow-xl flex w-full items-center justify-between rounded-lg bg-gradient-to-r px-6 py-3 text-on-color transition-all">
|
||||
<span className="text-16 font-bold">Custom Styled Trigger</span>
|
||||
<ChevronDownIcon className="h-5 w-5 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-4">
|
||||
<div className="from-purple-100 to-pink-100 shadow-md rounded-lg bg-gradient-to-br p-6">
|
||||
<p className="text-purple-900">This collapsible has custom styling with gradients, shadows, and colors.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleCollapsibles: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="w-96 space-y-2">
|
||||
<Collapsible.CollapsibleRoot>
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">First Item</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 rounded-md border p-4">
|
||||
<p className="text-13">Content for the first item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
|
||||
<Collapsible.CollapsibleRoot>
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">Second Item</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 rounded-md border p-4">
|
||||
<p className="text-13">Content for the second item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
|
||||
<Collapsible.CollapsibleRoot>
|
||||
<Collapsible.CollapsibleTrigger className="bg-gray-100 hover:bg-gray-200 flex w-full items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="font-semibold">Third Item</span>
|
||||
<ChevronDownIcon className="h-4 w-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="border-gray-200 rounded-md border p-4">
|
||||
<p className="text-13">Content for the third item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, createContext, useContext } from "react";
|
||||
import { Collapsible as BaseCollapsible } from "@base-ui-components/react/collapsible";
|
||||
import clsx from "clsx";
|
||||
|
||||
// Types
|
||||
type CollapsibleContextType = {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
type RootProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
type TriggerProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
buttonRef?: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Context
|
||||
const CollapsibleContext = createContext<CollapsibleContextType | undefined>(undefined);
|
||||
|
||||
// Hook
|
||||
const useCollapsible = () => {
|
||||
const context = useContext(CollapsibleContext);
|
||||
if (!context) {
|
||||
throw new Error("Collapsible compound components cannot be rendered outside the Collapsible component");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Components
|
||||
function Root({ children, className, isOpen: controlledIsOpen, onToggle, defaultOpen }: RootProps) {
|
||||
const [localIsOpen, setLocalIsOpen] = useState<boolean>(controlledIsOpen || defaultOpen || false);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlledIsOpen !== undefined) {
|
||||
setLocalIsOpen(controlledIsOpen);
|
||||
}
|
||||
}, [controlledIsOpen]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (controlledIsOpen !== undefined) {
|
||||
onToggle?.();
|
||||
} else {
|
||||
setLocalIsOpen((prev) => !prev);
|
||||
}
|
||||
}, [controlledIsOpen, onToggle]);
|
||||
|
||||
return (
|
||||
<CollapsibleContext.Provider value={{ isOpen: localIsOpen, onToggle: handleToggle }}>
|
||||
<BaseCollapsible.Root
|
||||
className={clsx(className)}
|
||||
defaultOpen={defaultOpen}
|
||||
open={localIsOpen}
|
||||
onOpenChange={handleToggle}
|
||||
>
|
||||
{children}
|
||||
</BaseCollapsible.Root>
|
||||
</CollapsibleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Trigger({ children, className, buttonRef }: TriggerProps) {
|
||||
const { isOpen } = useCollapsible();
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Trigger data-panel-open={isOpen} ref={buttonRef} className={className}>
|
||||
{children}
|
||||
</BaseCollapsible.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ children, className }: ContentProps) {
|
||||
return (
|
||||
<BaseCollapsible.Panel
|
||||
className={clsx(
|
||||
"flex h-[var(--collapsible-panel-height)] flex-col overflow-hidden text-13 transition-all ease-out data-[ending-style]:h-0 data-[starting-style]:h-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseCollapsible.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Compound Component
|
||||
export const Collapsible = {
|
||||
CollapsibleRoot: Root,
|
||||
CollapsibleTrigger: Trigger,
|
||||
CollapsibleContent: Content,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./collapsible";
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CheckIcon } from "../icons";
|
||||
import { Combobox } from "./combobox";
|
||||
|
||||
const frameworks = [
|
||||
{ value: "react", label: "React" },
|
||||
{ value: "vue", label: "Vue" },
|
||||
{ value: "angular", label: "Angular" },
|
||||
{ value: "svelte", label: "Svelte" },
|
||||
{ value: "solid", label: "Solid" },
|
||||
{ value: "next", label: "Next.js" },
|
||||
{ value: "nuxt", label: "Nuxt" },
|
||||
{ value: "remix", label: "Remix" },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Components/Combobox",
|
||||
component: Combobox,
|
||||
subcomponents: {
|
||||
ComboboxButton: Combobox.Button,
|
||||
ComboboxOptions: Combobox.Options,
|
||||
ComboboxOption: Combobox.Option,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: null,
|
||||
value: "",
|
||||
onValueChange: () => {},
|
||||
},
|
||||
render(args) {
|
||||
const [{ value }, updateArgs] = useArgs();
|
||||
const setValue = (newValue: string | string[]) => updateArgs({ value: newValue });
|
||||
return (
|
||||
<Combobox {...args} value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof Combobox>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Combobox multiSelect value={value} onValueChange={(v) => setValue(v as string[])}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span className="truncate">{value.length > 0 ? `${value.length} selected` : "Select frameworks..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value.includes(framework.value) && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelectWithLimit: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Combobox multiSelect maxSelections={3} value={value} onValueChange={(v) => setValue(v as string[])}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span className="truncate">
|
||||
{value.length > 0 ? `${value.length}/3 selected` : "Select up to 3 frameworks..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value.includes(framework.value) && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
<p className="text-gray-500 text-11">Maximum 3 selections allowed</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Combobox disabled value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 bg-gray-100 flex w-72 items-center justify-between rounded-md border px-4 py-2 opacity-50">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledOptions: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
disabled={framework.value === "angular" || framework.value === "svelte"}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options showSearch searchPlaceholder="Search framework..." maxHeight="sm" className="w-72">
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomEmptyMessage: Story = {
|
||||
render() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
|
||||
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
|
||||
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Combobox.Button>
|
||||
<Combobox.Options
|
||||
showSearch
|
||||
searchPlaceholder="Search framework..."
|
||||
emptyMessage="No frameworks found. Try a different search."
|
||||
className="w-72"
|
||||
>
|
||||
{frameworks.map((framework) => (
|
||||
<Combobox.Option
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <CheckIcon className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
|
||||
import { SearchIcon } from "../icons";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
// Type definitions
|
||||
type TMaxHeight = "lg" | "md" | "rg" | "sm";
|
||||
|
||||
export interface ComboboxProps {
|
||||
value?: string | string[];
|
||||
defaultValue?: string | string[];
|
||||
onValueChange?: (value: string | string[]) => void;
|
||||
multiSelect?: boolean;
|
||||
maxSelections?: number;
|
||||
disabled?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ComboboxButtonProps {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface ComboboxOptionsProps {
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
showSearch?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: TMaxHeight;
|
||||
inputClassName?: string;
|
||||
optionsContainerClassName?: string;
|
||||
positionerClassName?: string;
|
||||
searchQuery?: string;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
onSearchQueryKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
dataPreventOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
export interface ComboboxOptionProps {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const MAX_HEIGHT_CLASSES: Record<TMaxHeight, string> = {
|
||||
lg: "max-h-60",
|
||||
md: "max-h-48",
|
||||
rg: "max-h-36",
|
||||
sm: "max-h-28",
|
||||
} as const;
|
||||
|
||||
// Root component
|
||||
function ComboboxRoot({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
multiSelect = false,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: ComboboxProps) {
|
||||
const handleValueChange = React.useCallback(
|
||||
(newValue: string | string[]) => {
|
||||
onValueChange?.(newValue);
|
||||
},
|
||||
[onValueChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCombobox.Root
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={handleValueChange}
|
||||
multiple={multiSelect}
|
||||
disabled={disabled}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger button component
|
||||
const ComboboxButton = React.forwardRef(function ComboboxButton(
|
||||
{ className, children, disabled = false }: ComboboxButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
return (
|
||||
<BaseCombobox.Trigger ref={ref} disabled={disabled} className={className}>
|
||||
{children}
|
||||
</BaseCombobox.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
// Options popup component
|
||||
function ComboboxOptions({
|
||||
children,
|
||||
showSearch = false,
|
||||
searchPlaceholder,
|
||||
maxHeight = "lg",
|
||||
className,
|
||||
inputClassName,
|
||||
optionsContainerClassName,
|
||||
emptyMessage = "No results found",
|
||||
positionerClassName,
|
||||
searchQuery: controlledSearchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchQueryKeyDown,
|
||||
dataPreventOutsideClick,
|
||||
}: ComboboxOptionsProps) {
|
||||
// const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [internalSearchQuery, setInternalSearchQuery] = React.useState("");
|
||||
|
||||
const searchQuery = controlledSearchQuery !== undefined ? controlledSearchQuery : internalSearchQuery;
|
||||
|
||||
const setSearchQuery = React.useCallback(
|
||||
(query: string) => {
|
||||
if (onSearchQueryChange) {
|
||||
onSearchQueryChange(query);
|
||||
} else {
|
||||
setInternalSearchQuery(query);
|
||||
}
|
||||
},
|
||||
[onSearchQueryChange]
|
||||
);
|
||||
|
||||
// Filter children based on search query
|
||||
const filteredChildren = React.useMemo(() => {
|
||||
if (!showSearch || !searchQuery) return children;
|
||||
|
||||
return React.Children.toArray(children).filter((child) => {
|
||||
if (!React.isValidElement(child)) return true;
|
||||
|
||||
// Only filter ComboboxOption components, leave other elements (like additional content) unfiltered
|
||||
if (child.type !== ComboboxOption) return true;
|
||||
|
||||
// Extract text content from child to search against
|
||||
const getTextContent = (node: React.ReactNode): string => {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (React.isValidElement(node) && node.props.children) {
|
||||
return getTextContent(node.props.children);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getTextContent).join(" ");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const textContent = getTextContent(child.props.children);
|
||||
const value = child.props.value || "";
|
||||
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return textContent.toLowerCase().includes(searchLower) || String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
}, [children, searchQuery, showSearch]);
|
||||
|
||||
return (
|
||||
<BaseCombobox.Portal>
|
||||
<BaseCombobox.Positioner sideOffset={8} className={positionerClassName}>
|
||||
<BaseCombobox.Popup
|
||||
className={cn("shadow-lg rounded-md border border-subtle bg-surface-1 p-1", className)}
|
||||
data-prevent-outside-click={dataPreventOutsideClick}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-placeholder" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchQueryKeyDown}
|
||||
className={cn(
|
||||
"w-full rounded-sm border border-subtle bg-surface-2 py-1.5 pr-2 pl-8 text-13 outline-none placeholder:text-placeholder",
|
||||
inputClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BaseCombobox.List
|
||||
className={cn("overflow-auto outline-none", MAX_HEIGHT_CLASSES[maxHeight], optionsContainerClassName)}
|
||||
>
|
||||
{filteredChildren}
|
||||
{showSearch &&
|
||||
emptyMessage &&
|
||||
React.Children.count(
|
||||
React.Children.toArray(filteredChildren).filter(
|
||||
(child) => React.isValidElement(child) && child.type === ComboboxOption
|
||||
)
|
||||
) === 0 && <div className="px-2 py-1.5 text-13 text-placeholder">{emptyMessage}</div>}
|
||||
</BaseCombobox.List>
|
||||
</div>
|
||||
</BaseCombobox.Popup>
|
||||
</BaseCombobox.Positioner>
|
||||
</BaseCombobox.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual option component
|
||||
function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) {
|
||||
return (
|
||||
<BaseCombobox.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn("cursor-pointer rounded-sm px-2 py-1.5 text-13 transition-colors outline-none", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// Compound component export
|
||||
const Combobox = Object.assign(ComboboxRoot, {
|
||||
Button: ComboboxButton,
|
||||
Options: ComboboxOptions,
|
||||
Option: ComboboxOption,
|
||||
});
|
||||
|
||||
export { Combobox };
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./combobox";
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { File, Folder, Settings, User } from "lucide-react";
|
||||
import { Command } from "./command";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Command",
|
||||
component: Command,
|
||||
subcomponents: {
|
||||
CommandInput: Command.Input,
|
||||
CommandList: Command.List,
|
||||
CommandItem: Command.Item,
|
||||
CommandEmpty: Command.Empty,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Command>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-13 outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">Item 1</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">Item 2</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">Item 3</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input
|
||||
placeholder="Search files and folders..."
|
||||
className="h-9 w-full bg-transparent py-3 text-13 outline-none"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Documents</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Downloads</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<File className="h-4 w-4" />
|
||||
<span>README.md</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<File className="h-4 w-4" />
|
||||
<span>package.json</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">No files or folders found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCategories: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input
|
||||
placeholder="Search commands..."
|
||||
className="h-9 w-full bg-transparent py-3 text-13 outline-none"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<div className="text-gray-500 px-2 py-1.5 text-11 font-semibold">User</div>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Command.Item>
|
||||
|
||||
<div className="text-gray-500 mt-2 px-2 py-1.5 text-11 font-semibold">Files</div>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Open Folder</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<File className="h-4 w-4" />
|
||||
<span>New File</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">No commands found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-13 outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">{/* No items - will show empty state */}</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">
|
||||
<p className="font-semibold">No results found</p>
|
||||
<p className="mt-1 text-11">Try searching for something else</p>
|
||||
</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LongList: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input placeholder="Search items..." className="h-9 w-full bg-transparent py-3 text-13 outline-none" />
|
||||
<Command.List className="max-h-60 overflow-auto py-2">
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<Command.Item key={i} className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">
|
||||
Item {i + 1}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Files</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-blue-300 bg-blue-50 shadow-lg w-96 rounded-lg border-2 p-2">
|
||||
<Command.Input
|
||||
placeholder="Search with custom styling..."
|
||||
className="text-blue-900 placeholder:text-blue-400 h-9 w-full bg-transparent py-3 text-13 outline-none"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="text-blue-900 hover:bg-blue-200 cursor-pointer rounded-sm px-3 py-2">
|
||||
Custom Item 1
|
||||
</Command.Item>
|
||||
<Command.Item className="text-blue-900 hover:bg-blue-200 cursor-pointer rounded-sm px-3 py-2">
|
||||
Custom Item 2
|
||||
</Command.Item>
|
||||
<Command.Item className="text-blue-900 hover:bg-blue-200 cursor-pointer rounded-sm px-3 py-2">
|
||||
Custom Item 3
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="text-blue-500 py-6 text-center text-13">No matching items found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledItems: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="border-gray-200 w-96 rounded-lg border p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-13 outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">Active Item 1</Command.Item>
|
||||
<Command.Item disabled className="cursor-not-allowed rounded-sm px-3 py-2 opacity-50">
|
||||
Disabled Item
|
||||
</Command.Item>
|
||||
<Command.Item className="hover:bg-gray-100 cursor-pointer rounded-sm px-3 py-2">Active Item 2</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="text-gray-500 py-6 text-center text-13">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "../icons";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
function CommandComponent({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return <CommandPrimitive data-slot="command" className={cn("", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2"
|
||||
>
|
||||
<SearchIcon className="size-3.5 flex-shrink-0 text-placeholder" strokeWidth={1.5} />
|
||||
<CommandPrimitive.Input data-slot="command-input" className={cn(className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({ ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return <CommandPrimitive.List data-slot="command-list" {...props} />;
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return <CommandPrimitive.Empty data-slot="command-empty" {...props} />;
|
||||
}
|
||||
|
||||
function CommandItem({ ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return <CommandPrimitive.Item data-slot="command-item" {...props} />;
|
||||
}
|
||||
|
||||
const Command = Object.assign(CommandComponent, {
|
||||
Input: CommandInput,
|
||||
List: CommandList,
|
||||
Empty: CommandEmpty,
|
||||
Item: CommandItem,
|
||||
});
|
||||
|
||||
export { Command };
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./command";
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Download, Edit, Share, Star, Archive } from "lucide-react";
|
||||
import { CopyIcon, TrashIcon } from "../icons";
|
||||
import { ChevronRightIcon } from "../icons/arrows/chevron-right";
|
||||
import { ContextMenu } from "./context-menu";
|
||||
|
||||
// cannot use satisfies here because base-ui does not have portable types.
|
||||
const meta: Meta<typeof ContextMenu> = {
|
||||
title: "Components/ContextMenu",
|
||||
component: ContextMenu,
|
||||
subcomponents: {
|
||||
ContextMenuTrigger: ContextMenu.Trigger,
|
||||
ContextMenuPortal: ContextMenu.Portal,
|
||||
ContextMenuContent: ContextMenu.Content,
|
||||
ContextMenuItem: ContextMenu.Item,
|
||||
ContextMenuSeparator: ContextMenu.Separator,
|
||||
ContextMenuSubmenu: ContextMenu.Submenu,
|
||||
ContextMenuSubmenuTrigger: ContextMenu.SubmenuTrigger,
|
||||
},
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>Back</ContextMenu.Item>
|
||||
<ContextMenu.Item>Forward</ContextMenu.Item>
|
||||
<ContextMenu.Item>Reload</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>More Tools</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSubmenus: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Submenu>
|
||||
<ContextMenu.SubmenuTrigger>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
Share
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</ContextMenu.SubmenuTrigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>Email</ContextMenu.Item>
|
||||
<ContextMenu.Item>Message</ContextMenu.Item>
|
||||
<ContextMenu.Item>Copy Link</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Submenu>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledItems: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item disabled>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit (Disabled)
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item disabled>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
Share (Disabled)
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnFileCard: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="w-64 cursor-pointer rounded-lg border border-subtle p-4 hover:bg-layer-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-sm bg-accent-primary text-16 text-on-color">
|
||||
📄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Document.pdf</div>
|
||||
<div className="text-13 text-placeholder">2.4 MB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
Add to Favorites
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnImage: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="relative h-56 w-80 cursor-pointer overflow-hidden rounded-lg bg-layer-1">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-placeholder">Image Placeholder</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Save Image
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy Image
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy Image URL
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>Open Image in New Tab</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnText: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="w-96 rounded-lg border border-subtle p-6">
|
||||
<h3 className="mb-2 text-16 font-semibold">Context Menu on Text</h3>
|
||||
<p className="text-tertiary">
|
||||
Right click anywhere on this text area to see the context menu. This demonstrates how context menus can be
|
||||
applied to text content areas.
|
||||
</p>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>Select All</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedSubmenus: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>New File</ContextMenu.Item>
|
||||
<ContextMenu.Item>New Folder</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Submenu>
|
||||
<ContextMenu.SubmenuTrigger>
|
||||
Import
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</ContextMenu.SubmenuTrigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>From File</ContextMenu.Item>
|
||||
<ContextMenu.Item>From URL</ContextMenu.Item>
|
||||
<ContextMenu.Submenu>
|
||||
<ContextMenu.SubmenuTrigger>
|
||||
From Cloud
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</ContextMenu.SubmenuTrigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>Google Drive</ContextMenu.Item>
|
||||
<ContextMenu.Item>Dropbox</ContextMenu.Item>
|
||||
<ContextMenu.Item>OneDrive</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Submenu>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Submenu>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithKeyboardShortcuts: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-strong text-13">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto text-11 text-placeholder">⌘C</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
<span className="ml-auto text-11 text-placeholder">⌘E</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
<span className="ml-auto text-11 text-placeholder">⌘D</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<TrashIcon className="mr-2 h-4 w-4 text-danger-primary" />
|
||||
<span className="text-danger-primary">Delete</span>
|
||||
<span className="ml-auto text-11 text-placeholder">⌘⌫</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "@base-ui-components/react/context-menu";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface ContextMenuProps extends React.ComponentProps<typeof ContextMenuPrimitive.Root> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ContextMenuTriggerProps extends React.ComponentProps<typeof ContextMenuPrimitive.Trigger> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuContentProps extends React.ComponentProps<typeof ContextMenuPrimitive.Positioner> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
sideOffset?: number;
|
||||
positionerClassName?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuItemProps extends React.ComponentProps<typeof ContextMenuPrimitive.Item> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ContextMenuRoot = React.forwardRef(function ContextMenuRoot(
|
||||
{ children, ...props }: ContextMenuProps,
|
||||
_ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Root>>
|
||||
) {
|
||||
return <ContextMenuPrimitive.Root {...props}>{children}</ContextMenuPrimitive.Root>;
|
||||
});
|
||||
|
||||
const ContextMenuTrigger = React.forwardRef(function ContextMenuTrigger(
|
||||
{ className, children, ...props }: ContextMenuTriggerProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Trigger>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger ref={ref} className={cn("outline-none", className)} {...props}>
|
||||
{children}
|
||||
</ContextMenuPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuContent = React.forwardRef(function ContextMenuContent(
|
||||
{ positionerClassName, className, children, side = "bottom", sideOffset = 4, ...props }: ContextMenuContentProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Positioner>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Positioner
|
||||
ref={ref}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
className={positionerClassName}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
className={cn(
|
||||
"shadow-md z-50 min-w-32 overflow-hidden rounded-md border border-subtle bg-surface-1 p-1",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuPrimitive.Popup>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
);
|
||||
});
|
||||
|
||||
const ContextMenuItem = React.forwardRef(function ContextMenuItem(
|
||||
{ className, disabled, children, ...props }: ContextMenuItemProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Item>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center rounded-xs px-2 py-1.5 text-13 outline-none select-none",
|
||||
"focus:bg-surface-2 focus:text-primary",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef(function ContextMenuSeparator(
|
||||
{ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.Separator>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator ref={ref} className={cn("bg-subtle-1 -mx-1 my-1 h-px", className)} {...props} />
|
||||
);
|
||||
});
|
||||
|
||||
const ContextMenuSubmenu = ContextMenuPrimitive.SubmenuRoot;
|
||||
|
||||
const ContextMenuSubmenuTrigger = React.forwardRef(function ContextMenuSubmenuTrigger(
|
||||
{ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubmenuTrigger>,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof ContextMenuPrimitive.SubmenuTrigger>>
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-xs px-2 py-1.5 text-13 outline-none select-none focus:outline-none",
|
||||
"focus:bg-surface-2 data-[state=open]:bg-surface-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
ContextMenuRoot.displayName = "ContextMenu";
|
||||
ContextMenuTrigger.displayName = "ContextMenuTrigger";
|
||||
ContextMenuContent.displayName = "ContextMenuContent";
|
||||
ContextMenuItem.displayName = "ContextMenuItem";
|
||||
ContextMenuSeparator.displayName = "ContextMenuSeparator";
|
||||
ContextMenuSubmenuTrigger.displayName = "ContextMenuSubmenuTrigger";
|
||||
|
||||
// compound components
|
||||
const ContextMenu = Object.assign(ContextMenuRoot, {
|
||||
Trigger: ContextMenuTrigger,
|
||||
Portal: ContextMenuPortal,
|
||||
Content: ContextMenuContent,
|
||||
Item: ContextMenuItem,
|
||||
Separator: ContextMenuSeparator,
|
||||
Submenu: ContextMenuSubmenu,
|
||||
SubmenuTrigger: ContextMenuSubmenuTrigger,
|
||||
});
|
||||
|
||||
export { ContextMenu };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { ContextMenu } from "./context-menu";
|
||||
export type {
|
||||
ContextMenuProps,
|
||||
ContextMenuTriggerProps,
|
||||
ContextMenuContentProps,
|
||||
ContextMenuItemProps,
|
||||
} from "./context-menu";
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Philosophy",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
# Design System Philosophy
|
||||
|
||||
Reusable, composable Storybook stories that demonstrate Canvas, Surface, and Layer concepts.
|
||||
|
||||
Key concepts and rules are preserved, but the implementation is componentized for DRYness and clarity.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
export type Story = StoryObj<typeof meta>;
|
||||
|
||||
/* -----------------------------
|
||||
Reusable UI building blocks
|
||||
-----------------------------*/
|
||||
|
||||
type ContainerProps = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DemoRoot: React.FC<ContainerProps> = ({ children, className = "" }) => (
|
||||
<div className={`p-8 ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
const Info: React.FC<{ title: string; children?: React.ReactNode; tone?: "info" | "warn" }> = ({
|
||||
title,
|
||||
children,
|
||||
tone = "info",
|
||||
}) => (
|
||||
<div
|
||||
className={`mb-4 rounded-md border ${tone === "warn" ? "bg-red-50 border-danger-strong p-4" : "border-subtle bg-layer-1 p-4"}`}
|
||||
>
|
||||
<h3 className={`mb-2 text-16 font-semibold text-primary`}>{title}</h3>
|
||||
<div className="text-13 text-secondary">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Surface: React.FC<ContainerProps> = ({ children, className = "bg-surface-1 rounded-md p-6" }) => (
|
||||
<div className={className}>{children}</div>
|
||||
);
|
||||
|
||||
const Layer: React.FC<ContainerProps & { hover?: boolean }> = ({
|
||||
children,
|
||||
className = "bg-layer-1 hover:bg-layer-1-hover rounded-md p-4",
|
||||
}) => <div className={`${className} transition-colors`}>{children}</div>;
|
||||
|
||||
/* Small helpers to keep stories concise */
|
||||
const TwoColGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="grid grid-cols-2 gap-6">{children}</div>
|
||||
);
|
||||
|
||||
/* -----------------------------
|
||||
Stories (using the building blocks)
|
||||
-----------------------------*/
|
||||
|
||||
export const ApplicationRoot: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Application Root Pattern">
|
||||
This is the <strong>bg-canvas</strong> - the application-level background. It should only appear{" "}
|
||||
<strong>once</strong> in your entire application at the root level.
|
||||
</Info>
|
||||
|
||||
<Surface>
|
||||
<h4 className="mb-2 font-semibold text-primary">Page Content (bg-surface-1)</h4>
|
||||
<p className="text-13 text-secondary">Pages use surfaces, not canvas. This is a typical page layout.</p>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const SurfaceSiblings: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Surface Siblings Pattern">
|
||||
Surfaces are <strong>siblings</strong>, not nested.
|
||||
</Info>
|
||||
|
||||
<TwoColGrid>
|
||||
<Surface>
|
||||
<h4 className="mb-2 font-semibold text-primary">Surface 1</h4>
|
||||
<p className="text-13 text-secondary">This is bg-surface-1 - a primary surface</p>
|
||||
</Surface>
|
||||
|
||||
<Surface className="rounded-md bg-surface-2 p-6">
|
||||
<h4 className="mb-2 font-semibold text-primary">Surface 2</h4>
|
||||
<p className="text-13 text-secondary">This is bg-surface-2 - a secondary surface (sibling to surface-1)</p>
|
||||
</Surface>
|
||||
</TwoColGrid>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const LayerStacking: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Layer Stacking Pattern">Layers stack to create depth: Surface → Layer 1 → Layer 2 → Layer 3</Info>
|
||||
|
||||
<Surface>
|
||||
<h4 className="mb-3 font-semibold text-primary">Surface 1</h4>
|
||||
|
||||
<Layer className="mb-4 rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<h5 className="mb-2 font-medium text-primary">Layer 1 (First level of depth)</h5>
|
||||
<p className="mb-3 text-13 text-secondary">Hover over me to see the hover state</p>
|
||||
|
||||
<Layer className="rounded-md bg-layer-2 p-3 hover:bg-layer-2-hover">
|
||||
<h6 className="mb-2 text-13 font-medium text-primary">Layer 2 (Second level)</h6>
|
||||
<p className="mb-2 text-13 text-secondary">Nested within Layer 1</p>
|
||||
|
||||
<Layer className="rounded-md bg-layer-3 p-2 hover:bg-layer-3-hover" hover>
|
||||
<p className="text-11 font-medium text-primary">Layer 3 (Third level)</p>
|
||||
<p className="text-11 text-secondary">Deepest nesting level</p>
|
||||
</Layer>
|
||||
</Layer>
|
||||
</Layer>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const SurfaceLayerAssociation: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Surface-Layer Association">
|
||||
Each surface should use its corresponding layer: surface-1 → layer-1, surface-2 → layer-2. Very rare exception:
|
||||
inputs/buttons can go one level above for visual separation.
|
||||
</Info>
|
||||
|
||||
<TwoColGrid>
|
||||
<Surface>
|
||||
<h4 className="mb-3 font-semibold text-primary">Surface 1</h4>
|
||||
<Layer className="rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<h5 className="mb-1 font-medium text-primary">Layer 1</h5>
|
||||
<p className="text-13 text-secondary">Correctly using layer-1 with surface-1</p>
|
||||
</Layer>
|
||||
</Surface>
|
||||
|
||||
<Surface className="rounded-md bg-surface-2 p-6">
|
||||
<h4 className="mb-3 font-semibold text-primary">Surface 2</h4>
|
||||
<Layer className="rounded-md bg-layer-2 p-4 hover:bg-layer-2-hover">
|
||||
<h5 className="mb-1 font-medium text-primary">Layer 2</h5>
|
||||
<p className="text-13 text-secondary">Correctly using layer-2 with surface-2</p>
|
||||
</Layer>
|
||||
</Surface>
|
||||
</TwoColGrid>
|
||||
|
||||
<div className="mt-6">
|
||||
<Info title="✅ Rare Exception: Visual Separation for Form Elements">
|
||||
In very rare cases, form elements (inputs, buttons, switches) can use one level above for visual separation.
|
||||
</Info>
|
||||
<Surface>
|
||||
<h4 className="mb-3 font-semibold text-primary">Modal with Input (Rare Exception)</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="example-input" className="text-13 text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="example-input"
|
||||
className="mt-1 w-full rounded-md border border-subtle bg-layer-2 px-3 py-2 text-primary"
|
||||
placeholder="Input uses layer-2 for visual separation"
|
||||
/>
|
||||
<p className="mt-1 text-11 text-tertiary">
|
||||
Input uses bg-layer-2 (one level above) for visual separation from modal surface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const ModalException: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Modal Exception Pattern">
|
||||
Modals exist on a <strong>different plane</strong>, so they can use surfaces even when there's a surface
|
||||
below
|
||||
</Info>
|
||||
|
||||
<Surface>
|
||||
<h4 className="mb-2 font-semibold text-primary">Main Page Content</h4>
|
||||
<p className="text-13 text-secondary">This is the main page using bg-surface-1</p>
|
||||
</Surface>
|
||||
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-backdrop" />
|
||||
<div className="shadow-lg relative z-10 max-w-md rounded-lg bg-surface-1 p-6">
|
||||
<h4 className="mb-3 font-semibold text-primary">Modal Dialog</h4>
|
||||
<p className="mb-4 text-13 text-secondary">
|
||||
This modal uses bg-surface-1 even though the page below also uses bg-surface-1. This is allowed because
|
||||
they're on different planes.
|
||||
</p>
|
||||
|
||||
<Layer className="rounded-md bg-layer-1 p-3 hover:bg-layer-1-hover">
|
||||
<p className="text-13 text-primary">Modal content can use layers as normal</p>
|
||||
</Layer>
|
||||
</div>
|
||||
</div>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const CardListPattern: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Card List Pattern">Common pattern: Surface containing multiple layer-1 cards</Info>
|
||||
|
||||
<Surface>
|
||||
<h4 className="mb-4 font-semibold text-primary">Task List</h4>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<Layer key={item} className="rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<h5 className="mb-1 font-medium text-primary">Task {item}</h5>
|
||||
<p className="text-13 text-secondary">This is a task card using bg-layer-1 with hover state</p>
|
||||
</Layer>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const SidebarLayoutPattern: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Sidebar Layout Pattern">
|
||||
Sidebar and main content are both part of the same surface. Sidebar menu items use transparent backgrounds with
|
||||
hover states.
|
||||
</Info>
|
||||
|
||||
<Surface className="flex rounded-md bg-surface-1">
|
||||
<aside className="w-64 border-r border-subtle p-4">
|
||||
<h4 className="mb-3 font-semibold text-primary">Sidebar</h4>
|
||||
<div className="space-y-2">
|
||||
{["Home", "Projects", "Settings"].map((item) => (
|
||||
<div key={item} className="rounded-md p-2 transition-colors hover:bg-layer-1-hover">
|
||||
<p className="text-13 text-primary">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
<h4 className="mb-3 font-semibold text-primary">Main Content</h4>
|
||||
<Layer className="rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<p className="text-primary">Content card using layer-1</p>
|
||||
</Layer>
|
||||
</main>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const StateVariants: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ State Variants">Demonstrating hover, active, and selected states</Info>
|
||||
|
||||
<Surface>
|
||||
<div className="space-y-4">
|
||||
<Layer className="rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<h5 className="mb-1 font-medium text-primary">Hover State</h5>
|
||||
<p className="text-13 text-secondary">Hover over me to see bg-layer-1-hover</p>
|
||||
</Layer>
|
||||
|
||||
<div className="rounded-md bg-layer-1-active p-4">
|
||||
<h5 className="mb-1 font-medium text-primary">Active State</h5>
|
||||
<p className="text-13 text-secondary">Using bg-layer-1-active (pressed/active)</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-layer-1-selected p-4">
|
||||
<h5 className="mb-1 font-medium text-primary">Selected State</h5>
|
||||
<p className="text-13 text-secondary">Using bg-layer-1-selected (when selected)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const TextColorHierarchy: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Text Color Hierarchy">Semantic text colors for different importance levels</Info>
|
||||
|
||||
<Surface>
|
||||
<div className="rounded-md bg-layer-1 p-4">
|
||||
<h4 className="mb-3 text-16 font-semibold text-primary">Primary Text</h4>
|
||||
<p className="mb-3 text-secondary">Secondary text for descriptions and supporting content</p>
|
||||
<p className="mb-3 text-13 text-tertiary">Tertiary text for labels and metadata</p>
|
||||
<input
|
||||
className="rounded border border-subtle px-3 py-2 placeholder-(--text-color-placeholder)"
|
||||
placeholder="Placeholder text for inputs"
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const CompleteExample: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="✅ Complete Example">A realistic dashboard layout using all design system concepts</Info>
|
||||
|
||||
<div className="mb-6 rounded-md bg-surface-1">
|
||||
<div className="border-b border-subtle p-4">
|
||||
<h1 className="text-18 font-bold text-primary">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ label: "Total Users", value: "1,234" },
|
||||
{ label: "Active Projects", value: "42" },
|
||||
{ label: "Completed Tasks", value: "856" },
|
||||
].map((stat, idx) => (
|
||||
<Surface key={idx}>
|
||||
<Layer className="rounded-md bg-layer-1 p-4 hover:bg-layer-1-hover">
|
||||
<p className="mb-1 text-13 text-tertiary">{stat.label}</p>
|
||||
<p className="text-20 font-bold text-primary">{stat.value}</p>
|
||||
</Layer>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<Surface>
|
||||
<h3 className="mb-4 font-semibold text-primary">Recent Activity</h3>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<Layer key={item} className="rounded-md bg-layer-1 p-3 hover:bg-layer-1-hover">
|
||||
<p className="mb-1 text-13 font-medium text-primary">Activity {item}</p>
|
||||
<p className="text-11 text-secondary">Description of the activity</p>
|
||||
</Layer>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="rounded-md bg-surface-2 p-6">
|
||||
<h3 className="mb-4 font-semibold text-primary">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{["Create Project", "Invite Team", "View Reports"].map((action) => (
|
||||
<Layer key={action} className="rounded-md bg-layer-2 p-3 hover:bg-layer-2-hover">
|
||||
<p className="text-13 text-primary">{action}</p>
|
||||
</Layer>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
|
||||
export const CommonMistakes: Story = {
|
||||
render: () => (
|
||||
<DemoRoot>
|
||||
<Info title="❌ Common Mistakes to Avoid" tone="warn">
|
||||
These examples show incorrect usage patterns
|
||||
</Info>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border-2 border-danger-strong p-4">
|
||||
<h4 className="mb-2 font-semibold text-primary">❌ Mistake 1: Nested Surfaces (Same Plane)</h4>
|
||||
<Surface>
|
||||
<p className="mb-2 text-13 text-secondary">Surface 1</p>
|
||||
<div className="rounded-md bg-surface-2 p-4">
|
||||
<p className="text-13 text-secondary">Surface 2 nested inside Surface 1 - WRONG!</p>
|
||||
</div>
|
||||
</Surface>
|
||||
<p className="mt-2 text-11 text-tertiary">
|
||||
✅ Fix: Use bg-layer-1 for nested elements, or make them sibling surfaces
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-2 border-danger-strong p-4">
|
||||
<h4 className="mb-2 font-semibold text-primary">❌ Mistake 2: Wrong Layer-Surface Association</h4>
|
||||
<Surface>
|
||||
<p className="mb-2 text-13 text-secondary">Surface 1</p>
|
||||
<div className="rounded-md bg-layer-2 p-4">
|
||||
<p className="text-13 text-secondary">Using layer-2 with surface-1 for content box - WRONG!</p>
|
||||
</div>
|
||||
</Surface>
|
||||
<p className="mt-2 text-11 text-tertiary">
|
||||
✅ Fix: Use bg-layer-1 with bg-surface-1 for content boxes. Exception: Very rare cases for inputs/buttons
|
||||
that need visual separation (e.g., input in modal can use bg-layer-2 for separation).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-2 border-danger-strong p-4">
|
||||
<h4 className="mb-2 font-semibold text-primary">❌ Mistake 3: Mismatched Hover State</h4>
|
||||
<Surface>
|
||||
<div className="rounded-md bg-layer-1 p-4 transition-colors hover:bg-layer-2-hover">
|
||||
<p className="text-13 text-secondary">bg-layer-1 with hover:bg-layer-2-hover - WRONG!</p>
|
||||
</div>
|
||||
</Surface>
|
||||
<p className="mt-2 text-11 text-tertiary">✅ Fix: Use bg-layer-1 hover:bg-layer-1-hover</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-2 border-danger-strong p-4">
|
||||
<h4 className="mb-2 font-semibold text-primary">❌ Mistake 4: Canvas for Pages</h4>
|
||||
<div className="rounded-md bg-canvas p-4">
|
||||
<p className="text-13 text-secondary">Using bg-canvas for a page or component - WRONG!</p>
|
||||
</div>
|
||||
<p className="mt-2 text-11 text-tertiary">
|
||||
✅ Fix: Canvas should only be at application root. Use bg-surface-1 for pages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DemoRoot>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CloseIcon } from "../icons/actions/close-icon";
|
||||
import { Dialog, EDialogWidth } from "./root";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Dialog",
|
||||
component: Dialog,
|
||||
subcomponents: {
|
||||
DialogPanel: Dialog.Panel,
|
||||
DialogTitle: Dialog.Title,
|
||||
},
|
||||
args: {
|
||||
children: null,
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
render(args) {
|
||||
const [{ open }, updateArgs] = useArgs();
|
||||
const setOpen = (value: boolean) => updateArgs({ open: value });
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Dialog
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Dialog Title</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">This is the dialog content. You can put any content here.</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 rounded-sm px-4 py-2 text-13"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof Dialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const TopPosition: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Dialog (Top)
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel position="top">
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Top Positioned Dialog</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">
|
||||
This dialog appears at the top of the screen instead of centered.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 rounded-sm px-4 py-2 text-13"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallWidth: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Small Dialog
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel width={EDialogWidth.SM}>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Small Dialog</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">This is a small dialog.</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeWidth: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Large Dialog
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel width={EDialogWidth.XXXXL}>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Large Dialog</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">
|
||||
This is a large dialog with more horizontal space for content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCloseButton: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Dialog with Close Button
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<Dialog.Title>Dialog with Close Button</Dialog.Title>
|
||||
<button onClick={() => setOpen(false)} className="hover:bg-gray-100 rounded-full p-1">
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">This dialog has a close button in the header.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ConfirmationDialog: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
const handleConfirm = () => {
|
||||
alert("Confirmed!");
|
||||
setOpen(false);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-red-500 hover:bg-red-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Delete Item
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel width={EDialogWidth.SM}>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Confirm Deletion</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">
|
||||
Are you sure you want to delete this item? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 rounded-sm px-4 py-2 text-13"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="bg-red-500 hover:bg-red-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FormDialog: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
alert("Form submitted!");
|
||||
setOpen(false);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Form
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel width={EDialogWidth.MD}>
|
||||
<form onSubmit={handleSubmit} className="p-6">
|
||||
<Dialog.Title>Create New Item</Dialog.Title>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="text-gray-700 block text-13 font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
className="border-gray-300 mt-1 w-full rounded-sm border px-3 py-2 text-13"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="text-gray-700 block text-13 font-medium">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
className="border-gray-300 mt-1 w-full rounded-sm border px-3 py-2 text-13"
|
||||
placeholder="Enter description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 rounded-sm px-4 py-2 text-13"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ScrollableContent: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color"
|
||||
>
|
||||
Open Scrollable Dialog
|
||||
</button>
|
||||
{open && (
|
||||
<Dialog {...args} open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Panel width={EDialogWidth.MD}>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>Scrollable Content</Dialog.Title>
|
||||
<div className="mt-4 max-h-96 overflow-y-auto">
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<p key={i} className="text-gray-600 mb-2 text-13">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliqua.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AllWidths: Story = {
|
||||
render() {
|
||||
const [openWidth, setOpenWidth] = useState<EDialogWidth | null>(null);
|
||||
|
||||
const widths = [
|
||||
{ width: EDialogWidth.SM, label: "Small" },
|
||||
{ width: EDialogWidth.MD, label: "Medium" },
|
||||
{ width: EDialogWidth.LG, label: "Large" },
|
||||
{ width: EDialogWidth.XL, label: "XL" },
|
||||
{ width: EDialogWidth.XXL, label: "2XL" },
|
||||
{ width: EDialogWidth.XXXL, label: "3XL" },
|
||||
{ width: EDialogWidth.XXXXL, label: "4XL" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{widths.map(({ width, label }) => (
|
||||
<button
|
||||
key={width}
|
||||
onClick={() => setOpenWidth(width)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{widths.map(({ width, label }) => (
|
||||
<Dialog key={width} open={openWidth === width} onOpenChange={() => setOpenWidth(null)}>
|
||||
<Dialog.Panel width={width}>
|
||||
<div className="p-6">
|
||||
<Dialog.Title>{label} Dialog</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 text-13">This dialog uses the {label} width variant.</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpenWidth(null)}
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-13 text-on-color"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { forwardRef, memo, useMemo } from "react";
|
||||
import { Dialog as BaseDialog } from "@base-ui-components/react";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
// enums
|
||||
|
||||
export enum EDialogWidth {
|
||||
SM = "sm:max-w-sm",
|
||||
MD = "sm:max-w-md",
|
||||
LG = "sm:max-w-lg",
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
VXL = "sm:max-w-5xl",
|
||||
VIXL = "sm:max-w-6xl",
|
||||
VIIXL = "sm:max-w-7xl",
|
||||
}
|
||||
|
||||
// Types
|
||||
export type DialogPosition = "center" | "top";
|
||||
|
||||
export interface DialogProps extends React.ComponentProps<typeof BaseDialog.Root> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DialogPanelProps extends React.ComponentProps<typeof BaseDialog.Popup> {
|
||||
width?: EDialogWidth;
|
||||
position?: DialogPosition;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog.Title> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop");
|
||||
const BASE_CLASSNAME = "relative text-left bg-surface-1 rounded-lg shadow-md w-full z-100 border border-subtle";
|
||||
|
||||
// Utility functions
|
||||
const getPositionClassNames = (position: DialogPosition) =>
|
||||
cn("fixed isolate z-100", {
|
||||
"top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2": position === "center",
|
||||
"top-8 left-1/2 -translate-x-1/2": position === "top",
|
||||
});
|
||||
|
||||
const DialogPortal = memo(function DialogPortal({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Portal>) {
|
||||
return (
|
||||
<BaseDialog.Portal data-slot="dialog-portal" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Portal>
|
||||
);
|
||||
});
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogOverlay = memo(function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Backdrop>) {
|
||||
return <BaseDialog.Backdrop data-slot="dialog-overlay" className={cn(OVERLAY_CLASSNAME, className)} {...props} />;
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogComponent = memo(function DialogComponent({ children, ...props }: DialogProps) {
|
||||
return (
|
||||
<BaseDialog.Root data-slot="dialog" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Root>
|
||||
);
|
||||
});
|
||||
DialogComponent.displayName = "Dialog";
|
||||
|
||||
const DialogTrigger = memo(function DialogTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Trigger>) {
|
||||
return (
|
||||
<BaseDialog.Trigger data-slot="dialog-trigger" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Trigger>
|
||||
);
|
||||
});
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPanel = forwardRef(function DialogPanel(
|
||||
{ className, width = EDialogWidth.XXL, children, position = "center", ...props }: DialogPanelProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof BaseDialog.Popup>>
|
||||
) {
|
||||
const positionClassNames = useMemo(() => getPositionClassNames(position), [position]);
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<BaseDialog.Popup
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(BASE_CLASSNAME, positionClassNames, width, className)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseDialog.Popup>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogPanel.displayName = "DialogPanel";
|
||||
|
||||
const DialogTitle = memo(function DialogTitle({ className, children, ...props }: DialogTitleProps) {
|
||||
return (
|
||||
<BaseDialog.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-16 leading-none font-semibold", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseDialog.Title>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
// Create the compound Dialog component with proper typing
|
||||
const Dialog = Object.assign(DialogComponent, {
|
||||
Panel: DialogPanel,
|
||||
Title: DialogTitle,
|
||||
}) as typeof DialogComponent & {
|
||||
Panel: typeof DialogPanel;
|
||||
Title: typeof DialogTitle;
|
||||
};
|
||||
|
||||
export { Dialog, DialogTitle, DialogPanel };
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EmojiPicker } from "./emoji-picker";
|
||||
import type { TChangeHandlerProps } from "./helper";
|
||||
import { EmojiIconPickerTypes } from "./helper";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Emoji/EmojiPicker",
|
||||
component: EmojiPicker,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof EmojiPicker>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Default",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="😊 Pick an emoji or icon"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<div className="mb-2 font-medium">Selected:</div>
|
||||
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenToEmojiTab: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Emoji Tab",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="😊 Choose Emoji"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="text-13">Selected: {selectedValue.type === "emoji" ? selectedValue.value : "Icon"}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenToIconTab: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Icon Tab",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="🎨 Choose Icon"
|
||||
defaultOpen={EmojiIconPickerTypes.ICON}
|
||||
closeOnSelect
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="text-13">
|
||||
Selected:{" "}
|
||||
{selectedValue.type === "icon" && typeof selectedValue.value === "object"
|
||||
? selectedValue.value.name
|
||||
: "Emoji"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LucideIcons: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Lucide Icons",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="Lucide Icons"
|
||||
defaultOpen={EmojiIconPickerTypes.ICON}
|
||||
closeOnSelect
|
||||
iconType="lucide"
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<div className="mb-2 font-medium">Selected Icon:</div>
|
||||
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaterialIcons: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Material Icons",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="Material Icons"
|
||||
defaultOpen={EmojiIconPickerTypes.ICON}
|
||||
closeOnSelect
|
||||
iconType="material"
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<div className="mb-2 font-medium">Selected Icon:</div>
|
||||
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseOnSelectDisabled: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Close On Select Disabled",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState<TChangeHandlerProps[]>([]);
|
||||
|
||||
const handleChange = (value: TChangeHandlerProps) => {
|
||||
setSelectedValues((prev) => [...prev, value]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleChange}
|
||||
label="Select Multiple (Stays Open)"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
<button
|
||||
className="rounded-sm bg-layer-1 px-3 py-1.5 text-13 hover:bg-surface-2"
|
||||
onClick={() => setSelectedValues([])}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<div className="mb-2 font-medium">Selected ({selectedValues.length}):</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedValues.map((val, idx) => (
|
||||
<span key={idx} className="text-16">
|
||||
{val.type === "emoji" ? val.value : "🎨"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Custom Search",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="Custom Search"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect
|
||||
searchPlaceholder="Type to find emojis..."
|
||||
/>
|
||||
{selectedValue && <div className="text-13">Selected: {JSON.stringify(selectedValue)}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchDisabled: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Search Disabled",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="No Search"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect
|
||||
searchDisabled
|
||||
/>
|
||||
{selectedValue && <div className="text-13">Selected: {JSON.stringify(selectedValue)}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomIconColor: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Custom Icon Color",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedValue}
|
||||
label="Custom Icon Color"
|
||||
defaultOpen={EmojiIconPickerTypes.ICON}
|
||||
closeOnSelect
|
||||
defaultIconColor="#FF5733"
|
||||
/>
|
||||
{selectedValue && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentPlacements: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Different Placements",
|
||||
},
|
||||
render() {
|
||||
const [isOpen1, setIsOpen1] = useState(false);
|
||||
const [isOpen2, setIsOpen2] = useState(false);
|
||||
const [isOpen3, setIsOpen3] = useState(false);
|
||||
const [isOpen4, setIsOpen4] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Bottom Start:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen1}
|
||||
handleToggle={setIsOpen1}
|
||||
onChange={() => {}}
|
||||
label="Bottom Start"
|
||||
placement="bottom-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Bottom End:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen2}
|
||||
handleToggle={setIsOpen2}
|
||||
onChange={() => {}}
|
||||
label="Bottom End"
|
||||
placement="bottom-end"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Top Start:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen3}
|
||||
handleToggle={setIsOpen3}
|
||||
onChange={() => {}}
|
||||
label="Top Start"
|
||||
placement="top-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Top End:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen4}
|
||||
handleToggle={setIsOpen4}
|
||||
onChange={() => {}}
|
||||
label="Top End"
|
||||
placement="top-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InFormContext: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "In Form Context",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
emoji: null as TChangeHandlerProps | null,
|
||||
});
|
||||
|
||||
const handleEmojiChange = (value: TChangeHandlerProps) => {
|
||||
setFormData((prev) => ({ ...prev, emoji: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
alert(`Form submitted:\n${JSON.stringify(formData, null, 2)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md p-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-subtle p-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-13 font-medium">Project Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full rounded-sm border border-subtle bg-layer-1 px-3 py-2"
|
||||
placeholder="Enter project title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-13 font-medium">Project Icon</label>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleEmojiChange}
|
||||
label={formData.emoji && formData.emoji.type === "emoji" ? formData.emoji.value : "Click to select icon"}
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect
|
||||
buttonClassName="px-4 py-2 bg-layer-1 border border-subtle rounded-sm hover:bg-surface-2 w-full text-left"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-sm bg-accent-primary px-4 py-2 text-on-color hover:bg-accent-primary/80"
|
||||
>
|
||||
Create Project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Tabs } from "@base-ui-components/react";
|
||||
import { Popover } from "../popover";
|
||||
import { cn } from "../utils/classname";
|
||||
import { convertPlacementToSideAndAlign } from "../utils/placement";
|
||||
import { EmojiRoot } from "./emoji/emoji";
|
||||
import type { TCustomEmojiPicker } from "./helper";
|
||||
import { emojiToString, EmojiIconPickerTypes } from "./helper";
|
||||
import { IconRoot } from "./icon/icon-root";
|
||||
|
||||
export function EmojiPicker(props: TCustomEmojiPicker) {
|
||||
const {
|
||||
isOpen,
|
||||
handleToggle,
|
||||
buttonClassName,
|
||||
closeOnSelect = true,
|
||||
defaultIconColor = "#6d7b8a",
|
||||
defaultOpen = EmojiIconPickerTypes.EMOJI,
|
||||
disabled = false,
|
||||
dropdownClassName,
|
||||
label,
|
||||
onChange,
|
||||
placement = "bottom-start",
|
||||
searchDisabled = false,
|
||||
searchPlaceholder = "Search",
|
||||
iconType = "lucide",
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
} = props;
|
||||
|
||||
// side and align calculations
|
||||
const { finalSide, finalAlign } = useMemo(() => {
|
||||
if (placement) {
|
||||
const converted = convertPlacementToSideAndAlign(placement);
|
||||
return { finalSide: converted.side, finalAlign: converted.align };
|
||||
}
|
||||
return { finalSide: side, finalAlign: align };
|
||||
}, [placement, side, align]);
|
||||
|
||||
const handleEmojiChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange({
|
||||
type: EmojiIconPickerTypes.EMOJI,
|
||||
value: emojiToString(value),
|
||||
});
|
||||
if (closeOnSelect) handleToggle(false);
|
||||
},
|
||||
[onChange, closeOnSelect, handleToggle]
|
||||
);
|
||||
|
||||
const handleIconChange = useCallback(
|
||||
(value: { name: string; color: string }) => {
|
||||
onChange({
|
||||
type: EmojiIconPickerTypes.ICON,
|
||||
value: value,
|
||||
});
|
||||
if (closeOnSelect) handleToggle(false);
|
||||
},
|
||||
[onChange, closeOnSelect, handleToggle]
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: "emoji",
|
||||
label: "Emoji",
|
||||
content: (
|
||||
<EmojiRoot
|
||||
onChange={handleEmojiChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchDisabled={searchDisabled}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "icon",
|
||||
label: "Icon",
|
||||
content: (
|
||||
<IconRoot
|
||||
defaultColor={defaultIconColor}
|
||||
onChange={handleIconChange}
|
||||
searchDisabled={searchDisabled}
|
||||
iconType={iconType}
|
||||
/>
|
||||
),
|
||||
},
|
||||
].map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
content: tab.content,
|
||||
})),
|
||||
[defaultIconColor, searchDisabled, searchPlaceholder, iconType, handleEmojiChange, handleIconChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleToggle}>
|
||||
<Popover.Button className={cn("outline-none", buttonClassName)} disabled={disabled}>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
positionerClassName="z-50"
|
||||
className={cn("w-80 overflow-hidden rounded-md border-[0.5px] border-strong bg-surface-1", dropdownClassName)}
|
||||
side={finalSide}
|
||||
align={finalAlign}
|
||||
sideOffset={8}
|
||||
data-prevent-outside-click="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab") {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
handleToggle(false);
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Tabs.Root defaultValue={defaultOpen}>
|
||||
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
cn("rounded-sm border border-subtle bg-layer-1 py-1 text-13", {
|
||||
"bg-surface-1 text-primary": selected,
|
||||
"text-placeholder hover:bg-layer-1/60 hover:text-tertiary": !selected,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Panel key={tab.key} value={tab.key} className="h-80 overflow-hidden overflow-y-auto">
|
||||
{tab.content}
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EmojiPicker } from "frimousse";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
type EmojiRootProps = {
|
||||
onChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
searchDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function EmojiRoot(props: EmojiRootProps) {
|
||||
const { onChange, searchPlaceholder = "Search", searchDisabled = false } = props;
|
||||
const searchWrapperRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const focusInput = () => {
|
||||
const searchWrapper = searchWrapperRef.current;
|
||||
if (searchWrapper) {
|
||||
const inputElement = searchWrapper.querySelector("input");
|
||||
if (inputElement) {
|
||||
inputElement.removeAttribute("disabled");
|
||||
inputElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
focusInput();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EmojiPicker.Root
|
||||
data-slot="emoji-picker"
|
||||
className="isolate flex h-full w-full flex-col rounded-md border-none p-2"
|
||||
onEmojiSelect={(val) => onChange(val.emoji)}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 bg-surface-1 px-1.5 py-2 [&>[data-slot='emoji-picker-search-wrapper']]:flex-grow [&>[data-slot='emoji-picker-search-wrapper']]:p-0">
|
||||
<div ref={searchWrapperRef} data-slot="emoji-picker-search-wrapper" className="p-2">
|
||||
<EmojiPicker.Search
|
||||
placeholder={searchPlaceholder}
|
||||
disabled={searchDisabled}
|
||||
className="block h-full w-full flex-grow-0 rounded-md border-[0.5px] border-subtle bg-transparent p-0 px-3 py-2 text-16 placeholder-(--text-color-placeholder) focus:border-accent-strong focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<EmojiPicker.SkinToneSelector
|
||||
data-slot="emoji-picker-skin-tone-selector"
|
||||
className="hover:bg-accent mx-2 mb-1.5 size-8 flex-shrink-0 rounded-md bg-surface-1 text-16"
|
||||
/>
|
||||
</div>
|
||||
<EmojiPicker.Viewport data-slot="emoji-picker-content" className={cn("relative flex-1 outline-none")}>
|
||||
<EmojiPicker.List
|
||||
data-slot="emoji-picker-list"
|
||||
className={cn("pb-2 select-none")}
|
||||
components={{
|
||||
CategoryHeader: ({ category, ...props }) => (
|
||||
<div
|
||||
data-slot="emoji-picker-list-category-header"
|
||||
className="bg-surface-1 px-3 pb-1.5 text-11 font-medium text-tertiary"
|
||||
{...props}
|
||||
>
|
||||
{category.label}
|
||||
</div>
|
||||
),
|
||||
Row: ({ children, ...props }) => (
|
||||
<div data-slot="emoji-picker-list-row" className="scroll-my-1.5 px-1.5" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Emoji: ({ emoji, ...props }) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={emoji?.label ?? emoji?.emoji}
|
||||
data-slot="emoji-picker-list-emoji"
|
||||
className="data-active:bg-accent flex size-8 items-center justify-center rounded-md text-16"
|
||||
{...props}
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./emoji";
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { TPlacement, TSide, TAlign } from "../utils/placement";
|
||||
|
||||
export const EmojiIconPickerTypes = {
|
||||
EMOJI: "emoji",
|
||||
ICON: "icon",
|
||||
} as const;
|
||||
|
||||
export type TChangeHandlerProps =
|
||||
| {
|
||||
type: typeof EmojiIconPickerTypes.EMOJI;
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: typeof EmojiIconPickerTypes.ICON;
|
||||
value: {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TEmojiIconPickerTypes = typeof EmojiIconPickerTypes.EMOJI | typeof EmojiIconPickerTypes.ICON;
|
||||
|
||||
export type TCustomEmojiPicker = {
|
||||
isOpen: boolean;
|
||||
handleToggle: (value: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
closeOnSelect?: boolean;
|
||||
defaultIconColor?: string;
|
||||
defaultOpen?: TEmojiIconPickerTypes;
|
||||
disabled?: boolean;
|
||||
dropdownClassName?: string;
|
||||
label: React.ReactNode;
|
||||
onChange: (value: TChangeHandlerProps) => void;
|
||||
placement?: TPlacement;
|
||||
searchDisabled?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
iconType?: "material" | "lucide";
|
||||
theme?: "light" | "dark";
|
||||
side?: TSide;
|
||||
align?: TAlign;
|
||||
};
|
||||
|
||||
export type TIconsListProps = {
|
||||
defaultColor: string;
|
||||
onChange: (val: { name: string; color: string }) => void;
|
||||
searchDisabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adjusts the given hex color to ensure it has enough contrast.
|
||||
* @param {string} hex - The hex color code input by the user.
|
||||
* @returns {string} - The adjusted hex color code.
|
||||
*/
|
||||
export const adjustColorForContrast = (hex: string): string => {
|
||||
// Ensure hex color is valid
|
||||
if (!/^#([0-9A-F]{3}){1,2}$/i.test(hex)) {
|
||||
throw new Error("Invalid hex color code");
|
||||
}
|
||||
|
||||
// Convert hex to RGB
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (hex.length === 4) {
|
||||
r = parseInt(hex[1] + hex[1], 16);
|
||||
g = parseInt(hex[2] + hex[2], 16);
|
||||
b = parseInt(hex[3] + hex[3], 16);
|
||||
} else if (hex.length === 7) {
|
||||
r = parseInt(hex[1] + hex[2], 16);
|
||||
g = parseInt(hex[3] + hex[4], 16);
|
||||
b = parseInt(hex[5] + hex[6], 16);
|
||||
}
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
// If the color is too light, darken it
|
||||
if (luminance > 0.5) {
|
||||
r = Math.max(0, r - 50);
|
||||
g = Math.max(0, g - 50);
|
||||
b = Math.max(0, b - 50);
|
||||
}
|
||||
|
||||
// Convert RGB back to hex
|
||||
const toHex = (value: number): string => {
|
||||
const hex = value.toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b55c", "#f2be02", "#e57a00", "#f38e82"];
|
||||
|
||||
/**
|
||||
* Enhanced emoji to decimal conversion that preserves emoji sequences
|
||||
* This function handles complex emoji sequences including skin tone modifiers
|
||||
* @param emoji - The emoji string to convert
|
||||
* @returns Array of decimal Unicode code points
|
||||
*/
|
||||
export function emojiToDecimalEnhanced(emoji: string): number[] {
|
||||
const codePoints: number[] = [];
|
||||
|
||||
// Use Array.from to properly handle multi-byte Unicode characters
|
||||
const characters = Array.from(emoji);
|
||||
|
||||
for (const char of characters) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (codePoint !== undefined) {
|
||||
codePoints.push(codePoint);
|
||||
}
|
||||
}
|
||||
|
||||
return codePoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced decimal to emoji conversion that handles emoji sequences
|
||||
* @param decimals - Array of decimal Unicode code points
|
||||
* @returns The reconstructed emoji string
|
||||
*/
|
||||
export function decimalToEmojiEnhanced(decimals: number[]): string {
|
||||
return decimals.map((decimal) => String.fromCodePoint(decimal)).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts emoji to a string representation for storage
|
||||
* This creates a comma-separated string of decimal values
|
||||
* @param emoji - The emoji string to convert
|
||||
* @returns String representation of decimal values
|
||||
*/
|
||||
export function emojiToString(emoji: string): string {
|
||||
const decimals = emojiToDecimalEnhanced(emoji);
|
||||
return decimals.join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts string representation back to emoji
|
||||
* @param emojiString - Comma-separated string of decimal values
|
||||
* @returns The reconstructed emoji string
|
||||
*/
|
||||
export function stringToEmoji(emojiString: string): string {
|
||||
if (!emojiString) return "";
|
||||
const decimals = emojiString
|
||||
.split("-")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 0x10ffff);
|
||||
try {
|
||||
return decimalToEmojiEnhanced(decimals);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const getEmojiSize = (size: number) => size * 0.9 * 0.0625;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { SearchIcon } from "../../icons";
|
||||
import { cn } from "../../utils/classname";
|
||||
import { adjustColorForContrast, DEFAULT_COLORS } from "../helper";
|
||||
import { LucideIconsList } from "./lucide-root";
|
||||
import { MaterialIconList } from "./material-root";
|
||||
|
||||
type IconRootProps = {
|
||||
onChange: (value: { name: string; color: string }) => void;
|
||||
defaultColor: string;
|
||||
searchDisabled?: boolean;
|
||||
iconType: "material" | "lucide";
|
||||
};
|
||||
|
||||
export function IconRoot(props: IconRootProps) {
|
||||
const { defaultColor, onChange, searchDisabled = false, iconType } = props;
|
||||
// states
|
||||
const [activeColor, setActiveColor] = useState(defaultColor);
|
||||
const [showHexInput, setShowHexInput] = useState(false);
|
||||
const [hexValue, setHexValue] = useState("");
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (DEFAULT_COLORS.includes(defaultColor.toLowerCase() ?? "")) setShowHexInput(false);
|
||||
else {
|
||||
setHexValue(defaultColor?.slice(1, 7) ?? "");
|
||||
setShowHexInput(true);
|
||||
}
|
||||
}, [defaultColor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 flex flex-col bg-surface-1">
|
||||
{!searchDisabled && (
|
||||
<div className="flex w-full items-center px-2 py-[15px]">
|
||||
<div
|
||||
className={cn("relative flex h-10 w-full items-center gap-2 rounded-lg border bg-surface-2 px-[30px]", {
|
||||
"border-accent-strong": isInputFocused,
|
||||
"border-transparent": !isInputFocused,
|
||||
})}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
>
|
||||
<SearchIcon className="absolute bottom-3 left-2.5 h-3.5 w-3.5 text-placeholder" />
|
||||
|
||||
<input
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="block h-full w-full rounded-md border-[0.5px] border-none border-subtle bg-transparent p-0 px-3 py-2 text-16 placeholder-(--text-color-placeholder) focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid h-9 grid-cols-9 items-center justify-items-center gap-2 px-2.5 py-1">
|
||||
{showHexInput ? (
|
||||
<div className="col-span-8 ml-2 flex items-center gap-1 justify-self-stretch">
|
||||
<span
|
||||
className="mr-1 h-4 w-4 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${hexValue}`,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-11 text-tertiary">HEX</span>
|
||||
<span className="-mr-1 flex-shrink-0 text-11 text-secondary">#</span>
|
||||
<input
|
||||
type="text"
|
||||
value={hexValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setHexValue(value);
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`));
|
||||
}}
|
||||
className="block flex-grow rounded-sm border-[0.5px] border-none border-subtle bg-transparent px-3 py-2 pl-0 text-11 text-secondary placeholder-(--text-color-placeholder) ring-0 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
DEFAULT_COLORS.map((curCol) => (
|
||||
<button
|
||||
key={curCol}
|
||||
type="button"
|
||||
className="grid size-5 place-items-center"
|
||||
onClick={() => {
|
||||
setActiveColor(curCol);
|
||||
setHexValue(curCol.slice(1, 7));
|
||||
}}
|
||||
>
|
||||
<span className="h-4 w-4 cursor-pointer rounded-full" style={{ backgroundColor: curCol }} />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("grid h-4 w-4 place-items-center rounded-full border border-transparent", {
|
||||
"border-strong-1": !showHexInput,
|
||||
})}
|
||||
onClick={() => {
|
||||
setShowHexInput((prevData) => !prevData);
|
||||
setHexValue(activeColor.slice(1, 7));
|
||||
}}
|
||||
>
|
||||
{showHexInput ? (
|
||||
<span className="h-4 w-4 rounded-full conical-gradient" />
|
||||
) : (
|
||||
<span className="grid place-items-center text-10 text-tertiary">#</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-6 w-full items-center gap-2 py-1 pr-3 pl-4">
|
||||
<InfoIcon className="h-3 w-3" />
|
||||
<p className="text-11"> Colors will be adjusted to ensure sufficient contrast.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-8 justify-items-center gap-1 px-2.5">
|
||||
{iconType === "material" ? (
|
||||
<MaterialIconList query={query} onChange={onChange} activeColor={activeColor} />
|
||||
) : (
|
||||
<LucideIconsList query={query} onChange={onChange} activeColor={activeColor} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./icon-root";
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { LUCIDE_ICONS_LIST } from "../lucide-icons";
|
||||
|
||||
type LucideIconsListProps = {
|
||||
onChange: (value: { name: string; color: string }) => void;
|
||||
activeColor: string;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function LucideIconsList(props: LucideIconsListProps) {
|
||||
const { query, onChange, activeColor } = props;
|
||||
|
||||
const filteredArray = LUCIDE_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredArray.map((icon) => (
|
||||
<button
|
||||
key={icon.name}
|
||||
type="button"
|
||||
className="grid h-9 w-9 place-items-center rounded-sm text-16 select-none hover:bg-layer-1"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
name: icon.name,
|
||||
color: activeColor,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<icon.element style={{ color: activeColor }} className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
import { MATERIAL_ICONS_LIST } from "../material-icons";
|
||||
|
||||
type MaterialIconListProps = {
|
||||
onChange: (value: { name: string; color: string }) => void;
|
||||
activeColor: string;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function MaterialIconList(props: MaterialIconListProps) {
|
||||
const { query, onChange, activeColor } = props;
|
||||
|
||||
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredArray.map((icon) => (
|
||||
<button
|
||||
key={icon.name}
|
||||
type="button"
|
||||
className="grid h-9 w-9 place-items-center rounded-sm text-16 select-none hover:bg-layer-1"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
name: icon.name,
|
||||
color: activeColor,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isMaterialSymbolsFontLoaded ? (
|
||||
<span style={{ color: activeColor }} className="material-symbols-rounded text-20! leading-5!">
|
||||
{icon.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-5 animate-pulse rounded-sm bg-layer-1" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./emoji-picker";
|
||||
export * from "./helper";
|
||||
export * from "./logo";
|
||||
export * from "./lucide-icons";
|
||||
export * from "./material-icons";
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// Due to some weird issue with the import order, the import of useFontFaceObserver
|
||||
// should be after the imported here rather than some below helper functions as it is in the original file
|
||||
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// plane imports
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
// local imports
|
||||
import { getEmojiSize, stringToEmoji } from "./helper";
|
||||
import { LUCIDE_ICONS_LIST } from "./lucide-icons";
|
||||
|
||||
type Props = {
|
||||
logo?: TLogoProps;
|
||||
size?: number;
|
||||
type?: "lucide" | "material";
|
||||
};
|
||||
|
||||
export function Logo({ logo, size = 16, type = "material" }: Props) {
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: "Material Symbols Rounded",
|
||||
style: "normal",
|
||||
weight: "normal",
|
||||
stretch: "condensed",
|
||||
},
|
||||
]);
|
||||
|
||||
// Reusable loading skeleton
|
||||
const loadingSkeleton = <span style={{ height: size, width: size }} className="rounded-sm bg-layer-1" />;
|
||||
|
||||
// Early returns for loading/empty states
|
||||
if (!logo || !logo.in_use) return loadingSkeleton;
|
||||
|
||||
const { in_use, emoji, icon } = logo;
|
||||
const value = in_use === "emoji" ? emoji?.value : icon?.name;
|
||||
|
||||
if (!value) return loadingSkeleton;
|
||||
|
||||
// Emoji rendering
|
||||
if (in_use === "emoji") {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
fontSize: `${getEmojiSize(size)}rem`,
|
||||
lineHeight: `${getEmojiSize(size)}rem`,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
{stringToEmoji(emoji?.value || "")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Icon rendering
|
||||
if (in_use === "icon") {
|
||||
const color = icon?.color;
|
||||
|
||||
// Lucide icon
|
||||
if (type === "lucide") {
|
||||
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
|
||||
if (!lucideIcon) return null;
|
||||
|
||||
const LucideIconElement = lucideIcon.element;
|
||||
return <LucideIconElement style={{ color, height: size, width: size }} />;
|
||||
}
|
||||
|
||||
if (!isMaterialSymbolsFontLoaded) return loadingSkeleton;
|
||||
|
||||
// Material icon
|
||||
return (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
style={{
|
||||
fontSize: size,
|
||||
color,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Airplay,
|
||||
AlertCircle,
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Anchor,
|
||||
Aperture,
|
||||
Archive,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
Award,
|
||||
BarChart,
|
||||
BarChart2,
|
||||
Battery,
|
||||
BatteryCharging,
|
||||
Bell,
|
||||
BellOff,
|
||||
Book,
|
||||
Bookmark,
|
||||
BookOpen,
|
||||
Box,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
Camera,
|
||||
CameraOff,
|
||||
Cast,
|
||||
Check,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
CloudDrizzle,
|
||||
CloudLightning,
|
||||
CloudOff,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
Code,
|
||||
Codepen,
|
||||
Codesandbox,
|
||||
Coffee,
|
||||
Columns,
|
||||
Command,
|
||||
Compass,
|
||||
Copy,
|
||||
CornerDownLeft,
|
||||
CornerDownRight,
|
||||
CornerLeftDown,
|
||||
CornerLeftUp,
|
||||
CornerRightDown,
|
||||
CornerRightUp,
|
||||
CornerUpLeft,
|
||||
CornerUpRight,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Crop,
|
||||
Crosshair,
|
||||
Database,
|
||||
Delete,
|
||||
Disc,
|
||||
Divide,
|
||||
DivideCircle,
|
||||
DivideSquare,
|
||||
DollarSign,
|
||||
Download,
|
||||
DownloadCloud,
|
||||
Dribbble,
|
||||
Droplet,
|
||||
Edit,
|
||||
Edit2,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Facebook,
|
||||
FastForward,
|
||||
Feather,
|
||||
Figma,
|
||||
File,
|
||||
FileMinus,
|
||||
FilePlus,
|
||||
FileText,
|
||||
Film,
|
||||
Filter,
|
||||
Flag,
|
||||
Folder,
|
||||
FolderMinus,
|
||||
FolderPlus,
|
||||
Framer,
|
||||
Frown,
|
||||
Gift,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
Github,
|
||||
Gitlab,
|
||||
Globe,
|
||||
Grid,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Headphones,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Hexagon,
|
||||
Home,
|
||||
Image,
|
||||
Inbox,
|
||||
Info,
|
||||
Instagram,
|
||||
Italic,
|
||||
Key,
|
||||
Layers,
|
||||
Layout,
|
||||
LifeBuoy,
|
||||
Link,
|
||||
Link2,
|
||||
Linkedin,
|
||||
List,
|
||||
Loader,
|
||||
Lock,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Maximize,
|
||||
Maximize2,
|
||||
Meh,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MicOff,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Minus,
|
||||
MinusCircle,
|
||||
MinusSquare,
|
||||
CircleChevronDown,
|
||||
UsersRound,
|
||||
ToggleLeft,
|
||||
Search,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export const LUCIDE_ICONS_LIST = [
|
||||
{ name: "Activity", element: Activity },
|
||||
{ name: "Airplay", element: Airplay },
|
||||
{ name: "AlertCircle", element: AlertCircle },
|
||||
{ name: "AlertOctagon", element: AlertOctagon },
|
||||
{ name: "AlertTriangle", element: AlertTriangle },
|
||||
{ name: "AlignCenter", element: AlignCenter },
|
||||
{ name: "AlignJustify", element: AlignJustify },
|
||||
{ name: "AlignLeft", element: AlignLeft },
|
||||
{ name: "AlignRight", element: AlignRight },
|
||||
{ name: "Anchor", element: Anchor },
|
||||
{ name: "Aperture", element: Aperture },
|
||||
{ name: "Archive", element: Archive },
|
||||
{ name: "ArrowDown", element: ArrowDown },
|
||||
{ name: "ArrowLeft", element: ArrowLeft },
|
||||
{ name: "ArrowRight", element: ArrowRight },
|
||||
{ name: "ArrowUp", element: ArrowUp },
|
||||
{ name: "AtSign", element: AtSign },
|
||||
{ name: "Award", element: Award },
|
||||
{ name: "BarChart", element: BarChart },
|
||||
{ name: "BarChart2", element: BarChart2 },
|
||||
{ name: "Battery", element: Battery },
|
||||
{ name: "BatteryCharging", element: BatteryCharging },
|
||||
{ name: "Bell", element: Bell },
|
||||
{ name: "BellOff", element: BellOff },
|
||||
{ name: "Book", element: Book },
|
||||
{ name: "Bookmark", element: Bookmark },
|
||||
{ name: "BookOpen", element: BookOpen },
|
||||
{ name: "Box", element: Box },
|
||||
{ name: "Briefcase", element: Briefcase },
|
||||
{ name: "Calendar", element: Calendar },
|
||||
{ name: "Camera", element: Camera },
|
||||
{ name: "CameraOff", element: CameraOff },
|
||||
{ name: "Cast", element: Cast },
|
||||
{ name: "CircleChevronDown", element: CircleChevronDown },
|
||||
{ name: "Check", element: Check },
|
||||
{ name: "CheckCircle", element: CheckCircle },
|
||||
{ name: "CheckSquare", element: CheckSquare },
|
||||
{ name: "ChevronDown", element: ChevronDown },
|
||||
{ name: "ChevronLeft", element: ChevronLeft },
|
||||
{ name: "ChevronRight", element: ChevronRight },
|
||||
{ name: "ChevronUp", element: ChevronUp },
|
||||
{ name: "Clipboard", element: Clipboard },
|
||||
{ name: "Clock", element: Clock },
|
||||
{ name: "Cloud", element: Cloud },
|
||||
{ name: "CloudDrizzle", element: CloudDrizzle },
|
||||
{ name: "CloudLightning", element: CloudLightning },
|
||||
{ name: "CloudOff", element: CloudOff },
|
||||
{ name: "CloudRain", element: CloudRain },
|
||||
{ name: "CloudSnow", element: CloudSnow },
|
||||
{ name: "Code", element: Code },
|
||||
{ name: "Codepen", element: Codepen },
|
||||
{ name: "Codesandbox", element: Codesandbox },
|
||||
{ name: "Coffee", element: Coffee },
|
||||
{ name: "Columns", element: Columns },
|
||||
{ name: "Command", element: Command },
|
||||
{ name: "Compass", element: Compass },
|
||||
{ name: "Copy", element: Copy },
|
||||
{ name: "CornerDownLeft", element: CornerDownLeft },
|
||||
{ name: "CornerDownRight", element: CornerDownRight },
|
||||
{ name: "CornerLeftDown", element: CornerLeftDown },
|
||||
{ name: "CornerLeftUp", element: CornerLeftUp },
|
||||
{ name: "CornerRightDown", element: CornerRightDown },
|
||||
{ name: "CornerRightUp", element: CornerRightUp },
|
||||
{ name: "CornerUpLeft", element: CornerUpLeft },
|
||||
{ name: "CornerUpRight", element: CornerUpRight },
|
||||
{ name: "Cpu", element: Cpu },
|
||||
{ name: "CreditCard", element: CreditCard },
|
||||
{ name: "Crop", element: Crop },
|
||||
{ name: "Crosshair", element: Crosshair },
|
||||
{ name: "Database", element: Database },
|
||||
{ name: "Delete", element: Delete },
|
||||
{ name: "Disc", element: Disc },
|
||||
{ name: "Divide", element: Divide },
|
||||
{ name: "DivideCircle", element: DivideCircle },
|
||||
{ name: "DivideSquare", element: DivideSquare },
|
||||
{ name: "DollarSign", element: DollarSign },
|
||||
{ name: "Download", element: Download },
|
||||
{ name: "DownloadCloud", element: DownloadCloud },
|
||||
{ name: "Dribbble", element: Dribbble },
|
||||
{ name: "Droplet", element: Droplet },
|
||||
{ name: "Edit", element: Edit },
|
||||
{ name: "Edit2", element: Edit2 },
|
||||
{ name: "Edit3", element: Edit3 },
|
||||
{ name: "ExternalLink", element: ExternalLink },
|
||||
{ name: "Eye", element: Eye },
|
||||
{ name: "EyeOff", element: EyeOff },
|
||||
{ name: "Facebook", element: Facebook },
|
||||
{ name: "FastForward", element: FastForward },
|
||||
{ name: "Feather", element: Feather },
|
||||
{ name: "Figma", element: Figma },
|
||||
{ name: "File", element: File },
|
||||
{ name: "FileMinus", element: FileMinus },
|
||||
{ name: "FilePlus", element: FilePlus },
|
||||
{ name: "FileText", element: FileText },
|
||||
{ name: "Film", element: Film },
|
||||
{ name: "Filter", element: Filter },
|
||||
{ name: "Flag", element: Flag },
|
||||
{ name: "Folder", element: Folder },
|
||||
{ name: "FolderMinus", element: FolderMinus },
|
||||
{ name: "FolderPlus", element: FolderPlus },
|
||||
{ name: "Framer", element: Framer },
|
||||
{ name: "Frown", element: Frown },
|
||||
{ name: "Gift", element: Gift },
|
||||
{ name: "GitBranch", element: GitBranch },
|
||||
{ name: "GitCommit", element: GitCommit },
|
||||
{ name: "GitMerge", element: GitMerge },
|
||||
{ name: "GitPullRequest", element: GitPullRequest },
|
||||
{ name: "Github", element: Github },
|
||||
{ name: "Gitlab", element: Gitlab },
|
||||
{ name: "Globe", element: Globe },
|
||||
{ name: "Grid", element: Grid },
|
||||
{ name: "HardDrive", element: HardDrive },
|
||||
{ name: "Hash", element: Hash },
|
||||
{ name: "Headphones", element: Headphones },
|
||||
{ name: "Heart", element: Heart },
|
||||
{ name: "HelpCircle", element: HelpCircle },
|
||||
{ name: "Hexagon", element: Hexagon },
|
||||
{ name: "Home", element: Home },
|
||||
{ name: "Image", element: Image },
|
||||
{ name: "Inbox", element: Inbox },
|
||||
{ name: "Info", element: Info },
|
||||
{ name: "Instagram", element: Instagram },
|
||||
{ name: "Italic", element: Italic },
|
||||
{ name: "Key", element: Key },
|
||||
{ name: "Layers", element: Layers },
|
||||
{ name: "Layout", element: Layout },
|
||||
{ name: "LifeBuoy", element: LifeBuoy },
|
||||
{ name: "Link", element: Link },
|
||||
{ name: "Link2", element: Link2 },
|
||||
{ name: "Linkedin", element: Linkedin },
|
||||
{ name: "List", element: List },
|
||||
{ name: "Loader", element: Loader },
|
||||
{ name: "Lock", element: Lock },
|
||||
{ name: "LogIn", element: LogIn },
|
||||
{ name: "LogOut", element: LogOut },
|
||||
{ name: "Mail", element: Mail },
|
||||
{ name: "Map", element: MapIcon },
|
||||
{ name: "MapPin", element: MapPin },
|
||||
{ name: "Maximize", element: Maximize },
|
||||
{ name: "Maximize2", element: Maximize2 },
|
||||
{ name: "Meh", element: Meh },
|
||||
{ name: "Menu", element: Menu },
|
||||
{ name: "MessageCircle", element: MessageCircle },
|
||||
{ name: "MessageSquare", element: MessageSquare },
|
||||
{ name: "Mic", element: Mic },
|
||||
{ name: "MicOff", element: MicOff },
|
||||
{ name: "Minimize", element: Minimize },
|
||||
{ name: "Minimize2", element: Minimize2 },
|
||||
{ name: "Minus", element: Minus },
|
||||
{ name: "MinusCircle", element: MinusCircle },
|
||||
{ name: "MinusSquare", element: MinusSquare },
|
||||
{ name: "Search", element: Search },
|
||||
{ name: "ToggleLeft", element: ToggleLeft },
|
||||
{ name: "User", element: User },
|
||||
{ name: "UsersRound", element: UsersRound },
|
||||
];
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export const MATERIAL_ICONS_LIST = [
|
||||
{
|
||||
name: "search",
|
||||
},
|
||||
{
|
||||
name: "home",
|
||||
},
|
||||
{
|
||||
name: "menu",
|
||||
},
|
||||
{
|
||||
name: "close",
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
},
|
||||
{
|
||||
name: "done",
|
||||
},
|
||||
{
|
||||
name: "check_circle",
|
||||
},
|
||||
{
|
||||
name: "favorite",
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
},
|
||||
{
|
||||
name: "arrow_back",
|
||||
},
|
||||
{
|
||||
name: "star",
|
||||
},
|
||||
{
|
||||
name: "logout",
|
||||
},
|
||||
{
|
||||
name: "add_circle",
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
},
|
||||
{
|
||||
name: "arrow_drop_down",
|
||||
},
|
||||
{
|
||||
name: "more_vert",
|
||||
},
|
||||
{
|
||||
name: "check",
|
||||
},
|
||||
{
|
||||
name: "check_box",
|
||||
},
|
||||
{
|
||||
name: "toggle_on",
|
||||
},
|
||||
{
|
||||
name: "open_in_new",
|
||||
},
|
||||
{
|
||||
name: "refresh",
|
||||
},
|
||||
{
|
||||
name: "login",
|
||||
},
|
||||
{
|
||||
name: "radio_button_unchecked",
|
||||
},
|
||||
{
|
||||
name: "more_horiz",
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
},
|
||||
{
|
||||
name: "radio_button_checked",
|
||||
},
|
||||
{
|
||||
name: "download",
|
||||
},
|
||||
{
|
||||
name: "remove",
|
||||
},
|
||||
{
|
||||
name: "toggle_off",
|
||||
},
|
||||
{
|
||||
name: "bolt",
|
||||
},
|
||||
{
|
||||
name: "arrow_upward",
|
||||
},
|
||||
{
|
||||
name: "filter_list",
|
||||
},
|
||||
{
|
||||
name: "delete_forever",
|
||||
},
|
||||
{
|
||||
name: "autorenew",
|
||||
},
|
||||
{
|
||||
name: "key",
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
},
|
||||
{
|
||||
name: "add_box",
|
||||
},
|
||||
{
|
||||
name: "block",
|
||||
},
|
||||
{
|
||||
name: "restart_alt",
|
||||
},
|
||||
{
|
||||
name: "menu_open",
|
||||
},
|
||||
{
|
||||
name: "shopping_cart_checkout",
|
||||
},
|
||||
{
|
||||
name: "expand_circle_down",
|
||||
},
|
||||
{
|
||||
name: "backspace",
|
||||
},
|
||||
{
|
||||
name: "undo",
|
||||
},
|
||||
{
|
||||
name: "done_all",
|
||||
},
|
||||
{
|
||||
name: "do_not_disturb_on",
|
||||
},
|
||||
{
|
||||
name: "open_in_full",
|
||||
},
|
||||
{
|
||||
name: "double_arrow",
|
||||
},
|
||||
{
|
||||
name: "sync_alt",
|
||||
},
|
||||
{
|
||||
name: "zoom_in",
|
||||
},
|
||||
{
|
||||
name: "done_outline",
|
||||
},
|
||||
{
|
||||
name: "drag_indicator",
|
||||
},
|
||||
{
|
||||
name: "fullscreen",
|
||||
},
|
||||
{
|
||||
name: "star_half",
|
||||
},
|
||||
{
|
||||
name: "settings_accessibility",
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
},
|
||||
{
|
||||
name: "exit_to_app",
|
||||
},
|
||||
{
|
||||
name: "unfold_more",
|
||||
},
|
||||
{
|
||||
name: "library_add",
|
||||
},
|
||||
{
|
||||
name: "cached",
|
||||
},
|
||||
{
|
||||
name: "select_check_box",
|
||||
},
|
||||
{
|
||||
name: "terminal",
|
||||
},
|
||||
{
|
||||
name: "change_circle",
|
||||
},
|
||||
{
|
||||
name: "disabled_by_default",
|
||||
},
|
||||
{
|
||||
name: "swap_horiz",
|
||||
},
|
||||
{
|
||||
name: "swap_vert",
|
||||
},
|
||||
{
|
||||
name: "app_registration",
|
||||
},
|
||||
{
|
||||
name: "download_for_offline",
|
||||
},
|
||||
{
|
||||
name: "close_fullscreen",
|
||||
},
|
||||
{
|
||||
name: "file_open",
|
||||
},
|
||||
{
|
||||
name: "minimize",
|
||||
},
|
||||
{
|
||||
name: "open_with",
|
||||
},
|
||||
{
|
||||
name: "dataset",
|
||||
},
|
||||
{
|
||||
name: "add_task",
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
},
|
||||
{
|
||||
name: "keyboard_voice",
|
||||
},
|
||||
{
|
||||
name: "create_new_folder",
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
},
|
||||
{
|
||||
name: "settings_applications",
|
||||
},
|
||||
{
|
||||
name: "compare_arrows",
|
||||
},
|
||||
{
|
||||
name: "redo",
|
||||
},
|
||||
{
|
||||
name: "zoom_out",
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
},
|
||||
{
|
||||
name: "html",
|
||||
},
|
||||
{
|
||||
name: "token",
|
||||
},
|
||||
{
|
||||
name: "switch_access_shortcut",
|
||||
},
|
||||
{
|
||||
name: "fullscreen_exit",
|
||||
},
|
||||
{
|
||||
name: "sort_by_alpha",
|
||||
},
|
||||
{
|
||||
name: "delete_sweep",
|
||||
},
|
||||
{
|
||||
name: "indeterminate_check_box",
|
||||
},
|
||||
{
|
||||
name: "view_timeline",
|
||||
},
|
||||
{
|
||||
name: "settings_backup_restore",
|
||||
},
|
||||
{
|
||||
name: "arrow_drop_down_circle",
|
||||
},
|
||||
{
|
||||
name: "assistant_navigation",
|
||||
},
|
||||
{
|
||||
name: "sync_problem",
|
||||
},
|
||||
{
|
||||
name: "clear_all",
|
||||
},
|
||||
{
|
||||
name: "density_medium",
|
||||
},
|
||||
{
|
||||
name: "heart_plus",
|
||||
},
|
||||
{
|
||||
name: "filter_alt_off",
|
||||
},
|
||||
{
|
||||
name: "expand",
|
||||
},
|
||||
{
|
||||
name: "subdirectory_arrow_right",
|
||||
},
|
||||
{
|
||||
name: "download_done",
|
||||
},
|
||||
{
|
||||
name: "arrow_outward",
|
||||
},
|
||||
{
|
||||
name: "123",
|
||||
},
|
||||
{
|
||||
name: "swipe_left",
|
||||
},
|
||||
{
|
||||
name: "auto_mode",
|
||||
},
|
||||
{
|
||||
name: "saved_search",
|
||||
},
|
||||
{
|
||||
name: "place_item",
|
||||
},
|
||||
{
|
||||
name: "system_update_alt",
|
||||
},
|
||||
{
|
||||
name: "javascript",
|
||||
},
|
||||
{
|
||||
name: "search_off",
|
||||
},
|
||||
{
|
||||
name: "output",
|
||||
},
|
||||
{
|
||||
name: "select_all",
|
||||
},
|
||||
{
|
||||
name: "fit_screen",
|
||||
},
|
||||
{
|
||||
name: "swipe_up",
|
||||
},
|
||||
{
|
||||
name: "dynamic_form",
|
||||
},
|
||||
{
|
||||
name: "hide_source",
|
||||
},
|
||||
{
|
||||
name: "swipe_right",
|
||||
},
|
||||
{
|
||||
name: "switch_access_shortcut_add",
|
||||
},
|
||||
{
|
||||
name: "browse_gallery",
|
||||
},
|
||||
{
|
||||
name: "css",
|
||||
},
|
||||
{
|
||||
name: "density_small",
|
||||
},
|
||||
{
|
||||
name: "assistant_direction",
|
||||
},
|
||||
{
|
||||
name: "check_small",
|
||||
},
|
||||
{
|
||||
name: "youtube_searched_for",
|
||||
},
|
||||
{
|
||||
name: "move_up",
|
||||
},
|
||||
{
|
||||
name: "swap_horizontal_circle",
|
||||
},
|
||||
{
|
||||
name: "data_thresholding",
|
||||
},
|
||||
{
|
||||
name: "install_mobile",
|
||||
},
|
||||
{
|
||||
name: "move_down",
|
||||
},
|
||||
{
|
||||
name: "dataset_linked",
|
||||
},
|
||||
{
|
||||
name: "keyboard_command_key",
|
||||
},
|
||||
{
|
||||
name: "view_kanban",
|
||||
},
|
||||
{
|
||||
name: "swipe_down",
|
||||
},
|
||||
{
|
||||
name: "key_off",
|
||||
},
|
||||
{
|
||||
name: "transcribe",
|
||||
},
|
||||
{
|
||||
name: "send_time_extension",
|
||||
},
|
||||
{
|
||||
name: "swipe_down_alt",
|
||||
},
|
||||
{
|
||||
name: "swipe_left_alt",
|
||||
},
|
||||
{
|
||||
name: "swipe_right_alt",
|
||||
},
|
||||
{
|
||||
name: "swipe_up_alt",
|
||||
},
|
||||
{
|
||||
name: "keyboard_option_key",
|
||||
},
|
||||
{
|
||||
name: "cycle",
|
||||
},
|
||||
{
|
||||
name: "rebase",
|
||||
},
|
||||
{
|
||||
name: "rebase_edit",
|
||||
},
|
||||
{
|
||||
name: "empty_dashboard",
|
||||
},
|
||||
{
|
||||
name: "magic_exchange",
|
||||
},
|
||||
{
|
||||
name: "acute",
|
||||
},
|
||||
{
|
||||
name: "point_scan",
|
||||
},
|
||||
{
|
||||
name: "step_into",
|
||||
},
|
||||
{
|
||||
name: "cheer",
|
||||
},
|
||||
{
|
||||
name: "emoticon",
|
||||
},
|
||||
{
|
||||
name: "explosion",
|
||||
},
|
||||
{
|
||||
name: "water_bottle",
|
||||
},
|
||||
{
|
||||
name: "weather_hail",
|
||||
},
|
||||
{
|
||||
name: "syringe",
|
||||
},
|
||||
{
|
||||
name: "pill",
|
||||
},
|
||||
{
|
||||
name: "genetics",
|
||||
},
|
||||
{
|
||||
name: "allergy",
|
||||
},
|
||||
{
|
||||
name: "medical_mask",
|
||||
},
|
||||
{
|
||||
name: "body_fat",
|
||||
},
|
||||
{
|
||||
name: "barefoot",
|
||||
},
|
||||
{
|
||||
name: "infrared",
|
||||
},
|
||||
{
|
||||
name: "wrist",
|
||||
},
|
||||
{
|
||||
name: "metabolism",
|
||||
},
|
||||
{
|
||||
name: "conditions",
|
||||
},
|
||||
{
|
||||
name: "taunt",
|
||||
},
|
||||
{
|
||||
name: "altitude",
|
||||
},
|
||||
{
|
||||
name: "tibia",
|
||||
},
|
||||
{
|
||||
name: "footprint",
|
||||
},
|
||||
{
|
||||
name: "eyeglasses",
|
||||
},
|
||||
{
|
||||
name: "man_3",
|
||||
},
|
||||
{
|
||||
name: "woman_2",
|
||||
},
|
||||
{
|
||||
name: "rheumatology",
|
||||
},
|
||||
{
|
||||
name: "tornado",
|
||||
},
|
||||
{
|
||||
name: "landslide",
|
||||
},
|
||||
{
|
||||
name: "foggy",
|
||||
},
|
||||
{
|
||||
name: "severe_cold",
|
||||
},
|
||||
{
|
||||
name: "tsunami",
|
||||
},
|
||||
{
|
||||
name: "vape_free",
|
||||
},
|
||||
{
|
||||
name: "sign_language",
|
||||
},
|
||||
{
|
||||
name: "emoji_symbols",
|
||||
},
|
||||
{
|
||||
name: "clear_night",
|
||||
},
|
||||
{
|
||||
name: "emoji_food_beverage",
|
||||
},
|
||||
{
|
||||
name: "hive",
|
||||
},
|
||||
{
|
||||
name: "thunderstorm",
|
||||
},
|
||||
{
|
||||
name: "communication",
|
||||
},
|
||||
{
|
||||
name: "rocket",
|
||||
},
|
||||
{
|
||||
name: "pets",
|
||||
},
|
||||
{
|
||||
name: "public",
|
||||
},
|
||||
{
|
||||
name: "quiz",
|
||||
},
|
||||
{
|
||||
name: "mood",
|
||||
},
|
||||
{
|
||||
name: "gavel",
|
||||
},
|
||||
{
|
||||
name: "eco",
|
||||
},
|
||||
{
|
||||
name: "diamond",
|
||||
},
|
||||
{
|
||||
name: "forest",
|
||||
},
|
||||
{
|
||||
name: "rainy",
|
||||
},
|
||||
{
|
||||
name: "skull",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { stringToEmoji } from "../emoji-icon-picker";
|
||||
import type { EmojiReactionType } from "./emoji-reaction";
|
||||
import { EmojiReactionGroup } from "./emoji-reaction";
|
||||
import { EmojiReactionPicker } from "./emoji-reaction-picker";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Emoji/EmojiReactionPicker",
|
||||
component: EmojiReactionPicker,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof EmojiReactionPicker>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Pick Emoji",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedEmoji}
|
||||
closeOnSelect
|
||||
label={
|
||||
<span className="flex size-8 items-center justify-center rounded-md px-2 text-18">
|
||||
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-6 text-primary" />}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">Selected: {selectedEmoji}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomLabel: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Add Reaction",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedEmoji}
|
||||
closeOnSelect
|
||||
label={
|
||||
<button className="flex items-center gap-2 rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
|
||||
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-4 w-4" />}
|
||||
<span className="text-13">Add Reaction</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineReactions: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Add",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||
{ emoji: "👍", count: 3, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||
{ emoji: "❤️", count: 2, reacted: true, users: ["You", "David"] },
|
||||
]);
|
||||
|
||||
const handleReactionAdd = (emoji: string) => {
|
||||
setReactions((prev) => {
|
||||
const existing = prev.find((r) => r.emoji === emoji);
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, reacted: true } : r));
|
||||
}
|
||||
return [...prev, { emoji, count: 1, reacted: true, users: ["You"] }];
|
||||
});
|
||||
};
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactions((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
reacted: !r.reacted,
|
||||
count: r.reacted ? r.count - 1 : r.count + 1,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<EmojiReactionGroup
|
||||
reactions={reactions}
|
||||
onReactionClick={handleReactionClick}
|
||||
onAddReaction={() => setIsOpen(true)}
|
||||
showAddButton={false}
|
||||
/>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleReactionAdd}
|
||||
closeOnSelect
|
||||
label={
|
||||
<button className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-dashed border-strong bg-surface-1 text-placeholder transition-all duration-200 hover:border-accent-strong hover:bg-accent-primary/5 hover:text-accent-primary">
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentPlacements: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Placements",
|
||||
},
|
||||
render() {
|
||||
const [isOpen1, setIsOpen1] = useState(false);
|
||||
const [isOpen2, setIsOpen2] = useState(false);
|
||||
const [isOpen3, setIsOpen3] = useState(false);
|
||||
const [isOpen4, setIsOpen4] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Bottom Start:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen1}
|
||||
handleToggle={setIsOpen1}
|
||||
onChange={() => {}}
|
||||
placement="bottom-start"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Bottom End:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen2}
|
||||
handleToggle={setIsOpen2}
|
||||
onChange={() => {}}
|
||||
placement="bottom-end"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Top Start:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen3}
|
||||
handleToggle={setIsOpen3}
|
||||
onChange={() => {}}
|
||||
placement="top-start"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 text-13">Top End:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen4}
|
||||
handleToggle={setIsOpen4}
|
||||
onChange={() => {}}
|
||||
placement="top-end"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchDisabled: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "No Search",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedEmoji}
|
||||
closeOnSelect
|
||||
searchDisabled
|
||||
label={
|
||||
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
|
||||
No Search
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Custom Search",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={setSelectedEmoji}
|
||||
closeOnSelect
|
||||
searchPlaceholder="Find your emoji..."
|
||||
label={
|
||||
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
|
||||
Custom Search
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseOnSelectDisabled: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Select Multiple",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedEmojis, setSelectedEmojis] = useState<string[]>([]);
|
||||
|
||||
const handleChange = (emoji: string) => {
|
||||
setSelectedEmojis((prev) => [...prev, emoji]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleChange}
|
||||
closeOnSelect={false}
|
||||
label={
|
||||
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
|
||||
Select Multiple (Stays Open)
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="rounded-sm bg-layer-1 px-3 py-1.5 text-13 hover:bg-surface-2"
|
||||
onClick={() => setSelectedEmojis([])}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{selectedEmojis.length > 0 && (
|
||||
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
|
||||
<div className="mb-2 font-medium">Selected ({selectedEmojis.length}):</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEmojis.map((emoji, idx) => (
|
||||
<span key={idx} className="text-18">
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InMessageContext: Story = {
|
||||
args: {
|
||||
isOpen: false,
|
||||
handleToggle: () => {},
|
||||
onChange: () => {},
|
||||
label: "Message",
|
||||
},
|
||||
render() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||
]);
|
||||
|
||||
const handleReactionAdd = (emoji: string) => {
|
||||
setReactions((prev) => {
|
||||
const existing = prev.find((r) => r.emoji === emoji);
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, reacted: true } : r));
|
||||
}
|
||||
return [...prev, { emoji, count: 1, reacted: true, users: ["You"] }];
|
||||
});
|
||||
};
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactions((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
reacted: !r.reacted,
|
||||
count: r.reacted ? r.count - 1 : r.count + 1,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-3 rounded-lg border border-subtle p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-primary text-13 text-on-color">
|
||||
AB
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-13 font-medium">Alice Brown</div>
|
||||
<div className="mt-1 text-13 text-tertiary">
|
||||
Just finished the design for the new dashboard! Would love to hear your thoughts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<EmojiReactionGroup reactions={reactions} onReactionClick={handleReactionClick} showAddButton={false} />
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleReactionAdd}
|
||||
closeOnSelect
|
||||
label={
|
||||
<button className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-dashed border-strong bg-surface-1 text-placeholder transition-all duration-200 hover:border-accent-strong hover:bg-accent-primary/5 hover:text-accent-primary">
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { EmojiRoot } from "../emoji-icon-picker/emoji/emoji";
|
||||
import { emojiToString } from "../emoji-icon-picker/helper";
|
||||
import { Popover } from "../popover";
|
||||
import { cn } from "../utils/classname";
|
||||
import { convertPlacementToSideAndAlign } from "../utils/placement";
|
||||
import type { TPlacement, TSide, TAlign } from "../utils/placement";
|
||||
|
||||
export interface EmojiReactionPickerProps {
|
||||
isOpen: boolean;
|
||||
handleToggle: (value: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
closeOnSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
dropdownClassName?: string;
|
||||
label: React.ReactNode;
|
||||
onChange: (emoji: string) => void;
|
||||
placement?: TPlacement;
|
||||
searchDisabled?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
side?: TSide;
|
||||
align?: TAlign;
|
||||
}
|
||||
|
||||
export function EmojiReactionPicker(props: EmojiReactionPickerProps) {
|
||||
const {
|
||||
isOpen,
|
||||
handleToggle,
|
||||
buttonClassName,
|
||||
closeOnSelect = true,
|
||||
disabled = false,
|
||||
dropdownClassName,
|
||||
label,
|
||||
onChange,
|
||||
placement = "bottom-start",
|
||||
searchDisabled = false,
|
||||
searchPlaceholder = "Search",
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
} = props;
|
||||
|
||||
// side and align calculations
|
||||
const { finalSide, finalAlign } = useMemo(() => {
|
||||
if (placement) {
|
||||
const converted = convertPlacementToSideAndAlign(placement);
|
||||
return { finalSide: converted.side, finalAlign: converted.align };
|
||||
}
|
||||
return { finalSide: side, finalAlign: align };
|
||||
}, [placement, side, align]);
|
||||
|
||||
const handleEmojiChange = useCallback(
|
||||
(value: string) => {
|
||||
const emoji = emojiToString(value);
|
||||
onChange(emoji);
|
||||
if (closeOnSelect) handleToggle(false);
|
||||
},
|
||||
[onChange, closeOnSelect, handleToggle]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleToggle}>
|
||||
<Popover.Button className={cn("outline-none", buttonClassName)} disabled={disabled}>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
positionerClassName="z-50"
|
||||
className={cn("w-80 overflow-hidden rounded-md border-[0.5px] border-strong bg-surface-1", dropdownClassName)}
|
||||
side={finalSide}
|
||||
align={finalAlign}
|
||||
sideOffset={8}
|
||||
data-prevent-outside-click="true"
|
||||
>
|
||||
<div className="h-80 overflow-hidden overflow-y-auto">
|
||||
<EmojiRoot
|
||||
onChange={handleEmojiChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchDisabled={searchDisabled}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { EmojiReactionType } from "./emoji-reaction";
|
||||
import { EmojiReaction, EmojiReactionGroup, EmojiReactionButton } from "./emoji-reaction";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Emoji/EmojiReaction",
|
||||
component: EmojiReaction,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof EmojiReaction>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Single: Story = {
|
||||
args: {
|
||||
emoji: "👍",
|
||||
count: 5,
|
||||
reacted: false,
|
||||
users: ["Alice", "Bob", "Charlie"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Reacted: Story = {
|
||||
args: {
|
||||
emoji: "❤️",
|
||||
count: 12,
|
||||
reacted: true,
|
||||
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
emoji: "👍",
|
||||
count: 0,
|
||||
},
|
||||
render() {
|
||||
const [reacted, setReacted] = useState(false);
|
||||
const [count, setCount] = useState(5);
|
||||
|
||||
const handleClick = () => {
|
||||
setReacted(!reacted);
|
||||
setCount(reacted ? count - 1 : count + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<EmojiReaction
|
||||
emoji="👍"
|
||||
count={count}
|
||||
reacted={reacted}
|
||||
users={["Alice", "Bob", "Charlie"]}
|
||||
onReactionClick={handleClick}
|
||||
/>
|
||||
<p className="text-13 text-placeholder">Click to toggle reaction</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTooltip: Story = {
|
||||
args: {
|
||||
emoji: "🎉",
|
||||
count: 8,
|
||||
reacted: false,
|
||||
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank", "Grace", "Henry"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutCount: Story = {
|
||||
args: {
|
||||
emoji: "🔥",
|
||||
count: 0,
|
||||
reacted: false,
|
||||
showCount: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleReactions: Story = {
|
||||
args: {
|
||||
emoji: "👍",
|
||||
count: 0,
|
||||
},
|
||||
render() {
|
||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||
{ emoji: "❤️", count: 12, reacted: true, users: ["David", "Emma", "Frank"] },
|
||||
{ emoji: "🎉", count: 3, reacted: false, users: ["Grace"] },
|
||||
]);
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactions((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
reacted: !r.reacted,
|
||||
count: r.reacted ? r.count - 1 : r.count + 1,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{reactions.map((reaction) => (
|
||||
<EmojiReaction
|
||||
key={reaction.emoji}
|
||||
emoji={reaction.emoji}
|
||||
count={reaction.count}
|
||||
reacted={reaction.reacted}
|
||||
users={reaction.users}
|
||||
onReactionClick={handleReactionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AddButton: Story = {
|
||||
args: {
|
||||
emoji: "➕",
|
||||
count: 0,
|
||||
},
|
||||
render() {
|
||||
const handleAdd = () => {
|
||||
alert("Add reaction clicked");
|
||||
};
|
||||
|
||||
return <EmojiReactionButton onAddReaction={handleAdd} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const ReactionGroup: Story = {
|
||||
args: {
|
||||
emoji: "👍",
|
||||
count: 0,
|
||||
},
|
||||
render() {
|
||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||
{ emoji: "❤️", count: 12, reacted: true, users: ["David", "Emma", "Frank"] },
|
||||
{ emoji: "🎉", count: 3, reacted: false, users: ["Grace"] },
|
||||
{ emoji: "🔥", count: 8, reacted: false, users: ["Henry", "Ivy"] },
|
||||
]);
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactions((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
reacted: !r.reacted,
|
||||
count: r.reacted ? r.count - 1 : r.count + 1,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddReaction = () => {
|
||||
alert("Add reaction clicked");
|
||||
};
|
||||
|
||||
return (
|
||||
<EmojiReactionGroup
|
||||
reactions={reactions}
|
||||
onReactionClick={handleReactionClick}
|
||||
onAddReaction={handleAddReaction}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InMessageContext: Story = {
|
||||
args: {
|
||||
emoji: "👍",
|
||||
count: 0,
|
||||
},
|
||||
render() {
|
||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||
{ emoji: "❤️", count: 2, reacted: true, users: ["You", "David"] },
|
||||
]);
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactions((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
reacted: !r.reacted,
|
||||
count: r.reacted ? r.count - 1 : r.count + 1,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-3 rounded-lg border border-subtle p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-primary text-13 text-on-color">
|
||||
AB
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-13 font-medium">Alice Brown</div>
|
||||
<div className="mt-1 text-13 text-tertiary">
|
||||
Hey everyone! Just wanted to share some exciting news about our project launch next week!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmojiReactionGroup reactions={reactions} onReactionClick={handleReactionClick} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyUsers: Story = {
|
||||
args: {
|
||||
emoji: "🎉",
|
||||
count: 47,
|
||||
reacted: true,
|
||||
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank", "Grace", "Henry", "Ivy", "Jack", "Kate", "Liam"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatedCounter } from "../animated-counter";
|
||||
import { stringToEmoji } from "../emoji-icon-picker";
|
||||
import { AddReactionIcon } from "../icons";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { cn } from "../utils";
|
||||
import { IconButton } from "../icon-button";
|
||||
|
||||
export interface EmojiReactionType {
|
||||
emoji: string;
|
||||
count: number;
|
||||
reacted?: boolean;
|
||||
users?: string[];
|
||||
}
|
||||
|
||||
export interface EmojiReactionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
emoji: string;
|
||||
count: number;
|
||||
reacted?: boolean;
|
||||
users?: string[];
|
||||
onReactionClick?: (emoji: string) => void;
|
||||
className?: string;
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
reactions: EmojiReactionType[];
|
||||
onReactionClick?: (emoji: string) => void;
|
||||
onAddReaction?: () => void;
|
||||
className?: string;
|
||||
showAddButton?: boolean;
|
||||
maxDisplayUsers?: number;
|
||||
}
|
||||
|
||||
export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
onAddReaction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EmojiReaction = React.forwardRef(function EmojiReaction(
|
||||
{
|
||||
emoji,
|
||||
count,
|
||||
reacted = false,
|
||||
users = [],
|
||||
onReactionClick,
|
||||
className,
|
||||
showCount = true,
|
||||
...props
|
||||
}: EmojiReactionProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
const handleClick = () => {
|
||||
onReactionClick?.(emoji);
|
||||
};
|
||||
|
||||
const tooltipContent = React.useMemo(() => {
|
||||
if (!users.length) return null;
|
||||
|
||||
const displayUsers = users.slice(0, 5);
|
||||
const remainingCount = users.length - displayUsers.length;
|
||||
|
||||
return (
|
||||
<div className="text-11">
|
||||
<div className="mb-1 font-medium">{stringToEmoji(emoji)}</div>
|
||||
<div>
|
||||
{displayUsers.join(", ")}
|
||||
{remainingCount > 0 && ` and ${remainingCount} more`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [emoji, users]);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded-full border px-1.5 text-11 transition-all duration-200",
|
||||
reacted
|
||||
? "border-accent-strong bg-accent-primary/10 text-accent-primary"
|
||||
: "border-subtle bg-surface-1 text-tertiary hover:border-strong hover:bg-surface-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="leading-unset text-14">{emoji}</span>
|
||||
{showCount && count > 0 && <AnimatedCounter count={count} size="sm" className="text-11 leading-normal" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tooltipContent && users.length > 0) {
|
||||
return <Tooltip tooltipContent={tooltipContent}>{button}</Tooltip>;
|
||||
}
|
||||
|
||||
return button;
|
||||
});
|
||||
|
||||
const EmojiReactionButton = React.forwardRef(function EmojiReactionButton(
|
||||
{ onAddReaction, className, ...props }: EmojiReactionButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
return (
|
||||
<Tooltip tooltipContent="Add reaction">
|
||||
<IconButton
|
||||
ref={ref}
|
||||
icon={AddReactionIcon}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAddReaction}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const EmojiReactionGroup = React.forwardRef(function EmojiReactionGroup(
|
||||
{
|
||||
reactions,
|
||||
onReactionClick,
|
||||
onAddReaction,
|
||||
className,
|
||||
showAddButton = true,
|
||||
maxDisplayUsers = 5,
|
||||
...props
|
||||
}: EmojiReactionGroupProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-wrap items-center gap-2", className)} {...props}>
|
||||
{reactions.map((reaction, index) => (
|
||||
<EmojiReaction
|
||||
key={`${reaction.emoji}-${index}`}
|
||||
emoji={reaction.emoji}
|
||||
count={reaction.count}
|
||||
reacted={reaction.reacted}
|
||||
users={reaction.users?.slice(0, maxDisplayUsers)}
|
||||
onReactionClick={onReactionClick}
|
||||
/>
|
||||
))}
|
||||
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojiReaction.displayName = "EmojiReaction";
|
||||
EmojiReactionButton.displayName = "EmojiReactionButton";
|
||||
EmojiReactionGroup.displayName = "EmojiReactionGroup";
|
||||
|
||||
export { EmojiReaction, EmojiReactionButton, EmojiReactionGroup };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { EmojiReaction, EmojiReactionGroup, EmojiReactionButton } from "./emoji-reaction";
|
||||
export type {
|
||||
EmojiReactionProps,
|
||||
EmojiReactionGroupProps,
|
||||
EmojiReactionButtonProps,
|
||||
EmojiReactionType,
|
||||
} from "./emoji-reaction";
|
||||
|
||||
export { EmojiReactionPicker } from "./emoji-reaction-picker";
|
||||
export type { EmojiReactionPickerProps } from "./emoji-reaction-picker";
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { HorizontalStackAssetsMap } from "./assets/horizontal-stack/constant";
|
||||
import { IllustrationMap } from "./assets/illustration/constant";
|
||||
import { VerticalStackAssetsMap } from "./assets/vertical-stack/constant";
|
||||
|
||||
// Meta for asset showcase
|
||||
const meta: Meta = {
|
||||
title: "Components/EmptyState/Assets Showcase",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component: "Visual catalog of all available empty state assets organized by type.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const HorizontalStackAssets: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Horizontal stack assets designed for compact empty states. These are optimized for smaller, inline empty state scenarios.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Horizontal Stack Assets</h2>
|
||||
<p className="text-13 text-tertiary">Used primarily in EmptyStateCompact component</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-6">
|
||||
{HorizontalStackAssetsMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-3 rounded-lg border border-subtle bg-surface-1 p-6 sm:col-span-4 lg:col-span-3"
|
||||
>
|
||||
<div className="flex h-24 w-24 items-center justify-center">{item.asset}</div>
|
||||
<p className="text-center text-11 font-medium text-secondary">{item.title}</p>
|
||||
<code className="rounded-sm bg-layer-1 px-2 py-1 text-11 text-tertiary">
|
||||
{item.title.toLowerCase().replace(/\s+/g, "-")}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const VerticalStackAssets: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Vertical stack assets designed for detailed empty states. These are larger and more prominent, suitable for feature-specific empty states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Vertical Stack Assets</h2>
|
||||
<p className="text-13 text-tertiary">Used primarily in EmptyStateDetailed component</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-6">
|
||||
{VerticalStackAssetsMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-3 rounded-lg border border-subtle bg-surface-1 p-6 sm:col-span-4 lg:col-span-3"
|
||||
>
|
||||
<div className="flex h-32 w-32 items-center justify-center">{item.asset}</div>
|
||||
<p className="text-center text-11 font-medium text-secondary">
|
||||
{item.title.replace(/VerticalStackIllustration$/, "")}
|
||||
</p>
|
||||
<code className="rounded-sm bg-layer-1 px-2 py-1 text-11 text-tertiary">
|
||||
{item.title
|
||||
.replace(/VerticalStackIllustration$/, "")
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.toLowerCase()
|
||||
.slice(1)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const IllustrationAssets: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Illustration assets available for both compact and detailed empty states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Illustration Assets</h2>
|
||||
<p className="text-13 text-tertiary">Available in both EmptyStateCompact and EmptyStateDetailed</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-6">
|
||||
{IllustrationMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-3 rounded-lg border border-subtle bg-surface-1 p-6 sm:col-span-4 lg:col-span-3"
|
||||
>
|
||||
<div className="flex h-24 w-24 items-center justify-center">{item.asset}</div>
|
||||
<p className="text-center text-11 font-medium text-secondary">{item.title}</p>
|
||||
<code className="rounded-sm bg-layer-1 px-2 py-1 text-11 text-tertiary">{item.title.toLowerCase()}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllAssets: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Complete catalog of all available empty state assets.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="space-y-12 p-8">
|
||||
{/* Horizontal Stack */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Horizontal Stack Assets</h2>
|
||||
<p className="text-13 text-tertiary">For EmptyStateCompact - {HorizontalStackAssetsMap.length} assets</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-4">
|
||||
{HorizontalStackAssetsMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-2 rounded-sm border border-subtle bg-surface-1 p-4 sm:col-span-3 lg:col-span-2"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center">{item.asset}</div>
|
||||
<code className="text-10 text-placeholder">{item.title.toLowerCase().replace(/\s+/g, "-")}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Stack */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Vertical Stack Assets</h2>
|
||||
<p className="text-13 text-tertiary">For EmptyStateDetailed - {VerticalStackAssetsMap.length} assets</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-4">
|
||||
{VerticalStackAssetsMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-2 rounded-sm border border-subtle bg-surface-1 p-4 sm:col-span-3 lg:col-span-2"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center">{item.asset}</div>
|
||||
<code className="text-center text-10 text-placeholder">
|
||||
{item.title
|
||||
.replace(/VerticalStackIllustration$/, "")
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.toLowerCase()
|
||||
.slice(1)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Illustrations */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-18 font-semibold text-primary">Illustration Assets</h2>
|
||||
<p className="text-13 text-tertiary">For both components - {IllustrationMap.length} assets</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-12 gap-4">
|
||||
{IllustrationMap.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="col-span-6 flex flex-col items-center justify-center gap-2 rounded-sm border border-subtle bg-surface-1 p-4 sm:col-span-3 lg:col-span-2"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center">{item.asset}</div>
|
||||
<code className="text-10 text-placeholder">{item.title.toLowerCase()}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type {
|
||||
CompactAssetType,
|
||||
DetailedAssetType,
|
||||
HorizontalStackAssetType,
|
||||
IllustrationAssetType,
|
||||
VerticalStackAssetType,
|
||||
} from "./asset-types";
|
||||
import {
|
||||
CustomerHorizontalStackIllustration,
|
||||
EpicHorizontalStackIllustration,
|
||||
EstimateHorizontalStackIllustration,
|
||||
ExportHorizontalStackIllustration,
|
||||
IntakeHorizontalStackIllustration,
|
||||
LabelHorizontalStackIllustration,
|
||||
LinkHorizontalStackIllustration,
|
||||
MembersHorizontalStackIllustration,
|
||||
NoteHorizontalStackIllustration,
|
||||
PriorityHorizontalStackIllustration,
|
||||
ProjectHorizontalStackIllustration,
|
||||
SettingsHorizontalStackIllustration,
|
||||
StateHorizontalStackIllustration,
|
||||
TemplateHorizontalStackIllustration,
|
||||
TokenHorizontalStackIllustration,
|
||||
UnknownHorizontalStackIllustration,
|
||||
UpdateHorizontalStackIllustration,
|
||||
WebhookHorizontalStackIllustration,
|
||||
WorkItemHorizontalStackIllustration,
|
||||
WorklogHorizontalStackIllustration,
|
||||
} from "./horizontal-stack";
|
||||
import { InboxIllustration, SearchIllustration } from "./illustration";
|
||||
import {
|
||||
ArchivedCycleVerticalStackIllustration,
|
||||
ArchivedModuleVerticalStackIllustration,
|
||||
ArchivedWorkItemVerticalStackIllustration,
|
||||
ChangelogVerticalStackIllustration,
|
||||
CustomerVerticalStackIllustration,
|
||||
CycleVerticalStackIllustration,
|
||||
DashboardVerticalStackIllustration,
|
||||
DraftVerticalStackIllustration,
|
||||
EpicVerticalStackIllustration,
|
||||
Error404VerticalStackIllustration,
|
||||
InitiativeVerticalStackIllustration,
|
||||
InvalidLinkVerticalStackIllustration,
|
||||
ModuleVerticalStackIllustration,
|
||||
NoAccessVerticalStackIllustration,
|
||||
PageVerticalStackIllustration,
|
||||
ProjectVerticalStackIllustration,
|
||||
ServerErrorVerticalStackIllustration,
|
||||
TeamspaceVerticalStackIllustration,
|
||||
ViewVerticalStackIllustration,
|
||||
WorkItemVerticalStackIllustration,
|
||||
} from "./vertical-stack";
|
||||
|
||||
// Horizontal Stack Asset Registry
|
||||
export const HORIZONTAL_STACK_ASSETS: Record<HorizontalStackAssetType, React.ComponentType<{ className?: string }>> = {
|
||||
customer: CustomerHorizontalStackIllustration,
|
||||
epic: EpicHorizontalStackIllustration,
|
||||
estimate: EstimateHorizontalStackIllustration,
|
||||
export: ExportHorizontalStackIllustration,
|
||||
intake: IntakeHorizontalStackIllustration,
|
||||
label: LabelHorizontalStackIllustration,
|
||||
link: LinkHorizontalStackIllustration,
|
||||
members: MembersHorizontalStackIllustration,
|
||||
note: NoteHorizontalStackIllustration,
|
||||
priority: PriorityHorizontalStackIllustration,
|
||||
project: ProjectHorizontalStackIllustration,
|
||||
settings: SettingsHorizontalStackIllustration,
|
||||
state: StateHorizontalStackIllustration,
|
||||
template: TemplateHorizontalStackIllustration,
|
||||
token: TokenHorizontalStackIllustration,
|
||||
unknown: UnknownHorizontalStackIllustration,
|
||||
update: UpdateHorizontalStackIllustration,
|
||||
webhook: WebhookHorizontalStackIllustration,
|
||||
"work-item": WorkItemHorizontalStackIllustration,
|
||||
worklog: WorklogHorizontalStackIllustration,
|
||||
};
|
||||
|
||||
// Vertical Stack Asset Registry
|
||||
export const VERTICAL_STACK_ASSETS: Record<VerticalStackAssetType, React.ComponentType<{ className?: string }>> = {
|
||||
"archived-cycle": ArchivedCycleVerticalStackIllustration,
|
||||
"archived-module": ArchivedModuleVerticalStackIllustration,
|
||||
"archived-work-item": ArchivedWorkItemVerticalStackIllustration,
|
||||
changelog: ChangelogVerticalStackIllustration,
|
||||
customer: CustomerVerticalStackIllustration,
|
||||
cycle: CycleVerticalStackIllustration,
|
||||
dashboard: DashboardVerticalStackIllustration,
|
||||
draft: DraftVerticalStackIllustration,
|
||||
epic: EpicVerticalStackIllustration,
|
||||
"error-404": Error404VerticalStackIllustration,
|
||||
initiative: InitiativeVerticalStackIllustration,
|
||||
"invalid-link": InvalidLinkVerticalStackIllustration,
|
||||
module: ModuleVerticalStackIllustration,
|
||||
"no-access": NoAccessVerticalStackIllustration,
|
||||
page: PageVerticalStackIllustration,
|
||||
project: ProjectVerticalStackIllustration,
|
||||
"server-error": ServerErrorVerticalStackIllustration,
|
||||
teamspace: TeamspaceVerticalStackIllustration,
|
||||
view: ViewVerticalStackIllustration,
|
||||
"work-item": WorkItemVerticalStackIllustration,
|
||||
};
|
||||
|
||||
// Illustration Asset Registry
|
||||
export const ILLUSTRATION_ASSETS: Record<IllustrationAssetType, React.ComponentType<{ className?: string }>> = {
|
||||
inbox: InboxIllustration,
|
||||
search: SearchIllustration,
|
||||
};
|
||||
|
||||
// Helper functions to get assets
|
||||
export const getCompactAsset = (assetKey: CompactAssetType, className?: string): React.ReactNode => {
|
||||
const AssetComponent =
|
||||
(HORIZONTAL_STACK_ASSETS[assetKey as HorizontalStackAssetType] as React.ComponentType<{ className?: string }>) ||
|
||||
ILLUSTRATION_ASSETS[assetKey as IllustrationAssetType];
|
||||
|
||||
if (!AssetComponent) {
|
||||
console.warn(`Asset "${assetKey}" not found in compact asset registry`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AssetComponent className={className} />;
|
||||
};
|
||||
|
||||
export const getDetailedAsset = (assetKey: DetailedAssetType, className?: string): React.ReactNode => {
|
||||
const AssetComponent =
|
||||
(VERTICAL_STACK_ASSETS[assetKey as VerticalStackAssetType] as React.ComponentType<{ className?: string }>) ||
|
||||
ILLUSTRATION_ASSETS[assetKey as IllustrationAssetType];
|
||||
|
||||
if (!AssetComponent) {
|
||||
console.warn(`Asset "${assetKey}" not found in detailed asset registry`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AssetComponent className={className} />;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// Horizontal Stack Asset Types
|
||||
export type HorizontalStackAssetType =
|
||||
| "customer"
|
||||
| "epic"
|
||||
| "estimate"
|
||||
| "export"
|
||||
| "intake"
|
||||
| "label"
|
||||
| "link"
|
||||
| "members"
|
||||
| "note"
|
||||
| "priority"
|
||||
| "project"
|
||||
| "settings"
|
||||
| "state"
|
||||
| "template"
|
||||
| "token"
|
||||
| "unknown"
|
||||
| "update"
|
||||
| "webhook"
|
||||
| "work-item"
|
||||
| "worklog";
|
||||
|
||||
// Vertical Stack Asset Types
|
||||
export type VerticalStackAssetType =
|
||||
| "archived-cycle"
|
||||
| "archived-module"
|
||||
| "archived-work-item"
|
||||
| "changelog"
|
||||
| "customer"
|
||||
| "cycle"
|
||||
| "dashboard"
|
||||
| "draft"
|
||||
| "epic"
|
||||
| "error-404"
|
||||
| "initiative"
|
||||
| "invalid-link"
|
||||
| "module"
|
||||
| "no-access"
|
||||
| "page"
|
||||
| "project"
|
||||
| "server-error"
|
||||
| "teamspace"
|
||||
| "view"
|
||||
| "work-item";
|
||||
|
||||
// Illustration Asset Types
|
||||
export type IllustrationAssetType = "inbox" | "search";
|
||||
|
||||
// Combined Asset Types for Compact (uses horizontal + illustration)
|
||||
export type CompactAssetType = HorizontalStackAssetType | IllustrationAssetType;
|
||||
|
||||
// Combined Asset Types for Detailed (uses vertical + illustration)
|
||||
export type DetailedAssetType = VerticalStackAssetType | IllustrationAssetType;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export const ILLUSTRATION_COLOR_TOKEN_MAP = {
|
||||
fill: {
|
||||
primary: "var(--illustration-fill-primary)", // #FFFFFF
|
||||
secondary: "var(--illustration-fill-secondary)", // #F4F5F5
|
||||
tertiary: "var(--illustration-fill-tertiary)", // #eaebeb
|
||||
quaternary: "var(--illustration-fill-quaternary)", // #CFD2D3
|
||||
},
|
||||
stroke: {
|
||||
primary: "var(--illustration-stroke-primary)", //#CFD2D3
|
||||
secondary: "var(--illustration-stroke-secondary)", // #8A9093
|
||||
tertiary: "var(--illustration-stroke-tertiary)", // #1d1f20
|
||||
},
|
||||
};
|
||||
|
||||
export type TIllustrationAssetProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
CustomerHorizontalStackIllustration,
|
||||
EpicHorizontalStackIllustration,
|
||||
EstimateHorizontalStackIllustration,
|
||||
ExportHorizontalStackIllustration,
|
||||
IntakeHorizontalStackIllustration,
|
||||
LabelHorizontalStackIllustration,
|
||||
LinkHorizontalStackIllustration,
|
||||
MembersHorizontalStackIllustration,
|
||||
NoteHorizontalStackIllustration,
|
||||
PriorityHorizontalStackIllustration,
|
||||
ProjectHorizontalStackIllustration,
|
||||
SettingsHorizontalStackIllustration,
|
||||
StateHorizontalStackIllustration,
|
||||
TemplateHorizontalStackIllustration,
|
||||
TokenHorizontalStackIllustration,
|
||||
UnknownHorizontalStackIllustration,
|
||||
UpdateHorizontalStackIllustration,
|
||||
WebhookHorizontalStackIllustration,
|
||||
WorkItemHorizontalStackIllustration,
|
||||
WorklogHorizontalStackIllustration,
|
||||
} from "./";
|
||||
|
||||
export const HorizontalStackAssetsMap = [
|
||||
{
|
||||
asset: <CustomerHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Customer",
|
||||
},
|
||||
{
|
||||
asset: <EpicHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Epic",
|
||||
},
|
||||
{
|
||||
asset: <EstimateHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Estimate",
|
||||
},
|
||||
{
|
||||
asset: <ExportHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Export",
|
||||
},
|
||||
{
|
||||
asset: <IntakeHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Intake",
|
||||
},
|
||||
{
|
||||
asset: <LabelHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Label",
|
||||
},
|
||||
{
|
||||
asset: <LinkHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Link",
|
||||
},
|
||||
{
|
||||
asset: <MembersHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Members",
|
||||
},
|
||||
{
|
||||
asset: <NoteHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Note",
|
||||
},
|
||||
{
|
||||
asset: <PriorityHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Priority",
|
||||
},
|
||||
{
|
||||
asset: <ProjectHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Project",
|
||||
},
|
||||
{
|
||||
asset: <SettingsHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Settings",
|
||||
},
|
||||
{
|
||||
asset: <StateHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "State",
|
||||
},
|
||||
{
|
||||
asset: <TemplateHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Template",
|
||||
},
|
||||
{
|
||||
asset: <TokenHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Token",
|
||||
},
|
||||
{
|
||||
asset: <UnknownHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Unknown",
|
||||
},
|
||||
{
|
||||
asset: <UpdateHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Update",
|
||||
},
|
||||
{
|
||||
asset: <WebhookHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Webhook",
|
||||
},
|
||||
{
|
||||
asset: <WorkItemHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "WorkItem",
|
||||
},
|
||||
{
|
||||
asset: <WorklogHorizontalStackIllustration className="h-20 w-20" />,
|
||||
title: "Worklog",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function CustomerHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="81"
|
||||
height="91"
|
||||
viewBox="0 0 81 91"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M47.6282 2.35629C46.7128 1.89072 45.537 1.93039 44.2587 2.57746L9.017 20.5374C6.12097 22.0131 3.7695 26.0693 3.7695 29.5887V72.4766C3.7695 74.4415 4.49547 75.8226 5.63968 76.4145L2.12019 74.6232C0.975977 74.0392 0.25 72.6502 0.25 70.6853V27.7974C0.25 24.2701 2.60157 20.2218 5.4976 18.7462L40.7393 0.786161C42.0255 0.131199 43.2013 0.0994185 44.1088 0.564994L47.6282 2.35629Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.628 2.35601C48.7722 2.93995 49.4982 4.32897 49.4982 6.29386V49.1818C49.4982 52.7091 47.1466 56.7574 44.2506 58.233L9.00889 76.1934C7.72264 76.8484 6.54695 76.8798 5.63947 76.4142C4.49526 75.8303 3.76929 74.4412 3.76929 72.4763V29.5884C3.76929 26.0611 6.12076 22.0128 9.01679 20.5372L44.2585 2.57718C45.5447 1.92221 46.7205 1.89043 47.628 2.35601Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.0078 9.15121C62.0925 8.68564 60.9167 8.72531 59.6383 9.37238L24.3966 27.3324C21.5006 28.808 19.1491 32.8642 19.1491 36.3836V79.2719C19.1491 81.2368 19.8751 82.6176 21.0193 83.2094L17.4998 81.4181C16.3556 80.8342 15.6296 79.4455 15.6296 77.4806V34.5924C15.6296 31.065 17.9812 27.0167 20.8772 25.5411L56.1189 7.58108C57.4052 6.92612 58.5809 6.89434 59.4884 7.35992L63.0078 9.15121Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.0084 9.14898C64.1526 9.73292 64.8785 11.1219 64.8785 13.0868V55.9751C64.8785 59.5024 62.527 63.5504 59.6309 65.026L24.3894 82.9864C23.1031 83.6413 21.9273 83.6727 21.0198 83.2072C19.8756 82.6232 19.1497 81.2346 19.1497 79.2697V36.3814C19.1497 32.8541 21.5011 28.8058 24.3972 27.3301L59.6388 9.37015C60.9251 8.71518 62.1009 8.6834 63.0084 9.14898Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.3799 15.9463C77.4645 15.4807 76.2887 15.52 75.0104 16.1671L39.7688 34.1275C36.8728 35.6031 34.5212 39.6589 34.5212 43.1783V86.0666C34.5212 88.0315 35.2472 89.4126 36.3914 90.0045L32.872 88.2132C31.7278 87.6292 31.0017 86.2402 31.0017 84.2753V41.387C31.0017 37.8597 33.3533 33.8118 36.2493 32.3362L71.491 14.3758C72.7772 13.7208 73.953 13.6894 74.8605 14.155L78.3799 15.9463Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.3799 15.9441C79.5241 16.528 80.2501 17.9166 80.2501 19.8815V62.7698C80.2501 66.2971 77.8986 70.3454 75.0025 71.8211L39.7609 89.7811C38.4747 90.436 37.2989 90.4678 36.3914 90.0022C35.2472 89.4183 34.5212 88.0293 34.5212 86.0644V43.1761C34.5212 39.6488 36.8728 35.6009 39.7688 34.1252L75.0104 16.1648C76.2967 15.5099 77.4725 15.4785 78.3799 15.9441Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M46.3733 45.3247C46.3733 44.6855 46.7994 43.9515 47.3202 43.6832L62.5185 35.942C63.0393 35.6737 63.4654 35.9737 63.4654 36.6128C63.4654 37.252 63.0393 37.9859 62.5185 38.2542V42.8783L70.1176 39.0037C70.6384 38.7354 71.0645 39.0353 71.0645 39.6745C71.0645 40.3137 70.6384 41.0476 70.1176 41.3159V59.8126L71.0645 59.3314C71.5853 59.0631 72.0115 59.363 72.0115 60.0022C72.0115 60.6414 71.5853 61.3749 71.0645 61.6432L46.3654 74.2297C45.8446 74.498 45.4185 74.1981 45.4185 73.5589C45.4185 72.9197 45.8446 72.1858 46.3654 71.9175L47.3123 71.4362V46.0032C46.7915 46.2715 46.3654 45.9715 46.3654 45.3324L46.3733 45.3247ZM49.222 45.0249V70.4579L51.1237 69.4873V65.4389C51.1237 63.8449 52.189 62.0065 53.4989 61.3358L56.3477 59.8835C57.6576 59.2128 58.7228 59.9706 58.7228 61.5646V65.6127L60.6246 64.6421V39.209L49.2299 45.0168L49.222 45.0249ZM62.5264 45.1906V63.6873L68.2238 60.7832V42.2865L62.5264 45.1906ZM56.8212 66.5833V62.5352C56.8212 62.2196 56.6081 62.0695 56.3477 62.1958L53.4989 63.6476C53.2385 63.7818 53.0255 64.1527 53.0255 64.4683V68.5164L56.8212 66.5833ZM51.1237 48.6861C51.1237 48.0469 51.5499 47.313 52.0707 47.0447L53.0176 46.5634C53.5384 46.2951 53.9646 46.5951 53.9646 47.2342C53.9646 47.8734 53.5384 48.6074 53.0176 48.8757L52.0707 49.3569C51.5499 49.6252 51.1237 49.3253 51.1237 48.6861ZM55.8742 46.2636C55.8742 45.6245 56.3003 44.8905 56.8212 44.6222L57.768 44.141C58.2889 43.8727 58.715 44.1726 58.715 44.8118C58.715 45.451 58.2889 46.1845 57.768 46.4528L56.8212 46.9345C56.3003 47.2028 55.8742 46.9028 55.8742 46.2636ZM51.1237 53.3106C51.1237 52.6714 51.5499 51.9374 52.0707 51.6691L53.0176 51.1875C53.5384 50.9192 53.9646 51.2191 53.9646 51.8583C53.9646 52.4975 53.5384 53.2314 53.0176 53.4997L52.0707 53.981C51.5499 54.2493 51.1237 53.9497 51.1237 53.3106ZM55.8742 50.8877C55.8742 50.2486 56.3003 49.5146 56.8212 49.2463L57.768 48.7651C58.2889 48.4968 58.715 48.7967 58.715 49.4359C58.715 50.0751 58.2889 50.809 57.768 51.0773L56.8212 51.5586C56.3003 51.8269 55.8742 51.5269 55.8742 50.8877ZM64.4203 48.8441C64.4203 48.2049 64.8464 47.4709 65.3672 47.2026H65.3751C65.8959 46.9265 66.322 47.2341 66.322 47.8654V47.8812C66.322 48.5204 65.8959 49.2543 65.3751 49.5226H65.3672C64.8464 49.7988 64.4203 49.4911 64.4203 48.8599V48.8441ZM51.1237 57.9265C51.1237 57.2874 51.5499 56.5534 52.0707 56.2851L53.0176 55.8039C53.5384 55.5356 53.9646 55.8355 53.9646 56.4747C53.9646 57.1139 53.5384 57.8478 53.0176 58.1161L52.0707 58.5974C51.5499 58.8657 51.1237 58.5657 51.1237 57.9265ZM55.8742 55.5118C55.8742 54.8726 56.3003 54.1391 56.8212 53.8708L57.768 53.3892C58.2889 53.1209 58.715 53.4208 58.715 54.06C58.715 54.6992 58.2889 55.4331 57.768 55.7014L56.8212 56.1826C56.3003 56.4509 55.8742 56.151 55.8742 55.5118ZM64.4203 53.4681C64.4203 52.829 64.8464 52.095 65.3672 51.8267H65.3751C65.8959 51.5505 66.322 51.8586 66.322 52.4898V52.5056C66.322 53.1448 65.8959 53.8784 65.3751 54.1467H65.3672C64.8464 54.4229 64.4203 54.1152 64.4203 53.4839V53.4681ZM64.4203 58.0922C64.4203 57.453 64.8464 56.7195 65.3672 56.4512H65.3751C65.8959 56.175 66.322 56.4826 66.322 57.1139V57.1297C66.322 57.7689 65.8959 58.5028 65.3751 58.7711H65.3672C64.8464 59.0473 64.4203 58.7393 64.4203 58.108V58.0922Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function EpicHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="81"
|
||||
height="92"
|
||||
viewBox="0 0 81 92"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M47.6229 2.35611C46.7076 1.89059 45.532 1.93026 44.2538 2.57726L9.01602 20.5352C6.12031 22.0107 3.76901 26.0664 3.76901 29.5855V72.4685C3.76901 74.4332 4.4949 75.8141 5.63899 76.4059L2.11998 74.6148C0.975896 74.0309 0.25 72.6424 0.25 70.6778V27.7944C0.25 24.2674 2.6013 20.2196 5.49701 18.7441L40.7348 0.786161C42.0209 0.131273 43.1964 0.0994966 44.1038 0.56502L47.6229 2.35611Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.6222 2.35613C48.7663 2.94 49.4922 4.32887 49.4922 6.29353V49.1769C49.4922 52.7039 47.1409 56.7513 44.2452 58.2268L9.00743 76.1852C7.72132 76.84 6.54566 76.8714 5.63829 76.4059C4.49421 75.822 3.76831 74.4332 3.76831 72.4685V29.5855C3.76831 26.0585 6.11962 22.0107 9.01533 20.5352L44.2531 2.57727C45.5392 1.92238 46.7148 1.8906 47.6222 2.35613Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.0011 9.14965C62.0858 8.68413 60.9102 8.7238 59.6319 9.37079L24.3942 27.3287C21.4985 28.8042 19.1472 32.86 19.1472 36.379V79.2624C19.1472 81.2271 19.8731 82.6077 21.0172 83.1994L17.4982 81.4083C16.3541 80.8245 15.6282 79.436 15.6282 77.4713V34.5879C15.6282 31.061 17.9795 27.0131 20.8752 25.5377L56.1129 7.5797C57.399 6.92481 58.5746 6.89342 59.482 7.35894L63.0011 9.14965Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.0019 9.14949C64.1459 9.73337 64.8718 11.1222 64.8718 13.0869V55.9703C64.8718 59.4972 62.5205 63.5447 59.6248 65.0202L24.3871 82.9785C23.101 83.6334 21.9253 83.6648 21.0179 83.1993C19.8738 82.6154 19.1479 81.2269 19.1479 79.2623V36.3788C19.1479 32.8519 21.4993 28.8041 24.395 27.3286L59.6327 9.37063C60.9188 8.71575 62.0945 8.68397 63.0019 9.14949Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.3793 15.9509C77.464 15.4854 76.2883 15.5251 75.0101 16.1721L39.7724 34.13C36.8767 35.6055 34.5255 39.6612 34.5255 43.1803V86.0637C34.5255 88.0284 35.2513 89.409 36.3954 90.0007L32.8763 88.2096C31.7322 87.6257 31.0063 86.2373 31.0063 84.2726V41.3892C31.0063 37.8623 33.3577 33.8144 36.2534 32.3389L71.4911 14.381C72.7772 13.7261 73.9529 13.6943 74.8602 14.1598L78.3793 15.9509Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.3799 15.9509C79.524 16.5348 80.2499 17.9237 80.2499 19.8883V62.7718C80.2499 66.2987 77.8986 70.3462 75.0029 71.8216L39.7651 89.78C38.479 90.4349 37.3035 90.4662 36.3961 90.0007C35.252 89.4168 34.5261 88.0284 34.5261 86.0637V43.1803C34.5261 39.6534 36.8773 35.6055 39.773 34.13L75.0108 16.1721C76.2969 15.5172 77.4725 15.4854 78.3799 15.9509Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M73.4865 50.9128V54.7473C73.4865 55.1418 73.3603 55.5758 73.1551 55.9624C72.9342 56.349 72.6501 56.657 72.3424 56.8069L47.1726 69.6362C46.5414 69.9597 46.0286 69.5572 46.0286 68.7366V62.6298L53.6584 46.0604C54.0845 45.2871 54.5815 44.6005 55.1891 44.2928C55.7966 43.985 56.2859 44.1665 56.712 44.5058L63.3082 53.8874L66.1565 49.327C66.5826 48.5538 67.1664 47.9459 67.774 47.6303C68.3815 47.3147 68.9654 47.3384 69.3836 47.6777L73.4865 50.9051V50.9128Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function EstimateHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="81"
|
||||
height="92"
|
||||
viewBox="0 0 81 92"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M47.6234 2.35621C46.7081 1.89069 45.5325 1.93014 44.2542 2.57714L9.01616 20.5355C6.12042 22.0109 3.76909 26.0666 3.76909 29.5856V72.4694C3.76909 74.4341 4.495 75.8149 5.63909 76.4067L2.12005 74.6155C0.975952 74.0317 0.25 72.643 0.25 70.6783V27.7945C0.25 24.2676 2.60133 20.2198 5.49707 18.7444L40.7351 0.78604C42.0213 0.131145 43.1969 0.0995852 44.1043 0.565113L47.6234 2.35621Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.6232 2.3553C48.7673 2.93919 49.4932 4.32788 49.4932 6.29257V49.1763C49.4932 52.7033 47.1419 56.751 44.2462 58.2265L9.00809 76.1848C7.72197 76.8397 6.5463 76.8713 5.63892 76.4057C4.49482 75.8219 3.76892 74.4332 3.76892 72.4685V29.5847C3.76892 26.0578 6.12025 22.01 9.01599 20.5345L44.2541 2.57623C45.5402 1.92134 46.7158 1.88978 47.6232 2.3553Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.0014 9.14949C62.0861 8.68396 60.9105 8.72341 59.6322 9.37041L24.3942 27.3287C21.4984 28.8042 19.1471 32.8598 19.1471 36.3789V79.2627C19.1471 81.2273 19.873 82.6081 21.0171 83.1999L17.498 81.4088C16.354 80.8249 15.6281 79.4363 15.6281 77.4716V34.5878C15.6281 31.0608 17.9793 27.0131 20.8751 25.5376L56.1132 7.57931C57.3993 6.92442 58.5749 6.89286 59.4823 7.35839L63.0014 9.14949Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.0014 9.14968C64.1455 9.73356 64.8714 11.1223 64.8714 13.0869V55.9707C64.8714 59.4977 62.5201 63.5454 59.6243 65.0209L24.3863 82.9792C23.1001 83.6341 21.9245 83.6656 21.0171 83.2001C19.873 82.6162 19.1471 81.2275 19.1471 79.2628V36.3791C19.1471 32.8521 21.4984 28.8044 24.3942 27.3289L59.6322 9.3706C60.9184 8.71571 62.094 8.68415 63.0014 9.14968Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.3801 15.9514C77.4648 15.4858 76.2891 15.5253 75.0109 16.1723L39.7728 34.1306C36.8771 35.6061 34.5258 39.6617 34.5258 43.1808V86.0645C34.5258 88.0292 35.2517 89.41 36.3958 90.0018L32.8767 88.2107C31.7326 87.6268 31.0067 86.2381 31.0067 84.2734V41.3897C31.0067 37.8627 33.358 33.815 36.2537 32.3395L71.4919 14.3812C72.778 13.7263 73.9536 13.6947 74.861 14.1603L78.3801 15.9514Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.3799 15.9516C79.524 16.5354 80.2499 17.9241 80.2499 19.8888V62.7726C80.2499 66.2995 77.8987 70.3473 75.0029 71.8228L39.7648 89.7811C38.4787 90.4359 37.303 90.4675 36.3956 90.002C35.2515 89.4181 34.5256 88.0294 34.5256 86.0647V43.181C34.5256 39.654 36.877 35.6063 39.7727 34.1308L75.0108 16.1725C76.2969 15.5176 77.4726 15.486 78.3799 15.9516Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M56.9895 40.0798C57.5024 39.4644 58.0783 38.9752 58.6701 38.6753C59.2619 38.3755 59.8379 38.2651 60.3508 38.3676C60.8636 38.4623 61.2897 38.7621 61.5816 39.2356L70.5529 53.7774C70.8449 54.2508 71.0027 54.8741 71.0027 55.5921C71.0027 56.3102 70.8449 57.0992 70.5529 57.8724C70.261 58.6457 69.8349 59.3795 69.322 60.0028C68.8092 60.6262 68.2332 61.1075 67.6414 61.4073L49.6831 70.5521C49.0913 70.852 48.5153 70.9546 48.0024 70.8599C47.4895 70.7573 47.0635 70.4575 46.7715 69.984C46.4796 69.5106 46.3218 68.8873 46.3218 68.1693C46.3218 67.4513 46.4796 66.6701 46.7715 65.8969L55.7507 42.2023C56.0505 41.4291 56.4687 40.6953 56.9816 40.0798H56.9895ZM58.6701 41.4054C58.4729 41.508 58.2835 41.6658 58.1099 41.8709C57.9442 42.0761 57.8022 42.3207 57.6996 42.5732L48.7205 66.2598C48.6258 66.5202 48.5705 66.7806 48.5705 67.0173C48.5705 67.254 48.6258 67.467 48.7205 67.6248C48.8151 67.7827 48.9571 67.8852 49.1307 67.9168C49.3043 67.9483 49.4937 67.9168 49.691 67.8142L67.6414 58.6694C67.8386 58.5668 68.028 58.409 68.2016 58.2038C68.3752 57.9987 68.5172 57.7541 68.6119 57.4937C68.7066 57.2333 68.7618 56.973 68.7618 56.7362C68.7618 56.4995 68.7066 56.2865 68.6119 56.1287L59.6406 41.5869C59.5459 41.4291 59.4039 41.3344 59.2303 41.3028C59.0567 41.2712 58.8674 41.3028 58.6701 41.4054Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function ExportHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="71"
|
||||
height="80"
|
||||
viewBox="0 0 71 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.6519 2.04204C40.851 1.6347 39.8223 1.66941 38.7038 2.23554L7.87038 17.949C5.3366 19.24 3.27923 22.7888 3.27923 25.868V63.3912C3.27923 65.1103 3.91439 66.3187 4.91548 66.8365L1.83627 65.2692C0.83518 64.7584 0.200012 63.5431 0.200012 61.824V24.3008C0.200012 21.2147 2.25743 17.6728 4.79121 16.3817L35.6246 0.668313C36.75 0.095278 37.7787 0.0674731 38.5726 0.474811L41.6519 2.04204Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.6519 2.04317C42.653 2.55407 43.2881 3.76934 43.2881 5.48845V43.0116C43.2881 46.0977 41.2307 49.6396 38.6969 50.9307L7.86352 66.6444C6.73817 67.2175 5.70945 67.2449 4.91549 66.8376C3.9144 66.3267 3.27924 65.1114 3.27924 63.3923V25.8692C3.27924 22.7831 5.33661 19.2411 7.87039 17.9501L38.7038 2.23667C39.8292 1.66364 40.8579 1.63583 41.6519 2.04317Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.1078 7.9854C54.3069 7.57806 53.2782 7.61277 52.1598 8.1789L21.3264 23.8923C18.7926 25.1834 16.7352 28.7322 16.7352 31.8114V69.3349C16.7352 71.054 17.3703 72.262 18.3714 72.7798L15.2922 71.2126C14.2911 70.7017 13.6559 69.4868 13.6559 67.7677V30.2442C13.6559 27.1581 15.7134 23.6161 18.2471 22.3251L49.0805 6.61167C50.2059 6.03864 51.2346 6.01083 52.0286 6.41817L55.1078 7.9854Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.1079 7.98653C56.109 8.49743 56.7441 9.7127 56.7441 11.4318V48.9553C56.7441 52.0414 54.6867 55.583 52.1529 56.874L21.3195 72.5878C20.1942 73.1608 19.1654 73.1883 18.3715 72.781C17.3704 72.2701 16.7352 71.0551 16.7352 69.336V31.8125C16.7352 28.7264 18.7926 25.1845 21.3264 23.8935L52.1598 8.18003C53.2852 7.607 54.3139 7.57919 55.1079 7.98653Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.5637 13.9328C67.7629 13.5255 66.7342 13.5598 65.6157 14.126L34.7823 29.8397C32.2485 31.1308 30.1911 34.6793 30.1911 37.7585V75.282C30.1911 77.0011 30.8263 78.2094 31.8274 78.7272L28.7482 77.16C27.7471 76.6491 27.1119 75.4338 27.1119 73.7147V36.1912C27.1119 33.1051 29.1693 29.5636 31.7031 28.2725L62.5365 12.5587C63.6619 11.9857 64.6906 11.9582 65.4845 12.3656L68.5637 13.9328Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.5637 13.93C69.5648 14.4409 70.2 15.6559 70.2 17.375V54.8985C70.2 57.9846 68.1426 61.5265 65.6088 62.8175L34.7754 78.531C33.65 79.104 32.6213 79.1318 31.8274 78.7245C30.8263 78.2136 30.1911 76.9983 30.1911 75.2792V37.7557C30.1911 34.6696 32.2485 31.128 34.7823 29.837L65.6157 14.1232C66.7411 13.5502 67.7698 13.5227 68.5637 13.93Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M43.2881 40.2004C43.2881 39.51 43.7438 38.7229 44.3099 38.4329L58.6289 31.1355C59.195 30.8455 59.6507 31.1699 59.6507 31.8603C59.6507 32.5507 59.195 33.3378 58.6289 33.6277L44.3099 40.9255C43.7438 41.2155 43.2881 40.8908 43.2881 40.2004Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M58.339 45.8623C57.9385 46.5527 57.2895 46.877 56.8891 46.6009L52.4981 43.494V57.9096C52.4981 58.6 52.0356 59.3871 51.4764 59.6771C50.9171 59.967 50.4546 59.6358 50.4546 58.9523V44.5297L46.0636 52.1103C45.6631 52.8007 45.0142 53.1319 44.6138 52.8489C44.2202 52.5658 44.2202 51.7789 44.6138 51.0885L50.7514 40.4978C50.7514 40.4978 50.7928 40.4356 50.8136 40.4011C50.8205 40.3941 50.8274 40.3874 50.8274 40.3805C50.9033 40.27 50.9862 40.1594 51.0759 40.0697C51.1104 40.0352 51.145 40.0005 51.1795 39.9729C51.2071 39.9453 51.2416 39.9178 51.2762 39.8971C51.3107 39.8695 51.3521 39.8418 51.3866 39.8141C51.4211 39.7934 51.4488 39.7796 51.4833 39.7589C51.5178 39.7381 51.5523 39.7245 51.5868 39.7106C51.718 39.6554 51.8492 39.6416 51.9666 39.6554C51.9942 39.6554 52.0218 39.6625 52.0494 39.6763C52.0494 39.6763 52.1046 39.6968 52.1323 39.7106C52.1737 39.7314 52.2082 39.7521 52.2427 39.7798L58.3459 44.0948C58.7394 44.3778 58.7394 45.1648 58.3459 45.8552L58.339 45.8623Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./customer";
|
||||
export * from "./epic";
|
||||
export * from "./estimate";
|
||||
export * from "./export";
|
||||
export * from "./intake";
|
||||
export * from "./label";
|
||||
export * from "./link";
|
||||
export * from "./members";
|
||||
export * from "./note";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./settings";
|
||||
export * from "./state";
|
||||
export * from "./template";
|
||||
export * from "./token";
|
||||
export * from "./unknown";
|
||||
export * from "./update";
|
||||
export * from "./webhook";
|
||||
export * from "./work-item";
|
||||
export * from "./worklog";
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function IntakeHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="71"
|
||||
height="80"
|
||||
viewBox="0 0 71 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.6519 2.04294C40.851 1.6356 39.8223 1.67014 38.7038 2.23627L7.87039 17.9499C5.33661 19.2409 3.27921 22.7896 3.27921 25.8688V63.3923C3.27921 65.1114 3.91438 66.3196 4.91547 66.8374L1.83625 65.2702C0.835165 64.7593 0.199997 63.5442 0.199997 61.8251V24.3015C0.199997 21.2154 2.25742 17.6737 4.7912 16.3826L35.6246 0.669039C36.75 0.0960034 37.7787 0.068367 38.5726 0.475705L41.6519 2.04294Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.6519 2.04327C42.653 2.55417 43.2882 3.76927 43.2882 5.48838V43.0119C43.2882 46.098 41.2308 49.6397 38.697 50.9308L7.86357 66.6444C6.73821 67.2174 5.70951 67.2451 4.91555 66.8377C3.91446 66.3268 3.2793 65.1117 3.2793 63.3926V25.8691C3.2793 22.783 5.3367 19.2413 7.87048 17.9502L38.7039 2.2366C39.8293 1.66357 40.858 1.63593 41.6519 2.04327Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.1078 8.0216C54.307 7.61427 53.2783 7.64881 52.1598 8.21494L21.3264 23.9285C18.7926 25.2196 16.7352 28.7682 16.7352 31.8474V69.371C16.7352 71.0901 17.3704 72.2983 18.3714 72.8161L15.2923 71.2488C14.2912 70.7379 13.656 69.5228 13.656 67.8037V30.2802C13.656 27.1941 15.7134 23.6524 18.2472 22.3613L49.0806 6.64771C50.2059 6.07467 51.2347 6.04704 52.0286 6.45437L55.1078 8.0216Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.1079 8.02209C56.109 8.53299 56.7441 9.74809 56.7441 11.4672V48.9907C56.7441 52.0768 54.6867 55.6186 52.1529 56.9096L21.3195 72.6232C20.1942 73.1963 19.1655 73.2239 18.3715 72.8165C17.3704 72.3056 16.7352 71.0905 16.7352 69.3714V31.8479C16.7352 28.7618 18.7926 25.2201 21.3264 23.929L52.1599 8.21543C53.2852 7.64239 54.3139 7.61475 55.1079 8.02209Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.5638 13.973C67.7629 13.5657 66.7342 13.6002 65.6157 14.1663L34.7823 29.8799C32.2485 31.171 30.1911 34.7196 30.1911 37.7988V75.3222C30.1911 77.0413 30.8263 78.2495 31.8274 78.7673L28.7482 77.2002C27.7471 76.6893 27.1119 75.474 27.1119 73.7549V36.2316C27.1119 33.1455 29.1693 29.6037 31.7031 28.3127L62.5365 12.5991C63.6619 12.0261 64.6906 11.9984 65.4845 12.4058L68.5638 13.973Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.5636 13.9733C69.5647 14.4842 70.1999 15.6993 70.1999 17.4184V54.9418C70.1999 58.0279 68.1425 61.5698 65.6087 62.8609L34.7753 78.5744C33.6499 79.1475 32.6212 79.1749 31.8272 78.7676C30.8262 78.2567 30.191 77.0416 30.191 75.3225V37.7992C30.191 34.7131 32.2484 31.1713 34.7822 29.8803L65.6156 14.1667C66.741 13.5936 67.7697 13.566 68.5636 13.9733Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M57.5519 34.9131C57.8349 34.9683 58.0075 35.2513 58.0075 35.6586V46.0631C58.0075 46.4566 57.8419 46.9053 57.5726 47.2505L51.8699 54.4376C51.5937 54.7897 51.2485 54.9693 50.9654 54.9141C50.6824 54.8589 50.5097 54.5757 50.5097 54.1684V43.7158C50.5097 43.3222 50.6754 42.8664 50.9516 42.5212L56.6543 35.3825C56.9305 35.0373 57.2757 34.8579 57.5588 34.9131H57.5519ZM52.2979 43.4395V51.2687L56.2056 46.3461V38.5514L52.2979 43.4395Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M55.0249 34.4983C55.2804 34.9057 55.1354 35.6651 54.7073 36.1967L49.4395 42.7832V52.6007C49.4395 53.2221 49.0391 53.9332 48.542 54.1817C48.0449 54.4303 47.6445 54.1334 47.6445 53.519V43.0662C47.6445 42.6726 47.8102 42.217 48.0863 41.8718L53.7891 34.7331C54.2171 34.2015 54.7695 34.091 55.0249 34.4983Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M52.1253 33.8013C52.3808 34.2087 52.2358 34.9681 51.8077 35.4997L46.54 42.0862V51.9037C46.54 52.5251 46.1395 53.2362 45.6424 53.4848C45.1454 53.7333 44.7449 53.4365 44.7449 52.822V42.3692C44.7449 41.9757 44.9106 41.52 45.1868 41.1748L50.8895 34.0361C51.3175 33.5045 51.8699 33.394 52.1253 33.8013Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M41.7347 51.7794C42.2318 51.5239 42.6322 51.8277 42.6322 52.4421V60.444C42.6322 61.1965 43.1293 61.5624 43.73 61.2586L58.9465 53.5054C59.5471 53.2016 60.0442 52.3317 60.0442 51.5791V43.5773C60.0442 42.9559 60.4446 42.2448 60.9417 41.9963C61.4388 41.7477 61.8393 42.0447 61.8393 42.6592V50.6609C61.8393 52.6561 60.5413 54.9414 58.9465 55.7561L43.73 63.5093C42.1351 64.324 40.8372 63.3574 40.8372 61.369V53.3673C40.8372 52.746 41.2376 52.0348 41.7347 51.7863V51.7794Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function LabelHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="81"
|
||||
height="92"
|
||||
viewBox="0 0 81 92"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M47.6237 2.3855C46.7085 1.9135 45.5328 1.9535 44.2545 2.6095L9.0162 20.8176C6.12044 22.3136 3.76909 26.4256 3.76909 29.9936V73.4738C3.76909 75.4658 4.495 76.8658 5.63911 77.4658L2.12001 75.6498C0.975909 75.0578 0.25 73.6498 0.25 71.6578V28.1776C0.25 24.6016 2.60132 20.4976 5.49708 19.0016L40.7354 0.793494C42.0216 0.129492 43.1972 0.0974924 44.1046 0.569494L47.6237 2.3855Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.6237 2.38514C48.7678 2.97714 49.4937 4.38515 49.4937 6.37716V49.8573C49.4937 53.4333 47.1424 57.5373 44.2466 59.0334L9.00828 77.2414C7.72216 77.9054 6.54648 77.9374 5.63909 77.4654C4.49498 76.8734 3.76907 75.4654 3.76907 73.4734V29.9932C3.76907 26.4172 6.12042 22.3132 9.01618 20.8172L44.2545 2.60914C45.5406 1.94513 46.7163 1.91314 47.6237 2.38514Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.0022 9.27381C62.087 8.80181 60.9113 8.84181 59.633 9.49781L24.3947 27.7059C21.499 29.2019 19.1476 33.3139 19.1476 36.8819V80.3621C19.1476 82.3541 19.8735 83.7541 21.0176 84.3541L17.4985 82.5381C16.3544 81.9461 15.6285 80.5381 15.6285 78.5461V35.0659C15.6285 31.4899 17.9799 27.3859 20.8756 25.8899L56.114 7.6818C57.4001 7.0178 58.5757 6.9858 59.4831 7.4578L63.0022 9.27381Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.0021 9.27344C64.1462 9.86545 64.8721 11.2735 64.8721 13.2655V56.7456C64.8721 60.3216 62.5208 64.4257 59.6251 65.9217L24.3867 84.1297C23.1005 84.7937 21.9249 84.8257 21.0175 84.3537C19.8734 83.7617 19.1475 82.3537 19.1475 80.3617V36.8815C19.1475 33.3055 21.4988 29.2015 24.3946 27.7055L59.6329 9.49744C60.919 8.83344 62.0947 8.80144 63.0021 9.27344Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.3803 16.1699C77.465 15.6979 76.2894 15.7379 75.0112 16.3939L39.7728 34.602C36.877 36.098 34.5257 40.21 34.5257 43.778V87.2582C34.5257 89.2502 35.2516 90.6502 36.3957 91.2502L32.8766 89.4342C31.7325 88.8422 31.0066 87.4342 31.0066 85.4422V41.962C31.0066 38.386 33.3579 34.282 36.2537 32.786L71.4921 14.5779C72.7782 13.9139 73.9539 13.8819 74.8613 14.3539L78.3803 16.1699Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.3801 16.1696C79.5242 16.7616 80.2501 18.1696 80.2501 20.1616V63.6417C80.2501 67.2178 77.8988 71.3218 75.0031 72.8178L39.7647 91.0258C38.4786 91.6898 37.3029 91.7218 36.3955 91.2498C35.2514 90.6578 34.5255 89.2498 34.5255 87.2578V43.7777C34.5255 40.2016 36.8768 36.0976 39.7726 34.6016L75.011 16.3936C76.2971 15.7296 77.4727 15.6976 78.3801 16.1696Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M49.5568 47.8738C50.1643 46.8178 50.977 45.9779 51.837 45.5379L59.538 41.5618C60.3902 41.1218 61.2108 41.1138 61.8184 41.5458L71.1606 48.2498C71.847 48.7459 72.2258 49.6979 72.2258 50.8979C72.2258 52.0979 71.8391 53.4419 71.1606 54.6499L64.0987 67.0179C63.4122 68.2259 62.4811 69.1779 61.5106 69.6819C60.5401 70.1859 59.6091 70.1939 58.9226 69.7059L49.5804 63.0019C48.9729 62.5699 48.6336 61.7379 48.6336 60.6819V51.1859C48.6336 50.1299 48.9729 48.9459 49.5804 47.8899L49.5568 47.8738ZM51.837 48.1859C51.553 48.3299 51.2769 48.6099 51.0796 48.9619C50.8823 49.3139 50.764 49.7059 50.764 50.0579V59.5539C50.764 59.9059 50.8745 60.1859 51.0796 60.3299L60.4218 67.0259C60.7058 67.2259 61.1003 67.2259 61.5027 67.0179C61.9051 66.8099 62.2918 66.4099 62.5838 65.9139L69.6456 53.5619C69.9296 53.0579 70.0875 52.4979 70.0875 52.0019C70.0875 51.5059 69.9296 51.1059 69.6456 50.8979L60.3034 44.2018C60.1062 44.0578 59.83 44.0578 59.5459 44.2018L51.8449 48.1779L51.837 48.1859Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
d="M59.5302 51.1618C59.5302 53.3538 58.0863 55.8818 56.311 56.7938C54.5357 57.7138 53.0917 56.6818 53.0917 54.4818C53.0917 52.2818 54.5357 49.7618 56.311 48.8498C58.0863 47.9298 59.5302 48.9618 59.5302 51.1618Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function LinkHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="51"
|
||||
height="58"
|
||||
viewBox="0 0 51 58"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M30.145 1.45971C29.565 1.16471 28.82 1.18971 28.01 1.59971L5.68 12.9797C3.845 13.9147 2.355 16.4847 2.355 18.7147V45.8897C2.355 47.1347 2.815 48.0097 3.54 48.3847L1.31 47.2497C0.584998 46.8797 0.125 45.9997 0.125 44.7547V17.5797C0.125 15.3447 1.615 12.7797 3.45 11.8447L25.78 0.464705C26.595 0.0497054 27.34 0.029706 27.915 0.324706L30.145 1.45971Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.145 1.45972C30.87 1.82972 31.33 2.70971 31.33 3.95471V31.1297C31.33 33.3647 29.84 35.9297 28.005 36.8647L5.67498 48.2447C4.85998 48.6597 4.11498 48.6797 3.53998 48.3847C2.81498 48.0147 2.35498 47.1347 2.35498 45.8897V18.7147C2.35498 16.4797 3.84498 13.9147 5.67998 12.9797L28.01 1.59972C28.825 1.18472 29.57 1.16472 30.145 1.45972Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M39.89 5.79473C39.31 5.49973 38.565 5.52473 37.755 5.93473L15.425 17.3147C13.59 18.2497 12.1 20.8197 12.1 23.0497V50.2247C12.1 51.4697 12.56 52.3447 13.285 52.7197L11.055 51.5847C10.33 51.2147 9.87 50.3347 9.87 49.0897V21.9147C9.87 19.6797 11.36 17.1147 13.195 16.1797L35.525 4.79973C36.34 4.38473 37.085 4.36473 37.66 4.65973L39.89 5.79473Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M39.89 5.7948C40.615 6.1648 41.075 7.04479 41.075 8.28979V35.4648C41.075 37.6998 39.585 40.2648 37.75 41.1998L15.42 52.5798C14.605 52.9948 13.86 53.0148 13.285 52.7198C12.56 52.3498 12.1 51.4698 12.1 50.2248V23.0498C12.1 20.8148 13.59 18.2498 15.425 17.3148L37.755 5.9348C38.57 5.5198 39.315 5.4998 39.89 5.7948Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M49.635 10.0997C49.055 9.80472 48.31 9.82972 47.5 10.2397L25.17 21.6197C23.335 22.5547 21.845 25.1247 21.845 27.3547V54.5297C21.845 55.7747 22.305 56.6497 23.03 57.0247L20.8 55.8897C20.075 55.5197 19.615 54.6397 19.615 53.3947V26.2197C19.615 23.9847 21.105 21.4197 22.94 20.4847L45.27 9.10472C46.085 8.68972 46.83 8.66972 47.405 8.96472L49.635 10.0997Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.635 10.0997C50.36 10.4697 50.82 11.3497 50.82 12.5947V39.7697C50.82 42.0047 49.33 44.5697 47.495 45.5047L25.165 56.8847C24.35 57.2997 23.605 57.3197 23.03 57.0247C22.305 56.6547 21.845 55.7747 21.845 54.5297V27.3547C21.845 25.1197 23.335 22.5547 25.17 21.6197L47.5 10.2397C48.315 9.82473 49.06 9.80473 49.635 10.0997Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M32.49 37.2048C31.935 38.1598 31.625 39.2347 31.625 40.1847C31.625 41.1347 31.935 41.8948 32.49 42.2848C33.045 42.6748 33.795 42.6747 34.575 42.2747C35.355 41.8747 36.11 41.1148 36.66 40.1598L37.705 38.3598C37.995 37.8598 38.46 37.6248 38.75 37.8298C39.04 38.0348 39.04 38.6047 38.75 39.0997L37.705 40.8997C36.875 42.3347 35.75 43.4747 34.575 44.0747C33.4 44.6747 32.275 44.6797 31.445 44.0897C30.615 43.5047 30.15 42.3698 30.15 40.9398C30.15 39.5098 30.615 37.9048 31.445 36.4698L32.49 34.6697C32.78 34.1697 33.245 33.9348 33.535 34.1398C33.825 34.3448 33.825 34.9098 33.535 35.4098L32.49 37.2097V37.2048Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M35.62 30.5248C35.33 30.3198 35.33 29.7548 35.62 29.2548L36.665 27.4548C37.495 26.0198 38.62 24.8798 39.795 24.2798C40.97 23.6798 42.095 23.6748 42.925 24.2648C43.755 24.8498 44.22 25.9848 44.22 27.4148C44.22 28.8448 43.755 30.4498 42.925 31.8848L41.88 33.6848C41.59 34.1848 41.125 34.4198 40.835 34.2148C40.545 34.0098 40.545 33.4398 40.835 32.9448L41.88 31.1448C42.435 30.1898 42.745 29.1148 42.745 28.1648C42.745 27.2148 42.435 26.4548 41.88 26.0648C41.325 25.6748 40.575 25.6748 39.795 26.0748C39.01 26.4748 38.26 27.2348 37.71 28.1898L36.665 29.9898C36.375 30.4898 35.91 30.7248 35.62 30.5198V30.5248Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M34.575 38.6798C34.285 38.4748 34.285 37.9098 34.575 37.4098L38.75 30.2048C39.04 29.7048 39.505 29.4698 39.795 29.6748C40.085 29.8798 40.085 30.4448 39.795 30.9448L35.62 38.1498C35.33 38.6448 34.865 38.8848 34.575 38.6798Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function MembersHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="71"
|
||||
height="80"
|
||||
viewBox="0 0 71 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.6017 1.99286C40.8009 1.58553 39.7722 1.62005 38.6537 2.18616L7.82029 17.8993C5.28651 19.1903 3.2291 22.7389 3.2291 25.818V63.3404C3.2291 65.0594 3.86428 66.2676 4.86536 66.7854L1.78616 65.2182C0.785076 64.7073 0.149902 63.4923 0.149902 61.7732V24.2508C0.149902 21.1648 2.20731 17.6232 4.74109 16.3321L35.5745 0.619001C36.6999 0.0459819 37.7286 0.0183569 38.5225 0.425683L41.6017 1.99286Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.6014 1.99319C42.6025 2.50408 43.2377 3.71916 43.2377 5.43821V42.9606C43.2377 46.0466 41.1803 49.5883 38.6465 50.8793L7.81304 66.5924C6.68768 67.1654 5.65898 67.1931 4.86502 66.7857C3.86393 66.2748 3.22876 65.0598 3.22876 63.3407V25.8183C3.22876 22.7323 5.28616 19.1907 7.81995 17.8996L38.6534 2.1865C39.7787 1.61348 40.8074 1.58587 41.6014 1.99319Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.0576 7.9715C54.2567 7.56417 53.228 7.59869 52.1095 8.16481L21.2761 23.8779C18.7423 25.169 16.6849 28.7175 16.6849 31.7966V69.319C16.6849 71.0381 17.3201 72.2462 18.3212 72.764L15.242 71.1969C14.2409 70.686 13.6057 69.4709 13.6057 67.7519V30.2295C13.6057 27.1435 15.6631 23.6018 18.1969 22.3108L49.0303 6.59763C50.1557 6.02461 51.1844 5.997 51.9784 6.40433L55.0576 7.9715Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.0575 7.97183C56.0585 8.48271 56.6937 9.69779 56.6937 11.4169V48.9392C56.6937 52.0252 54.6363 55.5669 52.1025 56.8579L21.2691 72.5711C20.1437 73.1441 19.115 73.1717 18.3211 72.7644C17.32 72.2535 16.6848 71.0384 16.6848 69.3194V31.797C16.6848 28.711 18.7422 25.1693 21.276 23.8783L52.1094 8.16514C53.2348 7.59212 54.2635 7.5645 55.0575 7.97183Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.5136 13.9233C67.7127 13.5159 66.684 13.5505 65.5656 14.1166L34.7322 29.8297C32.1984 31.1207 30.141 34.6693 30.141 37.7484V75.2708C30.141 76.9899 30.7761 78.198 31.7772 78.7158L28.698 77.1486C27.6969 76.6378 27.0618 75.4227 27.0618 73.7036V36.1813C27.0618 33.0952 29.1192 29.5536 31.6529 28.2626L62.4864 12.5494C63.6117 11.9764 64.6404 11.9488 65.4344 12.3561L68.5136 13.9233Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.5138 13.9236C69.5148 14.4345 70.15 15.6496 70.15 17.3686V54.891C70.15 57.977 68.0926 61.5187 65.5588 62.8097L34.7254 78.5228C33.6 79.0959 32.5713 79.1235 31.7774 78.7161C30.7763 78.2053 30.1411 76.9902 30.1411 75.2711V37.7488C30.1411 34.6627 32.1985 31.1211 34.7323 29.8301L65.5657 14.1169C66.6911 13.5439 67.7198 13.5163 68.5138 13.9236Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M47.256 51.7485C48.5195 49.5668 50.2386 47.8271 52.0198 46.9158C53.808 46.0045 55.5202 45.9976 56.7836 46.8882C58.0471 47.7857 58.7582 49.5047 58.7582 51.6794C58.7582 52.3284 58.3301 53.074 57.7916 53.3432C57.2531 53.6125 56.825 53.3087 56.825 52.6598C56.825 51.1064 56.321 49.8706 55.4166 49.2355C54.5122 48.5934 53.2902 48.6003 52.0129 49.2562C50.7357 49.9051 49.5137 51.1478 48.6092 52.7081C47.7048 54.2684 47.2008 56.015 47.2008 57.5684C47.2008 58.2174 46.7728 58.963 46.2412 59.2322C45.7095 59.5015 45.2815 59.1977 45.2815 58.5487C45.2815 56.374 45.9926 53.9232 47.256 51.7416V51.7485Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M52.0265 39.8808C50.4317 40.6954 49.1407 42.9254 49.1407 44.8654C49.1407 46.8053 50.4317 47.7236 52.0265 46.9089C53.6214 46.0943 54.9124 43.8643 54.9124 41.9243C54.9124 39.9844 53.6214 39.0661 52.0265 39.8808ZM47.2075 45.8526C47.2075 42.6147 49.3616 38.8935 52.0196 37.5404C54.6777 36.1872 56.8317 37.713 56.8317 40.944C56.8317 44.175 54.6777 47.9031 52.0196 49.2562C49.3616 50.6094 47.2075 49.0836 47.2075 45.8526Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M56.8389 37.4298C56.8389 36.7808 57.2669 36.0352 57.8054 35.766C58.9239 35.1999 59.9871 35.2137 60.7673 35.7867C61.5474 36.3666 61.9824 37.4574 61.9824 38.8244C61.9824 40.1913 61.5474 41.7309 60.7673 43.0979C60.7327 43.16 60.6982 43.2221 60.6637 43.2774C61.1125 43.3671 61.5336 43.5466 61.9064 43.8228C62.9904 44.6236 63.591 46.1424 63.591 48.041C63.591 48.69 63.163 49.4356 62.6245 49.7048C62.0859 49.9741 61.6648 49.6703 61.6648 49.0214C61.6648 47.7234 61.2506 46.6948 60.5256 46.1563C59.8007 45.6178 58.8272 45.6109 57.8123 46.1286C57.2807 46.3979 56.8458 46.0941 56.8458 45.4452C56.8458 44.7962 57.2738 44.0506 57.8123 43.7813C58.3992 43.4845 58.9653 42.8976 59.3934 42.152C59.8145 41.4064 60.0631 40.5503 60.0631 39.784C60.0631 39.0177 59.8214 38.4171 59.3934 38.0995C58.9722 37.7819 58.3992 37.7819 57.8123 38.0857C57.2807 38.3549 56.8458 38.0511 56.8458 37.4022L56.8389 37.4298Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M47.2076 42.3389C47.2076 41.6899 46.7796 41.3861 46.248 41.6554C45.1295 42.2215 44.0663 43.3192 43.2862 44.6931C42.506 46.0669 42.071 47.6065 42.071 48.9665C42.071 50.3266 42.506 51.4312 43.2862 52.0042C43.3207 52.0319 43.3552 52.0526 43.3897 52.0802C42.9409 52.6325 42.5198 53.24 42.147 53.8959C41.063 55.8014 40.4624 57.9346 40.4624 59.8332C40.4624 60.4822 40.8905 60.7859 41.4221 60.5167C41.9537 60.2474 42.3817 59.5018 42.3817 58.8528C42.3817 57.5549 42.796 56.1051 43.5209 54.8279C44.2458 53.5507 45.2193 52.5496 46.2342 52.0318C46.7658 51.7626 47.1938 51.017 47.1938 50.368C47.1938 49.7191 46.7658 49.4153 46.2342 49.6845C45.6473 49.9814 45.0812 49.9814 44.6531 49.6707C44.232 49.3532 43.9835 48.7525 43.9835 47.9862C43.9835 47.2199 44.2251 46.3707 44.6531 45.6251C45.0743 44.8795 45.6473 44.2996 46.2342 43.9958C46.7658 43.7265 47.1938 42.9809 47.1938 42.332L47.2076 42.3389Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function NoteHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="51"
|
||||
height="58"
|
||||
viewBox="0 0 51 58"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M30.145 1.45968C29.565 1.16468 28.82 1.18968 28.01 1.59968L5.68001 12.9797C3.84501 13.9147 2.355 16.4847 2.355 18.7147V45.8897C2.355 47.1347 2.81501 48.0097 3.54001 48.3847L1.31 47.2497C0.584998 46.8797 0.125 45.9997 0.125 44.7547V17.5797C0.125 15.3447 1.615 12.7797 3.45 11.8447L25.78 0.464675C26.595 0.0496749 27.34 0.0296755 27.915 0.324675L30.145 1.45968Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.145 1.45965C30.87 1.82965 31.33 2.70965 31.33 3.95465V31.1296C31.33 33.3646 29.84 35.9296 28.005 36.8646L5.67499 48.2447C4.85999 48.6597 4.11499 48.6796 3.53999 48.3847C2.81499 48.0147 2.35498 47.1347 2.35498 45.8897V18.7147C2.35498 16.4797 3.84499 13.9147 5.67999 12.9797L28.01 1.59965C28.825 1.18465 29.57 1.16465 30.145 1.45965Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M39.89 5.79467C39.31 5.49967 38.565 5.52467 37.755 5.93467L15.425 17.3147C13.59 18.2497 12.1 20.8197 12.1 23.0497V50.2247C12.1 51.4697 12.56 52.3447 13.285 52.7197L11.055 51.5847C10.33 51.2147 9.87 50.3347 9.87 49.0897V21.9147C9.87 19.6797 11.36 17.1147 13.195 16.1797L35.525 4.79967C36.34 4.38467 37.085 4.36467 37.66 4.65967L39.89 5.79467Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M39.89 5.79465C40.615 6.16465 41.075 7.04464 41.075 8.28964V35.4646C41.075 37.6996 39.585 40.2646 37.75 41.1996L15.42 52.5797C14.605 52.9947 13.86 53.0146 13.285 52.7197C12.56 52.3497 12.1 51.4696 12.1 50.2246V23.0497C12.1 20.8147 13.59 18.2496 15.425 17.3146L37.755 5.93465C38.57 5.51965 39.315 5.49965 39.89 5.79465Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M49.635 10.0997C49.055 9.80466 48.31 9.82966 47.5 10.2397L25.17 21.6197C23.335 22.5547 21.845 25.1247 21.845 27.3547V54.5297C21.845 55.7747 22.305 56.6497 23.03 57.0247L20.8 55.8897C20.075 55.5197 19.615 54.6397 19.615 53.3947V26.2197C19.615 23.9847 21.105 21.4197 22.94 20.4847L45.27 9.10466C46.085 8.68966 46.83 8.66966 47.405 8.96466L49.635 10.0997Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.635 10.0996C50.36 10.4696 50.82 11.3496 50.82 12.5946V39.7696C50.82 42.0046 49.33 44.5696 47.495 45.5046L25.165 56.8846C24.35 57.2996 23.605 57.3196 23.03 57.0246C22.305 56.6546 21.845 55.7746 21.845 54.5296V27.3546C21.845 25.1196 23.335 22.5546 25.17 21.6196L47.5 10.2396C48.315 9.82464 49.06 9.80464 49.635 10.0996Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M43.005 22.1696L31.91 27.8196C30.6 28.4896 29.535 30.3246 29.535 31.9196V45.4196C29.535 47.0146 30.6 47.7696 31.91 47.1046L40.625 42.6646C40.835 42.5546 41.04 42.3496 41.19 42.0947L45.15 35.2546C45.3 34.9996 45.38 34.7096 45.38 34.4496V23.8447C45.38 22.2497 44.31 21.4996 43 22.1646L43.005 22.1696ZM43.795 33.3346L41.42 34.5446C40.11 35.2146 39.04 37.0546 39.04 38.6496V41.5396L31.91 45.1747C31.475 45.3947 31.12 45.1496 31.12 44.6146V31.1146C31.12 30.5796 31.475 29.9696 31.91 29.7496L43.005 24.0996C43.445 23.8746 43.795 24.1247 43.795 24.6597V33.3396V33.3346Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
import type { TIllustrationAssetProps } from "../helper";
|
||||
|
||||
export function PriorityHorizontalStackIllustration({ className }: TIllustrationAssetProps) {
|
||||
return (
|
||||
<svg
|
||||
width="71"
|
||||
height="80"
|
||||
viewBox="0 0 71 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.5768 1.96789C40.776 1.56055 39.7472 1.59509 38.6288 2.16122L7.79538 17.8747C5.2616 19.1658 3.2042 22.7145 3.2042 25.7937V63.3171C3.2042 65.0362 3.83937 66.2444 4.84046 66.7622L1.76126 65.195C0.760173 64.6841 0.125 63.469 0.125 61.7499V24.2265C0.125 21.1404 2.1824 17.5986 4.71618 16.3076L35.5496 0.593996C36.6749 0.0209608 37.7037 -0.00667559 38.4976 0.400662L41.5768 1.96789Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.5769 1.9678C42.5779 2.4787 43.2131 3.6938 43.2131 5.41291V42.9363C43.2131 46.0224 41.1557 49.5642 38.6219 50.8552L7.7885 66.5688C6.66314 67.1418 5.63445 67.1695 4.84048 66.7621C3.8394 66.2512 3.20422 65.0361 3.20422 63.317V25.7936C3.20422 22.7075 5.26163 19.1657 7.79541 17.8746L38.6288 2.16113C39.7542 1.5881 40.7829 1.56046 41.5769 1.9678Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.0328 7.91223C54.2319 7.50489 53.2032 7.53943 52.0848 8.10556L21.2514 23.8191C18.7176 25.1102 16.6602 28.6589 16.6602 31.7381V69.2615C16.6602 70.9806 17.2954 72.1888 18.2965 72.7066L15.2173 71.1393C14.2162 70.6284 13.581 69.4133 13.581 67.6942V30.1708C13.581 27.0847 15.6384 23.543 18.1722 22.2519L49.0056 6.53833C50.131 5.9653 51.1597 5.93766 51.9536 6.345L55.0328 7.91223Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.0329 7.91214C56.034 8.42304 56.6691 9.63814 56.6691 11.3572V48.8806C56.6691 51.9668 54.6117 55.5085 52.0779 56.7996L21.2445 72.5131C20.1192 73.0862 19.0905 73.1138 18.2965 72.7065C17.2954 72.1956 16.6602 70.9805 16.6602 69.2614V31.738C16.6602 28.6519 18.7176 25.1101 21.2514 23.8191L52.0848 8.10547C53.2102 7.53244 54.2389 7.5048 55.0329 7.91214Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.4888 13.8636C67.6879 13.4563 66.6592 13.4908 65.5408 14.057L34.7073 29.7706C32.1736 31.0616 30.1162 34.6103 30.1162 37.6895V75.2129C30.1162 76.932 30.7513 78.1402 31.7524 78.658L28.6732 77.0908C27.6721 76.5798 27.037 75.3647 27.037 73.6456V36.1222C27.037 33.0361 29.0944 29.4944 31.6281 28.2033L62.4616 12.4897C63.5869 11.9167 64.6156 11.8891 65.4096 12.2964L68.4888 13.8636Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.4888 13.8636C69.4899 14.3745 70.1251 15.5896 70.1251 17.3087V54.8321C70.1251 57.9182 68.0677 61.4599 65.5339 62.751L34.7005 78.4646C33.5751 79.0376 32.5464 79.0652 31.7525 78.6579C30.7514 78.147 30.1162 76.9319 30.1162 75.2128V37.6894C30.1162 34.6033 32.1736 31.0615 34.7074 29.7705L65.5408 14.0569C66.6662 13.4839 67.6949 13.4562 68.4888 13.8636Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.primary}
|
||||
strokeWidth="0.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.0315 63.4071C40.3204 63.7662 39.7404 63.3657 39.7404 62.5027C39.7404 61.6397 40.3066 60.6455 41.0177 60.2865H41.0315C41.7426 59.9206 42.3156 60.328 42.3156 61.191C42.3156 62.054 41.7426 63.0481 41.0315 63.4071Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
d="M47.4453 60.1413C46.7342 60.5003 46.1612 60.0929 46.1612 59.2299V52.9749C46.1612 52.1119 46.7342 51.1176 47.4453 50.7586C48.1564 50.3996 48.7295 50.807 48.7295 51.67V57.925C48.7295 58.788 48.1564 59.7822 47.4453 60.1413Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
d="M53.873 56.8689C53.1619 57.2279 52.5888 56.8206 52.5888 55.9576V43.4474C52.5888 42.5844 53.1619 41.5903 53.873 41.2313C54.5841 40.8723 55.1571 41.2796 55.1571 42.1426V54.6527C55.1571 55.5157 54.5841 56.5099 53.873 56.8689Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
<path
|
||||
d="M60.2937 53.5896C59.5826 53.9486 59.0096 53.5412 59.0096 52.6782V33.9131C59.0096 33.0501 59.5826 32.0559 60.2937 31.6968C61.0048 31.3378 61.5779 31.7452 61.5779 32.6082V51.3733C61.5779 52.2363 61.0048 53.2306 60.2937 53.5896Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.quaternary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user