base
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
import { join, dirname } from "path";
|
||||
|
||||
/*
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string) {
|
||||
return dirname(require.resolve(join(value, "package.json")));
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||
addons: [getAbsolutePath("@storybook/addon-designs"), getAbsolutePath("@storybook/addon-docs")],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { addons } from "storybook/manager-api";
|
||||
import { create } from "storybook/theming";
|
||||
|
||||
const planeTheme = create({
|
||||
base: "dark",
|
||||
brandTitle: "Plane UI",
|
||||
brandUrl: "https://plane.so",
|
||||
brandImage: "plane-lockup-light.svg",
|
||||
brandTarget: "_self",
|
||||
});
|
||||
|
||||
addons.setConfig({
|
||||
theme: planeTheme,
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import "./tailwind.css";
|
||||
|
||||
const parameters: Preview["parameters"] = {
|
||||
controls: {
|
||||
matchers: {},
|
||||
},
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default preview;
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "@plane/tailwind-config/index.css";
|
||||
@import "../src/styles/react-day-picker.css";
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./accordion": "./dist/accordion/index.js",
|
||||
"./animated-counter": "./dist/animated-counter/index.js",
|
||||
"./avatar": "./dist/avatar/index.js",
|
||||
"./badge": "./dist/badge/index.js",
|
||||
"./banner": "./dist/banner/index.js",
|
||||
"./button": "./dist/button/index.js",
|
||||
"./calendar": "./dist/calendar/index.js",
|
||||
"./card": "./dist/card/index.js",
|
||||
"./charts/area-chart": "./dist/charts/area-chart/index.js",
|
||||
"./charts/bar-chart": "./dist/charts/bar-chart/index.js",
|
||||
"./charts/line-chart": "./dist/charts/line-chart/index.js",
|
||||
"./charts/pie-chart": "./dist/charts/pie-chart/index.js",
|
||||
"./charts/radar-chart": "./dist/charts/radar-chart/index.js",
|
||||
"./charts/scatter-chart": "./dist/charts/scatter-chart/index.js",
|
||||
"./charts/tree-map": "./dist/charts/tree-map/index.js",
|
||||
"./collapsible": "./dist/collapsible/index.js",
|
||||
"./combobox": "./dist/combobox/index.js",
|
||||
"./command": "./dist/command/index.js",
|
||||
"./context-menu": "./dist/context-menu/index.js",
|
||||
"./dialog": "./dist/dialog/index.js",
|
||||
"./emoji-icon-picker": "./dist/emoji-icon-picker/index.js",
|
||||
"./emoji-reaction": "./dist/emoji-reaction/index.js",
|
||||
"./empty-state": "./dist/empty-state/index.js",
|
||||
"./icon-button": "./dist/icon-button/index.js",
|
||||
"./icons": "./dist/icons/index.js",
|
||||
"./input": "./dist/input/index.js",
|
||||
"./menu": "./dist/menu/index.js",
|
||||
"./pill": "./dist/pill/index.js",
|
||||
"./popover": "./dist/popover/index.js",
|
||||
"./portal": "./dist/portal/index.js",
|
||||
"./scrollarea": "./dist/scrollarea/index.js",
|
||||
"./skeleton": "./dist/skeleton/index.js",
|
||||
"./switch": "./dist/switch/index.js",
|
||||
"./tab-navigation": "./dist/tab-navigation/index.js",
|
||||
"./table": "./dist/table/index.js",
|
||||
"./tabs": "./dist/tabs/index.js",
|
||||
"./toast": "./dist/toast/index.js",
|
||||
"./toolbar": "./dist/toolbar/index.js",
|
||||
"./tooltip": "./dist/tooltip/index.js",
|
||||
"./utils": "./dist/utils/index.js",
|
||||
"./package.json": "./package.json",
|
||||
"./styles/react-day-picker.css": "./dist/styles/react-day-picker.css",
|
||||
"./styles/react-day-picker": "./dist/styles/react-day-picker.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsdown --watch --no-clean",
|
||||
"build": "tsdown",
|
||||
"check:lint": "oxlint --max-warnings=3605 .",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "oxfmt --check .",
|
||||
"fix:lint": "oxlint --fix .",
|
||||
"fix:format": "oxfmt .",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.3",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.0",
|
||||
"frimousse": "^0.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "9.5.0",
|
||||
"react-dom": "catalog:",
|
||||
"recharts": "^2.15.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"use-font-face-observer": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@storybook/addon-designs": "10.0.2",
|
||||
"@storybook/addon-docs": "9.1.10",
|
||||
"@storybook/react-vite": "9.1.10",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"storybook": "9.1.19",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import postcssConfig from "@plane/tailwind-config/postcss.config.js";
|
||||
|
||||
export default postcssConfig;
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="253" height="53" viewBox="0 0 253 53" fill="#ffffff" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_27_76)">
|
||||
<path d="M217.077 51.4468H210.972L210.98 31.4317C210.717 27.7545 208.786 25.1545 205.03 24.6199C198.587 23.7006 194.663 27.8828 194.21 34.0258L194.218 51.4468H188.041V20.2543H192.158L194.255 26.8846H194.6C195.825 22.8701 198.684 20.4831 202.898 19.5757C210.126 18.0231 216.643 22.2724 217.079 29.8239V51.4468H217.077Z" fill="#ffffff"/>
|
||||
<path d="M163.812 19.7315C158.03 20.9566 153.67 25.2354 153.222 31.264H159.395C159.669 28.3484 161.561 25.9969 164.267 24.9553C167.418 23.7401 172.805 23.8881 175.178 26.6005C176.774 28.4253 177.305 31.5816 175.212 33.2249C172.675 35.2173 166.39 34.6117 163.214 34.7636C158.631 34.9826 154.141 36.8429 153.183 41.7727C151.1 52.5042 165.035 54.9267 171.902 49.8963C174.095 48.2885 175.672 45.8443 176.261 43.1792H176.577L178.788 51.4508H182.61V29.964C182.517 20.3214 171.462 18.108 163.81 19.7315H163.812ZM174.847 41.2637C172.484 45.649 166.958 49.0441 162.218 47.3318C158.44 45.9686 158.268 40.4095 161.973 38.9181C165.613 37.4524 173.732 38.9694 176.368 34.949C176.275 37.0855 175.906 39.2989 174.849 41.2637H174.847Z" fill="#ffffff"/>
|
||||
<path d="M113.082 35.5652V51.4475H106.611V9.00063H124.863C125.007 8.99274 125.141 9.06178 125.283 9.06967C133.732 9.53129 139.297 13.601 139.104 22.6025C138.928 30.7991 132.663 35.2199 124.934 35.5671H113.082V35.5652ZM113.082 14.4828V30.2941H124.088C129.595 29.8956 133.071 27.1022 132.491 21.265C131.967 15.9781 127.802 14.6544 123.107 14.4788C119.795 14.3546 116.402 14.5795 113.082 14.4808V14.4828Z" fill="#ffffff"/>
|
||||
<path d="M148.647 9H142.529V51.4468H148.647V9Z" fill="#ffffff"/>
|
||||
<path d="M245.872 42.418C244.911 44.4953 243.138 46.1603 240.934 46.8981C238.368 47.7562 234.479 47.5431 232.083 46.2569C228.67 44.4263 227.145 40.6485 227.137 36.8984H251.891C252.194 33.6908 251.902 30.3648 250.496 27.4333C247.083 20.3177 238.859 17.7236 231.513 19.9429C222.868 22.5568 220.165 31.4162 221.462 39.6543C222.86 48.5611 230.197 52.976 238.958 52.3585C244.884 51.9423 250.271 48.1192 251.788 42.4161H245.874L245.872 42.418ZM227.208 32.4381C227.778 26.2457 233.347 23.2018 239.163 24.4269C243.361 25.3107 245.75 28.9523 245.842 33.1068H227.145C227.145 33.1068 227.19 32.6255 227.208 32.4401V32.4381Z" fill="#ffffff"/>
|
||||
<path d="M44.3223 2.9264C44.3223 0.754665 46.6083 -0.65811 48.5508 0.313121L80.4551 16.2653C82.9294 17.5024 84.4922 20.0321 84.4922 22.7985V48.2487C84.4922 50.4204 82.2071 51.833 80.2646 50.8619L62.3281 41.8932V22.7975C62.3281 20.0311 60.7653 17.5015 58.291 16.2643L44.3223 9.27992V2.9264ZM0 2.92543C8.01645e-05 0.753753 2.28609 -0.659069 4.22852 0.312144L22.1582 9.27699V28.3766C22.1582 31.1428 23.7213 33.6716 26.1953 34.9088L40.1699 41.8952V48.2487C40.1697 50.4202 37.8847 51.832 35.9424 50.861L4.03711 34.9088C1.56305 33.6716 0 31.1428 0 28.3766V2.92543ZM22.1582 2.92543C22.1583 0.753753 24.4443 -0.659069 26.3867 0.312144L44.3223 9.27992V28.3776C44.3223 31.1439 45.8861 33.6727 48.3604 34.9098L62.3281 41.8932V48.2487C62.3279 50.4202 60.0429 51.832 58.1006 50.861L40.1699 41.8952V22.7975C40.1699 20.0311 38.6071 17.5015 36.1328 16.2643L22.1582 9.27699V2.92543Z" fill="#ffffff"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_27_76">
|
||||
<rect width="252" height="53" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user