Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
3
packages/propel/.eslintignore
Normal file
3
packages/propel/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
29
packages/propel/.eslintrc.js
Normal file
29
packages/propel/.eslintrc.js
Normal file
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js", "plugin:storybook/recommended"],
|
||||
rules: {
|
||||
"import/order": [
|
||||
"warn",
|
||||
{
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: "react",
|
||||
group: "external",
|
||||
position: "before",
|
||||
},
|
||||
{
|
||||
pattern: "@plane/**",
|
||||
group: "external",
|
||||
position: "after",
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
5
packages/propel/.prettierignore
Normal file
5
packages/propel/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
5
packages/propel/.prettierrc
Normal file
5
packages/propel/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
20
packages/propel/.storybook/main.ts
Normal file
20
packages/propel/.storybook/main.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
14
packages/propel/.storybook/manager.ts
Normal file
14
packages/propel/.storybook/manager.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
});
|
||||
15
packages/propel/.storybook/preview.ts
Normal file
15
packages/propel/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import "@plane/tailwind-config/global.css";
|
||||
import "../src/styles/react-day-picker.css";
|
||||
|
||||
const parameters: Preview["parameters"] = {
|
||||
controls: {
|
||||
matchers: {},
|
||||
},
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default preview;
|
||||
205
packages/propel/package.json
Normal file
205
packages/propel/package.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "tsdown --watch",
|
||||
"build": "tsdown",
|
||||
"check:lint": "eslint . --max-warnings 7",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"exports": {
|
||||
"./accordion": {
|
||||
"import": "./dist/accordion/index.mjs",
|
||||
"require": "./dist/accordion/index.js"
|
||||
},
|
||||
"./animated-counter": {
|
||||
"import": "./dist/animated-counter/index.mjs",
|
||||
"require": "./dist/animated-counter/index.js"
|
||||
},
|
||||
"./avatar": {
|
||||
"import": "./dist/avatar/index.mjs",
|
||||
"require": "./dist/avatar/index.js"
|
||||
},
|
||||
"./banner": {
|
||||
"import": "./dist/banner/index.mjs",
|
||||
"require": "./dist/banner/index.js"
|
||||
},
|
||||
"./button": {
|
||||
"import": "./dist/button/index.mjs",
|
||||
"require": "./dist/button/index.js"
|
||||
},
|
||||
"./calendar": {
|
||||
"import": "./dist/calendar/index.mjs",
|
||||
"require": "./dist/calendar/index.js"
|
||||
},
|
||||
"./card": {
|
||||
"import": "./dist/card/index.mjs",
|
||||
"require": "./dist/card/index.js"
|
||||
},
|
||||
"./charts/area-chart": {
|
||||
"import": "./dist/charts/area-chart/index.mjs",
|
||||
"require": "./dist/charts/area-chart/index.js"
|
||||
},
|
||||
"./charts/bar-chart": {
|
||||
"import": "./dist/charts/bar-chart/index.mjs",
|
||||
"require": "./dist/charts/bar-chart/index.js"
|
||||
},
|
||||
"./charts/line-chart": {
|
||||
"import": "./dist/charts/line-chart/index.mjs",
|
||||
"require": "./dist/charts/line-chart/index.js"
|
||||
},
|
||||
"./charts/pie-chart": {
|
||||
"import": "./dist/charts/pie-chart/index.mjs",
|
||||
"require": "./dist/charts/pie-chart/index.js"
|
||||
},
|
||||
"./charts/radar-chart": {
|
||||
"import": "./dist/charts/radar-chart/index.mjs",
|
||||
"require": "./dist/charts/radar-chart/index.js"
|
||||
},
|
||||
"./charts/scatter-chart": {
|
||||
"import": "./dist/charts/scatter-chart/index.mjs",
|
||||
"require": "./dist/charts/scatter-chart/index.js"
|
||||
},
|
||||
"./charts/tree-map": {
|
||||
"import": "./dist/charts/tree-map/index.mjs",
|
||||
"require": "./dist/charts/tree-map/index.js"
|
||||
},
|
||||
"./collapsible": {
|
||||
"import": "./dist/collapsible/index.mjs",
|
||||
"require": "./dist/collapsible/index.js"
|
||||
},
|
||||
"./combobox": {
|
||||
"import": "./dist/combobox/index.mjs",
|
||||
"require": "./dist/combobox/index.js"
|
||||
},
|
||||
"./command": {
|
||||
"import": "./dist/command/index.mjs",
|
||||
"require": "./dist/command/index.js"
|
||||
},
|
||||
"./context-menu": {
|
||||
"import": "./dist/context-menu/index.mjs",
|
||||
"require": "./dist/context-menu/index.js"
|
||||
},
|
||||
"./dialog": {
|
||||
"import": "./dist/dialog/index.mjs",
|
||||
"require": "./dist/dialog/index.js"
|
||||
},
|
||||
"./emoji-icon-picker": {
|
||||
"import": "./dist/emoji-icon-picker/index.mjs",
|
||||
"require": "./dist/emoji-icon-picker/index.js"
|
||||
},
|
||||
"./emoji-reaction": {
|
||||
"import": "./dist/emoji-reaction/index.mjs",
|
||||
"require": "./dist/emoji-reaction/index.js"
|
||||
},
|
||||
"./empty-state": {
|
||||
"import": "./dist/empty-state/index.mjs",
|
||||
"require": "./dist/empty-state/index.js"
|
||||
},
|
||||
"./icons": {
|
||||
"import": "./dist/icons/index.mjs",
|
||||
"require": "./dist/icons/index.js"
|
||||
},
|
||||
"./input": {
|
||||
"import": "./dist/input/index.mjs",
|
||||
"require": "./dist/input/index.js"
|
||||
},
|
||||
"./menu": {
|
||||
"import": "./dist/menu/index.mjs",
|
||||
"require": "./dist/menu/index.js"
|
||||
},
|
||||
"./pill": {
|
||||
"import": "./dist/pill/index.mjs",
|
||||
"require": "./dist/pill/index.js"
|
||||
},
|
||||
"./popover": {
|
||||
"import": "./dist/popover/index.mjs",
|
||||
"require": "./dist/popover/index.js"
|
||||
},
|
||||
"./portal": {
|
||||
"import": "./dist/portal/index.mjs",
|
||||
"require": "./dist/portal/index.js"
|
||||
},
|
||||
"./scrollarea": {
|
||||
"import": "./dist/scrollarea/index.mjs",
|
||||
"require": "./dist/scrollarea/index.js"
|
||||
},
|
||||
"./skeleton": {
|
||||
"import": "./dist/skeleton/index.mjs",
|
||||
"require": "./dist/skeleton/index.js"
|
||||
},
|
||||
"./switch": {
|
||||
"import": "./dist/switch/index.mjs",
|
||||
"require": "./dist/switch/index.js"
|
||||
},
|
||||
"./table": {
|
||||
"import": "./dist/table/index.mjs",
|
||||
"require": "./dist/table/index.js"
|
||||
},
|
||||
"./tabs": {
|
||||
"import": "./dist/tabs/index.mjs",
|
||||
"require": "./dist/tabs/index.js"
|
||||
},
|
||||
"./toast": {
|
||||
"import": "./dist/toast/index.mjs",
|
||||
"require": "./dist/toast/index.js"
|
||||
},
|
||||
"./toolbar": {
|
||||
"import": "./dist/toolbar/index.mjs",
|
||||
"require": "./dist/toolbar/index.js"
|
||||
},
|
||||
"./tooltip": {
|
||||
"import": "./dist/tooltip/index.mjs",
|
||||
"require": "./dist/tooltip/index.js"
|
||||
},
|
||||
"./utils": {
|
||||
"import": "./dist/utils/index.mjs",
|
||||
"require": "./dist/utils/index.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./styles/fonts.css": "./dist/styles/fonts/index.css",
|
||||
"./styles/fonts": "./dist/styles/fonts/index.css",
|
||||
"./styles/react-day-picker.css": "./dist/styles/react-day-picker.css",
|
||||
"./styles/react-day-picker": "./dist/styles/react-day-picker.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.3",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"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.3.1",
|
||||
"use-font-face-observer": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@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:",
|
||||
"eslint-plugin-storybook": "9.1.10",
|
||||
"storybook": "9.1.10",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
2
packages/propel/postcss.config.js
Normal file
2
packages/propel/postcss.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
module.exports = require("@plane/tailwind-config/postcss.config.js");
|
||||
16
packages/propel/public/plane-lockup-light.svg
Normal file
16
packages/propel/public/plane-lockup-light.svg
Normal file
@@ -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 |
198
packages/propel/src/accordion/accordion.stories.tsx
Normal file
198
packages/propel/src/accordion/accordion.stories.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
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="w-full rounded-md bg-blue-500 px-4 py-2 text-left text-white hover:bg-blue-600">
|
||||
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="w-full rounded-md bg-green-500 px-4 py-2 text-left text-white hover:bg-green-600">
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
88
packages/propel/src/accordion/accordion.tsx
Normal file
88
packages/propel/src/accordion/accordion.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
import { Accordion as BaseAccordion } from "@base-ui-components/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const AccordionRoot: React.FC<AccordionRootProps> = ({
|
||||
defaultValue = [],
|
||||
allowMultiple = false,
|
||||
className = "",
|
||||
children,
|
||||
}) => (
|
||||
<BaseAccordion.Root defaultValue={defaultValue} openMultiple={allowMultiple} className={`text-base ${className}`}>
|
||||
{children}
|
||||
</BaseAccordion.Root>
|
||||
);
|
||||
|
||||
const AccordionItem: React.FC<AccordionItemProps> = ({ value, disabled, className = "", children }) => (
|
||||
<BaseAccordion.Item value={value} disabled={disabled} className={`relative ${className}`}>
|
||||
{children}
|
||||
</BaseAccordion.Item>
|
||||
);
|
||||
|
||||
const AccordionTrigger: React.FC<AccordionTriggerProps> = ({
|
||||
className = "",
|
||||
icon = <PlusIcon aria-hidden="true" className="transition-all ease-out group-data-[panel-open]:rotate-45" />,
|
||||
iconClassName = "",
|
||||
children,
|
||||
asChild = false,
|
||||
}) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const AccordionContent: React.FC<AccordionContentProps> = ({
|
||||
className = "",
|
||||
contentWrapperClassName = "",
|
||||
children,
|
||||
}) => (
|
||||
<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,
|
||||
};
|
||||
1
packages/propel/src/accordion/index.ts
Normal file
1
packages/propel/src/accordion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./accordion";
|
||||
@@ -0,0 +1,333 @@
|
||||
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="px-4 py-2 bg-red-500 text-white font-medium rounded-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 transition-colors shadow-md"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1))}
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<div className="flex items-center justify-center min-w-[60px] h-12 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<AnimatedCounter {...args} count={count} />
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2 transition-colors shadow-md"
|
||||
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="px-3 py-1 bg-custom-background-80 text-sm rounded hover:bg-custom-background-90"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1))}
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 bg-custom-background-80 text-sm rounded hover:bg-custom-background-90"
|
||||
onClick={() => setCount((prev) => prev + 1)}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-custom-text-400 w-20">Small:</span>
|
||||
<div className="flex items-center justify-center min-w-[40px] h-8 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<AnimatedCounter count={count} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-custom-text-400 w-20">Medium:</span>
|
||||
<div className="flex items-center justify-center min-w-[50px] h-10 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<AnimatedCounter count={count} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-custom-text-400 w-20">Large:</span>
|
||||
<div className="flex items-center justify-center min-w-[60px] h-12 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<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="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600"
|
||||
onClick={() => setCount((prev) => Math.max(0, prev - 1000))}
|
||||
>
|
||||
-1000
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600"
|
||||
onClick={() => setCount((prev) => prev + 1000)}
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-w-[100px] h-12 bg-custom-background-80 border border-custom-border-200 rounded-lg">
|
||||
<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 items-center justify-center min-w-[60px] h-16 bg-custom-background-80 border-2 border-custom-border-200 rounded-lg">
|
||||
<AnimatedCounter count={count} size="lg" className="text-2xl" />
|
||||
</div>
|
||||
<button
|
||||
className="px-6 py-2 bg-custom-primary-100 text-white font-medium rounded-lg hover:bg-custom-primary-200"
|
||||
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 items-center justify-center min-w-[80px] h-16 bg-custom-background-80 border-2 border-custom-border-200 rounded-lg">
|
||||
<AnimatedCounter count={count} size="lg" className="text-2xl" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-4 py-2 bg-green-500 text-white font-medium rounded hover:bg-green-600"
|
||||
onClick={() => setIsRunning(true)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white font-medium rounded hover:bg-red-600"
|
||||
onClick={() => setIsRunning(false)}
|
||||
disabled={!isRunning}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-500 text-white font-medium rounded hover:bg-gray-600"
|
||||
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 border border-custom-border-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">Engagement Stats</h3>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<div className="text-custom-text-400 text-sm">Likes</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setLikes((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex items-center justify-center min-w-[40px] h-10 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<AnimatedCounter count={likes} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<div className="text-custom-text-400 text-sm">Comments</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setComments((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex items-center justify-center min-w-[40px] h-10 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<AnimatedCounter count={comments} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<div className="text-custom-text-400 text-sm">Shares</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setShares((prev) => prev + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex items-center justify-center min-w-[40px] h-10 bg-custom-background-80 border border-custom-border-200 rounded">
|
||||
<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="px-4 py-2 bg-custom-background-80 border border-custom-border-200 rounded-lg">
|
||||
Notifications
|
||||
</button>
|
||||
<div className="absolute -top-2 -right-2 min-w-[24px] h-6 flex items-center justify-center bg-red-500 text-white rounded-full px-1.5">
|
||||
<AnimatedCounter count={notifications} size="sm" className="text-xs font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 bg-custom-primary-100 text-white rounded hover:bg-custom-primary-200"
|
||||
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 items-center justify-center min-w-[60px] h-12 bg-custom-background-80 border border-custom-border-200 rounded-lg">
|
||||
<AnimatedCounter count={count} size="lg" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-4 py-2 bg-custom-primary-100 text-white rounded hover:bg-custom-primary-200"
|
||||
onClick={incrementFast}
|
||||
>
|
||||
+10 Fast
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setCount(0)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
95
packages/propel/src/animated-counter/animated-counter.tsx
Normal file
95
packages/propel/src/animated-counter/animated-counter.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface AnimatedCounterProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
};
|
||||
|
||||
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, className, size = "md" }) => {
|
||||
// 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 items-center justify-center overflow-hidden min-w-2", sizeClass)}>
|
||||
{/* Previous number sliding out */}
|
||||
{isAnimating && (
|
||||
<span
|
||||
key={`prev-${animationKey}`}
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center font-medium",
|
||||
"animate-[slideOut_0.25s_ease-out_forwards]",
|
||||
direction === "up" && "[--slide-out-dir:-100%]",
|
||||
direction === "down" && "[--slide-out-dir:100%]",
|
||||
sizeClass
|
||||
)}
|
||||
style={{
|
||||
animation:
|
||||
direction === "up"
|
||||
? "slideOut 0.25s ease-out forwards, fadeOut 0.25s ease-out forwards"
|
||||
: "slideOutDown 0.25s ease-out forwards, fadeOut 0.25s ease-out forwards",
|
||||
}}
|
||||
>
|
||||
{prevCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* New number sliding in */}
|
||||
<span
|
||||
key={`current-${animationKey}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center font-medium",
|
||||
isAnimating && "animate-[slideIn_0.25s_ease-out_forwards]",
|
||||
!isAnimating && "opacity-100",
|
||||
sizeClass,
|
||||
className
|
||||
)}
|
||||
style={
|
||||
isAnimating
|
||||
? {
|
||||
animation:
|
||||
direction === "up"
|
||||
? "slideInFromBottom 0.25s ease-out forwards"
|
||||
: "slideInFromTop 0.25s ease-out forwards",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
packages/propel/src/animated-counter/index.ts
Normal file
2
packages/propel/src/animated-counter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AnimatedCounter } from "./animated-counter";
|
||||
export type { AnimatedCounterProps } from "./animated-counter";
|
||||
171
packages/propel/src/avatar/avatar.stories.tsx
Normal file
171
packages/propel/src/avatar/avatar.stories.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
119
packages/propel/src/avatar/avatar.tsx
Normal file
119
packages/propel/src/avatar/avatar.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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-xs",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "md":
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-xs",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "base":
|
||||
return {
|
||||
avatarSize: "h-6 w-6",
|
||||
fontSize: "text-sm",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
case "lg":
|
||||
return {
|
||||
avatarSize: "h-7 w-7",
|
||||
fontSize: "text-sm",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-xs",
|
||||
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";
|
||||
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 const Avatar: React.FC<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 ?? "rgba(var(--color-primary-500))",
|
||||
color: fallbackTextColor ?? "#ffffff",
|
||||
}}
|
||||
>
|
||||
{fallbackLetter}
|
||||
</AvatarPrimitive.Fallback>
|
||||
</AvatarPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
packages/propel/src/avatar/index.ts
Normal file
1
packages/propel/src/avatar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./avatar";
|
||||
181
packages/propel/src/banner/banner.stories.tsx
Normal file
181
packages/propel/src/banner/banner.stories.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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
|
||||
const SuccessIcon = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-green-600"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ErrorIcon = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-red-600"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
const WarningIcon = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const InfoIcon = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const CloseButton = ({ onClick }: { onClick?: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
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 />,
|
||||
},
|
||||
};
|
||||
131
packages/propel/src/banner/banner.tsx
Normal file
131
packages/propel/src/banner/banner.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import { cn } from "../utils";
|
||||
import {
|
||||
TBannerVariant,
|
||||
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<HTMLDivElement, BannerProps>(
|
||||
(
|
||||
{
|
||||
icon,
|
||||
title,
|
||||
action,
|
||||
variant = "info",
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
visible = true,
|
||||
animationDuration = 200,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 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 items-center gap-3 flex-1 min-w-0">
|
||||
{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;
|
||||
46
packages/propel/src/banner/helper.tsx
Normal file
46
packages/propel/src/banner/helper.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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-sm",
|
||||
action: "gap-2",
|
||||
};
|
||||
|
||||
// TODO: update this with new color once its implemented
|
||||
// Banner variant styling
|
||||
export const bannerStyling: IBannerStyling = {
|
||||
success: "bg-green-500/10",
|
||||
error: "bg-red-500/10",
|
||||
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-custom-text-200 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 p-1 hover:bg-custom-background-90 transition-colors flex-shrink-0";
|
||||
|
||||
// Get dismiss icon styling
|
||||
export const getBannerDismissIconStyling = (): string => "text-custom-text-200";
|
||||
3
packages/propel/src/banner/index.ts
Normal file
3
packages/propel/src/banner/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Banner } from "./banner";
|
||||
export type { BannerProps, BannerVariant } from "./banner";
|
||||
export type { TBannerVariant } from "./helper";
|
||||
245
packages/propel/src/button/button.stories.tsx
Normal file
245
packages/propel/src/button/button.stories.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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 AccentPrimary: Story = {
|
||||
args: {
|
||||
variant: "accent-primary",
|
||||
children: "Accent Primary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const OutlinePrimary: Story = {
|
||||
args: {
|
||||
variant: "outline-primary",
|
||||
children: "Outline Primary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const NeutralPrimary: Story = {
|
||||
args: {
|
||||
variant: "neutral-primary",
|
||||
children: "Neutral Primary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const LinkPrimary: Story = {
|
||||
args: {
|
||||
variant: "link-primary",
|
||||
children: "Link Primary Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: "danger",
|
||||
children: "Danger Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const AccentDanger: Story = {
|
||||
args: {
|
||||
variant: "accent-danger",
|
||||
children: "Accent Danger Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const OutlineDanger: Story = {
|
||||
args: {
|
||||
variant: "outline-danger",
|
||||
children: "Outline Danger Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const LinkDanger: Story = {
|
||||
args: {
|
||||
variant: "link-danger",
|
||||
children: "Link Danger Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const TertiaryDanger: Story = {
|
||||
args: {
|
||||
variant: "tertiary-danger",
|
||||
children: "Tertiary Danger Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const LinkNeutral: Story = {
|
||||
args: {
|
||||
variant: "link-neutral",
|
||||
children: "Link Neutral Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
children: "Small Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: "md",
|
||||
children: "Medium 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-lg font-semibold">Primary Variants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="accent-primary">Accent Primary</Button>
|
||||
<Button variant="outline-primary">Outline Primary</Button>
|
||||
<Button variant="neutral-primary">Neutral Primary</Button>
|
||||
<Button variant="link-primary">Link Primary</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Danger Variants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="accent-danger">Accent Danger</Button>
|
||||
<Button variant="outline-danger">Outline Danger</Button>
|
||||
<Button variant="link-danger">Link Danger</Button>
|
||||
<Button variant="tertiary-danger">Tertiary Danger</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Other Variants</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="link-neutral">Link Neutral</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="md">Medium</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-lg 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
44
packages/propel/src/button/button.tsx
Normal file
44
packages/propel/src/button/button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: TButtonVariant;
|
||||
size?: TButtonSizes;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
appendIcon?: any;
|
||||
prependIcon?: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
const {
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
prependIcon = null,
|
||||
appendIcon = null,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonStyle = getButtonStyling(variant, size, disabled || loading);
|
||||
const buttonIconStyle = getIconStyling(size);
|
||||
|
||||
return (
|
||||
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
|
||||
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
||||
{children}
|
||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "plane-ui-button";
|
||||
|
||||
export { Button };
|
||||
126
packages/propel/src/button/helper.tsx
Normal file
126
packages/propel/src/button/helper.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
export type TButtonVariant =
|
||||
| "primary"
|
||||
| "accent-primary"
|
||||
| "outline-primary"
|
||||
| "neutral-primary"
|
||||
| "link-primary"
|
||||
| "danger"
|
||||
| "accent-danger"
|
||||
| "outline-danger"
|
||||
| "link-danger"
|
||||
| "tertiary-danger"
|
||||
| "link-neutral";
|
||||
|
||||
export type TButtonSizes = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export interface IButtonStyling {
|
||||
[key: string]: {
|
||||
default: string;
|
||||
hover: string;
|
||||
pressed: string;
|
||||
disabled: string;
|
||||
};
|
||||
}
|
||||
|
||||
enum buttonSizeStyling {
|
||||
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
}
|
||||
|
||||
enum buttonIconStyling {
|
||||
sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0 ",
|
||||
}
|
||||
|
||||
export const buttonStyling: IButtonStyling = {
|
||||
primary: {
|
||||
default: `text-white bg-custom-primary-100`,
|
||||
hover: `hover:bg-custom-primary-200`,
|
||||
pressed: `focus:text-custom-brand-40 focus:bg-custom-primary-200`,
|
||||
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
|
||||
},
|
||||
"accent-primary": {
|
||||
default: `bg-custom-primary-100/20 text-custom-primary-100`,
|
||||
hover: `hover:bg-custom-primary-100/10 hover:text-custom-primary-200`,
|
||||
pressed: `focus:bg-custom-primary-100/10`,
|
||||
disabled: `cursor-not-allowed !text-custom-primary-60`,
|
||||
},
|
||||
"outline-primary": {
|
||||
default: `text-custom-primary-100 bg-transparent border border-custom-primary-100`,
|
||||
hover: `hover:bg-custom-primary-100/20`,
|
||||
pressed: `focus:text-custom-primary-100 focus:bg-custom-primary-100/30`,
|
||||
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
|
||||
},
|
||||
"neutral-primary": {
|
||||
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
|
||||
hover: `hover:bg-custom-background-90`,
|
||||
pressed: `focus:text-custom-text-300 focus:bg-custom-background-90`,
|
||||
disabled: `cursor-not-allowed !text-custom-text-400`,
|
||||
},
|
||||
"link-primary": {
|
||||
default: `text-custom-primary-100 bg-custom-background-100`,
|
||||
hover: `hover:text-custom-primary-200`,
|
||||
pressed: `focus:text-custom-primary-80 `,
|
||||
disabled: `cursor-not-allowed !text-custom-primary-60`,
|
||||
},
|
||||
|
||||
danger: {
|
||||
default: `text-white bg-red-500`,
|
||||
hover: ` hover:bg-red-600`,
|
||||
pressed: `focus:text-red-200 focus:bg-red-600`,
|
||||
disabled: `cursor-not-allowed !bg-red-300`,
|
||||
},
|
||||
"accent-danger": {
|
||||
default: `text-red-500 bg-red-50`,
|
||||
hover: `hover:text-red-600 hover:bg-red-100`,
|
||||
pressed: `focus:text-red-500 focus:bg-red-100`,
|
||||
disabled: `cursor-not-allowed !text-red-300`,
|
||||
},
|
||||
"outline-danger": {
|
||||
default: `text-red-500 bg-transparent border border-red-500`,
|
||||
hover: `hover:text-red-400 hover:border-red-400`,
|
||||
pressed: `focus:text-red-400 focus:border-red-400`,
|
||||
disabled: `cursor-not-allowed !text-red-300 !border-red-300`,
|
||||
},
|
||||
"link-danger": {
|
||||
default: `text-red-500 bg-custom-background-100`,
|
||||
hover: `hover:text-red-400`,
|
||||
pressed: `focus:text-red-400`,
|
||||
disabled: `cursor-not-allowed !text-red-300`,
|
||||
},
|
||||
"tertiary-danger": {
|
||||
default: `text-red-500 bg-custom-background-100 border border-red-200`,
|
||||
hover: `hover:bg-red-50 hover:border-red-300`,
|
||||
pressed: `focus:text-red-400`,
|
||||
disabled: `cursor-not-allowed !text-red-300`,
|
||||
},
|
||||
"link-neutral": {
|
||||
default: `text-custom-text-300`,
|
||||
hover: `hover:text-custom-text-200`,
|
||||
pressed: `focus:text-custom-text-100`,
|
||||
disabled: `cursor-not-allowed !text-custom-text-400`,
|
||||
},
|
||||
};
|
||||
|
||||
export const getButtonStyling = (variant: TButtonVariant, size: TButtonSizes, disabled: boolean = false): string => {
|
||||
let tempVariant: string = ``;
|
||||
const currentVariant = buttonStyling[variant];
|
||||
|
||||
tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover} ${
|
||||
currentVariant.pressed
|
||||
}`;
|
||||
|
||||
let tempSize: string = ``;
|
||||
if (size) tempSize = buttonSizeStyling[size];
|
||||
return `${tempVariant} ${tempSize}`;
|
||||
};
|
||||
|
||||
export const getIconStyling = (size: TButtonSizes): string => {
|
||||
let icon: string = ``;
|
||||
if (size) icon = buttonIconStyling[size];
|
||||
return icon;
|
||||
};
|
||||
3
packages/propel/src/button/index.ts
Normal file
3
packages/propel/src/button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Button } from "./button";
|
||||
export * from "./helper";
|
||||
export type { ButtonProps } from "./button";
|
||||
194
packages/propel/src/calendar/calendar.stories.tsx
Normal file
194
packages/propel/src/calendar/calendar.stories.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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(args) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Calendar {...args} mode="single" defaultMonth={new Date(2024, 0)} className="rounded-md border" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
2
packages/propel/src/calendar/index.ts
Normal file
2
packages/propel/src/calendar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./root";
|
||||
export type { Matcher, DateRange } from "react-day-picker";
|
||||
38
packages/propel/src/calendar/root.tsx
Normal file
38
packages/propel/src/calendar/root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { ChevronLeftIcon } from "../icons";
|
||||
|
||||
import { cn } from "../utils";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
export const 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)}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
211
packages/propel/src/card/card.stories.tsx
Normal file
211
packages/propel/src/card/card.stories.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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-lg font-semibold">Card Title</h3>
|
||||
<p className="text-sm text-gray-600">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-lg font-semibold">Card with Shadow</h3>
|
||||
<p className="text-sm text-gray-600">Hover over this card to see the shadow effect.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutShadow: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITHOUT_SHADOW,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">Card without Shadow</h3>
|
||||
<p className="text-sm text-gray-600">This card has no shadow effect on hover.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallSpacing: Story = {
|
||||
args: {
|
||||
spacing: ECardSpacing.SM,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">Small Spacing</h3>
|
||||
<p className="text-sm text-gray-600">This card uses small spacing (p-4).</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSpacing: Story = {
|
||||
args: {
|
||||
spacing: ECardSpacing.LG,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">Large Spacing</h3>
|
||||
<p className="text-sm text-gray-600">This card uses large spacing (p-6).</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ColumnDirection: Story = {
|
||||
args: {
|
||||
direction: ECardDirection.COLUMN,
|
||||
children: (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">Column Direction</h3>
|
||||
<p className="text-sm text-gray-600">Content is arranged vertically.</p>
|
||||
<button className="rounded bg-blue-500 px-4 py-2 text-white">Action</button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const RowDirection: Story = {
|
||||
args: {
|
||||
direction: ECardDirection.ROW,
|
||||
children: (
|
||||
<>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded bg-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Row Direction</h3>
|
||||
<p className="text-sm text-gray-600">Content is arranged horizontally.</p>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProductCard: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITH_SHADOW,
|
||||
spacing: ECardSpacing.LG,
|
||||
direction: ECardDirection.COLUMN,
|
||||
children: (
|
||||
<>
|
||||
<div className="h-48 w-full rounded bg-gray-200" />
|
||||
<h3 className="text-xl font-bold">Product Name</h3>
|
||||
<p className="text-sm text-gray-600">A brief description of the product goes here.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-semibold">$99.99</span>
|
||||
<button className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">Add to Cart</button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const UserCard: Story = {
|
||||
args: {
|
||||
variant: ECardVariant.WITH_SHADOW,
|
||||
spacing: ECardSpacing.LG,
|
||||
direction: ECardDirection.ROW,
|
||||
children: (
|
||||
<>
|
||||
<div className="h-16 w-16 flex-shrink-0 rounded-full bg-blue-500" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">John Doe</h3>
|
||||
<p className="text-sm text-gray-600">Software Engineer</p>
|
||||
<p className="text-xs text-gray-500">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-xs text-gray-500">2m ago</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">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-sm text-gray-600">Hover to see the shadow effect</p>
|
||||
</Card>
|
||||
<Card variant={ECardVariant.WITHOUT_SHADOW}>
|
||||
<h3 className="font-semibold">Without Shadow</h3>
|
||||
<p className="text-sm text-gray-600">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-sm text-gray-600">Compact padding</p>
|
||||
</Card>
|
||||
<Card spacing={ECardSpacing.LG}>
|
||||
<h3 className="font-semibold">Large Spacing (p-6)</h3>
|
||||
<p className="text-sm text-gray-600">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-sm text-gray-600">Vertical layout</p>
|
||||
<button className="w-fit rounded bg-blue-500 px-4 py-2 text-white">Button</button>
|
||||
</Card>
|
||||
<Card direction={ECardDirection.ROW}>
|
||||
<div className="h-12 w-12 flex-shrink-0 rounded bg-blue-500" />
|
||||
<div>
|
||||
<h3 className="font-semibold">Row Direction</h3>
|
||||
<p className="text-sm text-gray-600">Horizontal layout</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
41
packages/propel/src/card/card.tsx
Normal file
41
packages/propel/src/card/card.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils/classname";
|
||||
import {
|
||||
ECardDirection,
|
||||
ECardSpacing,
|
||||
ECardVariant,
|
||||
getCardStyle,
|
||||
TCardDirection,
|
||||
TCardSpacing,
|
||||
TCardVariant,
|
||||
} from "./helper";
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: TCardVariant;
|
||||
spacing?: TCardSpacing;
|
||||
direction?: TCardDirection;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>((props, ref) => {
|
||||
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 };
|
||||
36
packages/propel/src/card/helper.tsx
Normal file
36
packages/propel/src/card/helper.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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-custom-background-100 rounded-lg border-[0.5px] border-custom-border-200 w-full flex flex-col";
|
||||
export const containerStyle: ICardProperties = {
|
||||
[ECardVariant.WITHOUT_SHADOW]: "",
|
||||
[ECardVariant.WITH_SHADOW]: "hover:shadow-custom-shadow-4xl 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];
|
||||
1
packages/propel/src/card/index.ts
Normal file
1
packages/propel/src/card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./card";
|
||||
1
packages/propel/src/charts/area-chart/index.ts
Normal file
1
packages/propel/src/charts/area-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
205
packages/propel/src/charts/area-chart/root.tsx
Normal file
205
packages/propel/src/charts/area-chart/root.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
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 { 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(<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="rgba(var(--color-border-100), 0.8)" 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: "rgba(var(--color-text-300))",
|
||||
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";
|
||||
184
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
184
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { 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
|
||||
|
||||
// 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
|
||||
`;
|
||||
|
||||
const PercentageText = ({
|
||||
x,
|
||||
y,
|
||||
percentage,
|
||||
className,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
|
||||
{percentage}%
|
||||
</text>
|
||||
);
|
||||
|
||||
// Base Components
|
||||
const CustomBar = React.memo((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)}
|
||||
className="transition-opacity duration-200"
|
||||
fill={fill}
|
||||
opacity={opacity}
|
||||
/>
|
||||
{showText && (
|
||||
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
const CustomBarLollipop = React.memo((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 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";
|
||||
1
packages/propel/src/charts/bar-chart/index.ts
Normal file
1
packages/propel/src/charts/bar-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
209
packages/propel/src/charts/bar-chart/root.tsx
Normal file
209
packages/propel/src/charts/bar-chart/root.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
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 { TBarChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
import { barShapeVariants } from "./bar";
|
||||
|
||||
const DEFAULT_BAR_FILL_COLOR = "#000000";
|
||||
|
||||
export const BarChart = React.memo(<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="rgba(var(--color-border-100), 0.8)" 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: "currentColor",
|
||||
className: "text-custom-background-90/80 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";
|
||||
78
packages/propel/src/charts/components/legend.tsx
Normal file
78
packages/propel/src/charts/components/legend.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { LegendProps } from "recharts";
|
||||
// plane imports
|
||||
import { 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<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
|
||||
TChartLegend
|
||||
>((props, ref) => {
|
||||
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
|
||||
|
||||
if (!payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center px-4 overflow-scroll vertical-scrollbar scrollbar-sm", {
|
||||
"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-custom-text-300 text-sm font-medium whitespace-nowrap", {
|
||||
"px-2": layout === "horizontal",
|
||||
"py-2": layout === "vertical",
|
||||
"pl-0 pt-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="flex-shrink-0 size-2 rounded-sm"
|
||||
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";
|
||||
45
packages/propel/src/charts/components/tick.tsx
Normal file
45
packages/propel/src/charts/components/tick.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
|
||||
// Common classnames
|
||||
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
|
||||
|
||||
export const CustomXAxisTick = React.memo<any>(({ x, y, payload, getLabel }: any) => (
|
||||
<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<any>(({ x, y, payload }: any) => (
|
||||
<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<any>(({ 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";
|
||||
59
packages/propel/src/charts/components/tooltip.tsx
Normal file
59
packages/propel/src/charts/components/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { 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((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="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||
spacing={ECardSpacing.SM}
|
||||
>
|
||||
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
|
||||
{label}
|
||||
</p>
|
||||
{filteredPayload.map((item) => {
|
||||
if (!item.dataKey) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.dataKey}
|
||||
className={cn("flex items-center gap-2 text-xs transition-opacity", {
|
||||
"opacity-20": activeKey && item.dataKey !== activeKey,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{itemDotColors[item?.dataKey] && (
|
||||
<div
|
||||
className="flex-shrink-0 size-2 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: itemDotColors[item?.dataKey],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-custom-text-300 truncate">{itemLabels[item?.dataKey]}:</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
1
packages/propel/src/charts/line-chart/index.ts
Normal file
1
packages/propel/src/charts/line-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
181
packages/propel/src/charts/line-chart/root.tsx
Normal file
181
packages/propel/src/charts/line-chart/root.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
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 { 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(<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="rgba(var(--color-border-100), 0.8)" 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: "rgba(var(--color-text-300))",
|
||||
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";
|
||||
32
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal file
32
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Sector } from "recharts";
|
||||
import { PieSectorDataItem } from "recharts/types/polar/Pie";
|
||||
|
||||
export const CustomActiveShape = React.memo((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>
|
||||
);
|
||||
});
|
||||
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
151
packages/propel/src/charts/pie-chart/root.tsx
Normal file
151
packages/propel/src/charts/pie-chart/root.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { 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(<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="rgba(var(--color-text-200))"
|
||||
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: "text-custom-background-90/80 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";
|
||||
40
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
40
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { 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((props: Props) => {
|
||||
const { dotColor, label, payload } = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||
spacing={ECardSpacing.SM}
|
||||
>
|
||||
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
|
||||
{label}
|
||||
</p>
|
||||
{payload?.map((item) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<div
|
||||
className="flex-shrink-0 size-2 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: dotColor,
|
||||
}}
|
||||
/>
|
||||
<span className="text-custom-text-300 truncate">{item?.name}:</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||
1
packages/propel/src/charts/radar-chart/index.ts
Normal file
1
packages/propel/src/charts/radar-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
95
packages/propel/src/charts/radar-chart/root.tsx
Normal file
95
packages/propel/src/charts/radar-chart/root.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as CoreRadarChart,
|
||||
ResponsiveContainer,
|
||||
PolarAngleAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { TRadarChartProps } from "@plane/types";
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomRadarAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
const 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="rgba(var(--color-border-100), 0.9)" />
|
||||
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "rgba(var(--color-text-300))",
|
||||
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 };
|
||||
1
packages/propel/src/charts/scatter-chart/index.ts
Normal file
1
packages/propel/src/charts/scatter-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
166
packages/propel/src/charts/scatter-chart/root.tsx
Normal file
166
packages/propel/src/charts/scatter-chart/root.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
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 { 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(<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="rgba(var(--color-border-100), 0.8)" 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: "rgba(var(--color-text-300))",
|
||||
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";
|
||||
1
packages/propel/src/charts/tree-map/index.ts
Normal file
1
packages/propel/src/charts/tree-map/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
276
packages/propel/src/charts/tree-map/map-content.tsx
Normal file
276
packages/propel/src/charts/tree-map/map-content.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import { 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 const CustomTreeMapContent: React.FC<any> = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
fillColor,
|
||||
fillClassName,
|
||||
textClassName,
|
||||
icon,
|
||||
}) => {
|
||||
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-custom-text-300"}
|
||||
>
|
||||
{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(
|
||||
"text-sm font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
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(
|
||||
"text-sm font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
44
packages/propel/src/charts/tree-map/root.tsx
Normal file
44
packages/propel/src/charts/tree-map/root.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { 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((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="currentColor"
|
||||
className="text-custom-background-100 bg-custom-background-100"
|
||||
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: "text-custom-background-90/80 cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeMapChart.displayName = "TreeMapChart";
|
||||
29
packages/propel/src/charts/tree-map/tooltip.tsx
Normal file
29
packages/propel/src/charts/tree-map/tooltip.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "../../card";
|
||||
|
||||
interface TreeMapTooltipProps {
|
||||
active: boolean | undefined;
|
||||
payload: any[] | undefined;
|
||||
}
|
||||
|
||||
export const TreeMapTooltip = React.memo(({ 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-custom-border-200 pb-2.5">
|
||||
{data?.icon}
|
||||
<p className="text-xs text-custom-text-100 font-medium capitalize">{data?.name}</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-custom-text-200">
|
||||
{data?.value.toLocaleString()}
|
||||
{data.label && ` ${data.label}`}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
TreeMapTooltip.displayName = "TreeMapTooltip";
|
||||
174
packages/propel/src/collapsible/collapsible.stories.tsx
Normal file
174
packages/propel/src/collapsible/collapsible.stories.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { ChevronDownIcon } from "../icons";
|
||||
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="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm">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="rounded bg-blue-500 px-4 py-2 text-sm text-white">
|
||||
Open
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} className="rounded bg-gray-500 px-4 py-2 text-sm text-white">
|
||||
Close
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="rounded bg-green-500 px-4 py-2 text-sm text-white">
|
||||
Toggle
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.CollapsibleRoot isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} className="w-96">
|
||||
<Collapsible.CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm">This collapsible is controlled by external state.</p>
|
||||
<p className="mt-2 text-sm">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="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="space-y-2 rounded-md border border-gray-200 p-4">
|
||||
<h4 className="font-semibold">Section 1</h4>
|
||||
<p className="text-sm">This is some content in the first section.</p>
|
||||
<h4 className="font-semibold">Section 2</h4>
|
||||
<p className="text-sm">This is some content in the second section.</p>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
<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="flex w-full items-center justify-between rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 px-6 py-3 text-white shadow-lg transition-all hover:shadow-xl">
|
||||
<span className="text-lg 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="rounded-lg bg-gradient-to-br from-purple-100 to-pink-100 p-6 shadow-md">
|
||||
<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="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm">Content for the first item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
|
||||
<Collapsible.CollapsibleRoot>
|
||||
<Collapsible.CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm">Content for the second item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
|
||||
<Collapsible.CollapsibleRoot>
|
||||
<Collapsible.CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-100 px-4 py-2 hover:bg-gray-200">
|
||||
<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="rounded-md border border-gray-200 p-4">
|
||||
<p className="text-sm">Content for the third item.</p>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.CollapsibleRoot>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
100
packages/propel/src/collapsible/collapsible.tsx
Normal file
100
packages/propel/src/collapsible/collapsible.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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
|
||||
const Root: React.FC<RootProps> = ({ children, className, isOpen: controlledIsOpen, onToggle, defaultOpen }) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const Trigger: React.FC<TriggerProps> = ({ children, className, buttonRef }) => {
|
||||
const { isOpen } = useCollapsible();
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Trigger data-panel-open={isOpen} ref={buttonRef} className={className}>
|
||||
{children}
|
||||
</BaseCollapsible.Trigger>
|
||||
);
|
||||
};
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children, className }) => (
|
||||
<BaseCollapsible.Panel
|
||||
className={clsx(
|
||||
"flex h-[var(--collapsible-panel-height)] flex-col overflow-hidden text-sm 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,
|
||||
};
|
||||
1
packages/propel/src/collapsible/index.ts
Normal file
1
packages/propel/src/collapsible/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./collapsible";
|
||||
260
packages/propel/src/combobox/combobox.stories.tsx
Normal file
260
packages/propel/src/combobox/combobox.stories.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-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 && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-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 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 && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-50">
|
||||
<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) && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-50">
|
||||
<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) && <Check className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
<p className="text-xs text-gray-500">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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-gray-100 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 && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-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}
|
||||
disabled={framework.value === "angular" || framework.value === "svelte"}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
{value === framework.value && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-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..." 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 && <Check 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="flex w-72 items-center justify-between rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-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..."
|
||||
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 && <Check className="h-4 w-4" />}
|
||||
<span>{framework.label}</span>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
);
|
||||
},
|
||||
};
|
||||
224
packages/propel/src/combobox/combobox.tsx
Normal file
224
packages/propel/src/combobox/combobox.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import * as React from "react";
|
||||
import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
|
||||
import { Search } from "lucide-react";
|
||||
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;
|
||||
}
|
||||
|
||||
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<HTMLButtonElement, ComboboxButtonProps>(
|
||||
({ className, children, disabled = false }, ref) => (
|
||||
<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,
|
||||
}: 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("rounded-md border border-custom-border-200 bg-custom-background-100 p-1 shadow-lg", className)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchQueryKeyDown}
|
||||
className={cn(
|
||||
"w-full rounded border border-custom-border-100 bg-custom-background-90 py-1.5 pl-8 pr-2 text-sm outline-none placeholder:text-custom-text-400",
|
||||
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-sm text-custom-text-400">{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 px-2 py-1.5 text-sm outline-none transition-colors", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// Compound component export
|
||||
const Combobox = Object.assign(ComboboxRoot, {
|
||||
Button: ComboboxButton,
|
||||
Options: ComboboxOptions,
|
||||
Option: ComboboxOption,
|
||||
});
|
||||
|
||||
export { Combobox };
|
||||
1
packages/propel/src/combobox/index.ts
Normal file
1
packages/propel/src/combobox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./combobox";
|
||||
203
packages/propel/src/command/command.stories.tsx
Normal file
203
packages/propel/src/command/command.stories.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-sm outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">Item 1</Command.Item>
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">Item 2</Command.Item>
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">Item 3</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input
|
||||
placeholder="Search files and folders..."
|
||||
className="h-9 w-full bg-transparent py-3 text-sm outline-none"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Documents</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Downloads</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<File className="h-4 w-4" />
|
||||
<span>README.md</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<File className="h-4 w-4" />
|
||||
<span>package.json</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">No files or folders found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCategories: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input
|
||||
placeholder="Search commands..."
|
||||
className="h-9 w-full bg-transparent py-3 text-sm outline-none"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">User</div>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Command.Item>
|
||||
|
||||
<div className="mt-2 px-2 py-1.5 text-xs font-semibold text-gray-500">Files</div>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Open Folder</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<File className="h-4 w-4" />
|
||||
<span>New File</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">No commands found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-sm outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">{/* No items - will show empty state */}</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">
|
||||
<p className="font-semibold">No results found</p>
|
||||
<p className="mt-1 text-xs">Try searching for something else</p>
|
||||
</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LongList: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input placeholder="Search items..." className="h-9 w-full bg-transparent py-3 text-sm outline-none" />
|
||||
<Command.List className="max-h-60 overflow-auto py-2">
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<Command.Item key={i} className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">
|
||||
Item {i + 1}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Command.Item>
|
||||
<Command.Item className="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-gray-100">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Files</span>
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border-2 border-blue-300 bg-blue-50 p-2 shadow-lg">
|
||||
<Command.Input
|
||||
placeholder="Search with custom styling..."
|
||||
className="h-9 w-full bg-transparent py-3 text-sm text-blue-900 outline-none placeholder:text-blue-400"
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 text-blue-900 hover:bg-blue-200">
|
||||
Custom Item 1
|
||||
</Command.Item>
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 text-blue-900 hover:bg-blue-200">
|
||||
Custom Item 2
|
||||
</Command.Item>
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 text-blue-900 hover:bg-blue-200">
|
||||
Custom Item 3
|
||||
</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-blue-500">No matching items found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledItems: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Command className="w-96 rounded-lg border border-gray-200 p-2">
|
||||
<Command.Input placeholder="Search..." className="h-9 w-full bg-transparent py-3 text-sm outline-none" />
|
||||
<Command.List className="max-h-80 overflow-auto py-2">
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">Active Item 1</Command.Item>
|
||||
<Command.Item disabled className="cursor-not-allowed rounded px-3 py-2 opacity-50">
|
||||
Disabled Item
|
||||
</Command.Item>
|
||||
<Command.Item className="cursor-pointer rounded px-3 py-2 hover:bg-gray-100">Active Item 2</Command.Item>
|
||||
</Command.List>
|
||||
<Command.Empty className="py-6 text-center text-sm text-gray-500">No results found.</Command.Empty>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
};
|
||||
41
packages/propel/src/command/command.tsx
Normal file
41
packages/propel/src/command/command.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
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 border border-custom-border-100 bg-custom-background-90 px-2"
|
||||
>
|
||||
<SearchIcon className="size-3.5 flex-shrink-0 text-custom-text-400" 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 };
|
||||
1
packages/propel/src/command/index.ts
Normal file
1
packages/propel/src/command/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./command";
|
||||
379
packages/propel/src/context-menu/context-menu.stories.tsx
Normal file
379
packages/propel/src/context-menu/context-menu.stories.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Copy, Download, Edit, Share, Trash, Star, Archive } from "lucide-react";
|
||||
import { ChevronRightIcon } from "../icons";
|
||||
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-custom-border-300 text-sm">
|
||||
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-custom-border-300 text-sm">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Copy 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>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">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-custom-border-300 text-sm">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Copy 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>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">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-custom-border-300 text-sm">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Copy 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>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnFileCard: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="w-64 p-4 border border-custom-border-200 rounded-lg hover:bg-custom-background-80 cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-custom-primary-100 rounded flex items-center justify-center text-white text-lg">
|
||||
📄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Document.pdf</div>
|
||||
<div className="text-sm text-custom-text-400">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>
|
||||
<Copy 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>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">Delete</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnImage: Story = {
|
||||
render() {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<div className="relative w-80 h-56 bg-custom-background-80 rounded-lg overflow-hidden cursor-pointer">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-custom-text-400">
|
||||
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>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Image
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Copy 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 p-6 border border-custom-border-200 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2">Context Menu on Text</h3>
|
||||
<p className="text-custom-text-300">
|
||||
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>
|
||||
<Copy 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-custom-border-300 text-sm">
|
||||
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>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">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-custom-border-300 text-sm">
|
||||
Right click here
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto text-xs text-custom-text-400">⌘C</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
<span className="ml-auto text-xs text-custom-text-400">⌘E</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
<span className="ml-auto text-xs text-custom-text-400">⌘D</span>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="ml-auto text-xs text-custom-text-400">⌘⌫</span>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
};
|
||||
128
packages/propel/src/context-menu/context-menu.tsx
Normal file
128
packages/propel/src/context-menu/context-menu.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ContextMenuItemProps extends React.ComponentProps<typeof ContextMenuPrimitive.Item> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ContextMenuRoot = React.forwardRef<React.ElementRef<typeof ContextMenuPrimitive.Root>, ContextMenuProps>(
|
||||
({ children, ...props }, _ref) => <ContextMenuPrimitive.Root {...props}>{children}</ContextMenuPrimitive.Root>
|
||||
);
|
||||
|
||||
const ContextMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
|
||||
ContextMenuTriggerProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Trigger ref={ref} className={cn("outline-none", className)} {...props}>
|
||||
{children}
|
||||
</ContextMenuPrimitive.Trigger>
|
||||
));
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Positioner>,
|
||||
ContextMenuContentProps
|
||||
>(({ className, children, side = "bottom", sideOffset = 4, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Positioner ref={ref} side={side} sideOffset={sideOffset} {...props}>
|
||||
<ContextMenuPrimitive.Popup
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-custom-border-200 bg-custom-background-100 p-1 shadow-md",
|
||||
"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<React.ElementRef<typeof ContextMenuPrimitive.Item>, ContextMenuItemProps>(
|
||||
({ className, disabled, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||
"focus:bg-custom-background-90 focus:text-custom-text-100",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuPrimitive.Item>
|
||||
)
|
||||
);
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentProps<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-custom-border-200", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
const ContextMenuSubmenu = ContextMenuPrimitive.SubmenuRoot;
|
||||
|
||||
const ContextMenuSubmenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubmenuTrigger>,
|
||||
React.ComponentProps<typeof ContextMenuPrimitive.SubmenuTrigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:outline-none",
|
||||
"focus:bg-custom-background-90 data-[state=open]:bg-custom-background-90",
|
||||
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 };
|
||||
7
packages/propel/src/context-menu/index.ts
Normal file
7
packages/propel/src/context-menu/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ContextMenu } from "./context-menu";
|
||||
export type {
|
||||
ContextMenuProps,
|
||||
ContextMenuTriggerProps,
|
||||
ContextMenuContentProps,
|
||||
ContextMenuItemProps,
|
||||
} from "./context-menu";
|
||||
399
packages/propel/src/dialog/dialog.stories.tsx
Normal file
399
packages/propel/src/dialog/dialog.stories.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CloseIcon } from "../icons";
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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-sm text-gray-600">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="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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-sm text-gray-600">
|
||||
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="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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-sm text-gray-600">This is a small dialog.</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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-sm text-gray-600">
|
||||
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="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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="rounded-full p-1 hover:bg-gray-100">
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600">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="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600">
|
||||
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-sm text-gray-600">
|
||||
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="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="rounded bg-red-500 px-4 py-2 text-sm text-white hover:bg-red-600"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Enter description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600">
|
||||
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="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
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="mb-2 text-sm text-gray-600">
|
||||
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="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
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="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
{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-sm text-gray-600">This dialog uses the {label} width variant.</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setOpenWidth(null)}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
1
packages/propel/src/dialog/index.ts
Normal file
1
packages/propel/src/dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
115
packages/propel/src/dialog/root.tsx
Normal file
115
packages/propel/src/dialog/root.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
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-backdrop bg-custom-backdrop");
|
||||
const BASE_CLASSNAME = "relative text-left bg-custom-background-100 rounded-lg shadow-md w-full z-modal";
|
||||
|
||||
// Utility functions
|
||||
const getPositionClassNames = (position: DialogPosition) =>
|
||||
cn("isolate fixed z-modal", {
|
||||
"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<React.ComponentProps<typeof BaseDialog.Portal>>(({ children, ...props }) => (
|
||||
<BaseDialog.Portal data-slot="dialog-portal" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Portal>
|
||||
));
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogOverlay = memo<React.ComponentProps<typeof BaseDialog.Backdrop>>(({ className, ...props }) => (
|
||||
<BaseDialog.Backdrop data-slot="dialog-overlay" className={cn(OVERLAY_CLASSNAME, className)} {...props} />
|
||||
));
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogComponent = memo<DialogProps>(({ children, ...props }) => (
|
||||
<BaseDialog.Root data-slot="dialog" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Root>
|
||||
));
|
||||
DialogComponent.displayName = "Dialog";
|
||||
|
||||
const DialogTrigger = memo<React.ComponentProps<typeof BaseDialog.Trigger>>(({ children, ...props }) => (
|
||||
<BaseDialog.Trigger data-slot="dialog-trigger" {...props}>
|
||||
{children}
|
||||
</BaseDialog.Trigger>
|
||||
));
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPanel = forwardRef<React.ElementRef<typeof BaseDialog.Popup>, DialogPanelProps>(
|
||||
({ className, width = EDialogWidth.XXL, children, position = "center", ...props }, ref) => {
|
||||
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<DialogTitleProps>(({ className, children, ...props }) => (
|
||||
<BaseDialog.Title data-slot="dialog-title" className={cn("text-lg 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 };
|
||||
369
packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx
Normal file
369
packages/propel/src/emoji-icon-picker/emoji-picker.stories.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EmojiPicker } from "./emoji-picker";
|
||||
import { EmojiIconPickerTypes, TChangeHandlerProps } 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 = {
|
||||
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="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<div className="font-medium mb-2">Selected:</div>
|
||||
<pre className="text-xs">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenToEmojiTab: Story = {
|
||||
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-sm">Selected: {selectedValue.type === "emoji" ? selectedValue.value : "Icon"}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenToIconTab: Story = {
|
||||
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-sm">
|
||||
Selected:{" "}
|
||||
{selectedValue.type === "icon" && typeof selectedValue.value === "object"
|
||||
? selectedValue.value.name
|
||||
: "Emoji"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LucideIcons: Story = {
|
||||
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="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<div className="font-medium mb-2">Selected Icon:</div>
|
||||
<pre className="text-xs">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaterialIcons: Story = {
|
||||
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="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<div className="font-medium mb-2">Selected Icon:</div>
|
||||
<pre className="text-xs">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseOnSelectDisabled: Story = {
|
||||
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 gap-2 items-center">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleChange}
|
||||
label="Select Multiple (Stays Open)"
|
||||
defaultOpen={EmojiIconPickerTypes.EMOJI}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setSelectedValues([])}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<div className="font-medium mb-2">Selected ({selectedValues.length}):</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedValues.map((val, idx) => (
|
||||
<span key={idx} className="text-lg">
|
||||
{val.type === "emoji" ? val.value : "🎨"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
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-sm">Selected: {JSON.stringify(selectedValue)}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchDisabled: Story = {
|
||||
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-sm">Selected: {JSON.stringify(selectedValue)}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomIconColor: Story = {
|
||||
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="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<pre className="text-xs">{JSON.stringify(selectedValue, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentPlacements: Story = {
|
||||
render() {
|
||||
const [isOpen1, setIsOpen1] = useState(false);
|
||||
const [isOpen2, setIsOpen2] = useState(false);
|
||||
const [isOpen3, setIsOpen3] = useState(false);
|
||||
const [isOpen4, setIsOpen4] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Bottom Start:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen1}
|
||||
handleToggle={setIsOpen1}
|
||||
onChange={() => {}}
|
||||
label="Bottom Start"
|
||||
placement="bottom-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Bottom End:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen2}
|
||||
handleToggle={setIsOpen2}
|
||||
onChange={() => {}}
|
||||
label="Bottom End"
|
||||
placement="bottom-end"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Top Start:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen3}
|
||||
handleToggle={setIsOpen3}
|
||||
onChange={() => {}}
|
||||
label="Top Start"
|
||||
placement="top-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Top End:</span>
|
||||
<EmojiPicker
|
||||
isOpen={isOpen4}
|
||||
handleToggle={setIsOpen4}
|
||||
onChange={() => {}}
|
||||
label="Top End"
|
||||
placement="top-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InFormContext: Story = {
|
||||
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 p-6 border border-custom-border-200 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Project Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-custom-background-80 border border-custom-border-200 rounded"
|
||||
placeholder="Enter project title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">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-custom-background-80 border border-custom-border-200 rounded hover:bg-custom-background-90 w-full text-left"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 bg-custom-primary-100 text-white rounded hover:bg-custom-primary-200"
|
||||
>
|
||||
Create Project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
137
packages/propel/src/emoji-icon-picker/emoji-picker.tsx
Normal file
137
packages/propel/src/emoji-icon-picker/emoji-picker.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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 { emojiToString, TCustomEmojiPicker, EmojiIconPickerTypes } from "./helper";
|
||||
import { IconRoot } from "./icon/icon-root";
|
||||
|
||||
export const EmojiPicker: React.FC<TCustomEmojiPicker> = (props) => {
|
||||
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 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
|
||||
dropdownClassName
|
||||
)}
|
||||
side={finalSide}
|
||||
align={finalAlign}
|
||||
sideOffset={8}
|
||||
data-prevent-outside-click="true"
|
||||
>
|
||||
<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("py-1 text-sm rounded border border-custom-border-200 bg-custom-background-80", {
|
||||
"bg-custom-background-100 text-custom-text-100": selected,
|
||||
"text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60": !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>
|
||||
);
|
||||
};
|
||||
66
packages/propel/src/emoji-icon-picker/emoji/emoji.tsx
Normal file
66
packages/propel/src/emoji-icon-picker/emoji/emoji.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { EmojiPicker } from "frimousse";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
type EmojiRootProps = {
|
||||
onChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
searchDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const EmojiRoot = (props: EmojiRootProps) => {
|
||||
const { onChange, searchPlaceholder = "Search", searchDisabled = false } = props;
|
||||
return (
|
||||
<EmojiPicker.Root
|
||||
data-slot="emoji-picker"
|
||||
className="isolate flex flex-col rounded-md h-full w-full border-none p-2"
|
||||
onEmojiSelect={(val) => onChange(val.emoji)}
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-between [&>[data-slot='emoji-picker-search-wrapper']]:flex-grow [&>[data-slot='emoji-picker-search-wrapper']]:p-0 px-1.5 py-2 sticky top-0 z-10 bg-custom-background-100">
|
||||
<div data-slot="emoji-picker-search-wrapper" className="p-2">
|
||||
<EmojiPicker.Search
|
||||
placeholder={searchPlaceholder}
|
||||
disabled={searchDisabled}
|
||||
className="block rounded-md bg-transparent placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 text-[1rem] p-0 h-full w-full flex-grow-0 focus:border-custom-primary-100"
|
||||
/>
|
||||
</div>
|
||||
<EmojiPicker.SkinToneSelector
|
||||
data-slot="emoji-picker-skin-tone-selector"
|
||||
className="bg-custom-background-100 hover:bg-accent mx-2 mb-1.5 size-8 rounded-md text-lg flex-shrink-0"
|
||||
/>
|
||||
</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-custom-background-100 text-custom-text-300 px-3 pb-1.5 text-xs font-medium"
|
||||
{...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-lg"
|
||||
{...props}
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
);
|
||||
};
|
||||
1
packages/propel/src/emoji-icon-picker/emoji/index.ts
Normal file
1
packages/propel/src/emoji-icon-picker/emoji/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./emoji";
|
||||
156
packages/propel/src/emoji-icon-picker/helper.tsx
Normal file
156
packages/propel/src/emoji-icon-picker/helper.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { 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;
|
||||
128
packages/propel/src/emoji-icon-picker/icon/icon-root.tsx
Normal file
128
packages/propel/src/emoji-icon-picker/icon/icon-root.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { InfoIcon, Search } from "lucide-react";
|
||||
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 const IconRoot: React.FC<IconRootProps> = (props) => {
|
||||
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="flex flex-col sticky top-0 bg-custom-background-100">
|
||||
{!searchDisabled && (
|
||||
<div className="flex items-center px-2 py-[15px] w-full ">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border",
|
||||
{
|
||||
"border-custom-primary-100": isInputFocused,
|
||||
"border-transparent": !isInputFocused,
|
||||
}
|
||||
)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
>
|
||||
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
|
||||
|
||||
<input
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="block rounded-md bg-transparent placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 text-[1rem] border-none p-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
|
||||
{showHexInput ? (
|
||||
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">
|
||||
<span
|
||||
className="h-4 w-4 flex-shrink-0 rounded-full mr-1"
|
||||
style={{
|
||||
backgroundColor: `#${hexValue}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-custom-text-300 flex-shrink-0">HEX</span>
|
||||
<span className="text-xs text-custom-text-200 flex-shrink-0 -mr-1">#</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 placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 flex-grow pl-0 text-xs text-custom-text-200 rounded border-none bg-transparent ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
DEFAULT_COLORS.map((curCol) => (
|
||||
<button
|
||||
key={curCol}
|
||||
type="button"
|
||||
className="grid place-items-center size-5"
|
||||
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 place-items-center h-4 w-4 rounded-full border border-transparent", {
|
||||
"border-custom-border-400": !showHexInput,
|
||||
})}
|
||||
onClick={() => {
|
||||
setShowHexInput((prevData) => !prevData);
|
||||
setHexValue(activeColor.slice(1, 7));
|
||||
}}
|
||||
>
|
||||
{showHexInput ? (
|
||||
<span className="conical-gradient h-4 w-4 rounded-full" />
|
||||
) : (
|
||||
<span className="text-custom-text-300 text-[0.6rem] grid place-items-center">#</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full pl-4 pr-3 py-1 h-6">
|
||||
<InfoIcon className="h-3 w-3" />
|
||||
<p className="text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-1 px-2.5 justify-items-center mt-2">
|
||||
{iconType === "material" ? (
|
||||
<MaterialIconList query={query} onChange={onChange} activeColor={activeColor} />
|
||||
) : (
|
||||
<LucideIconsList query={query} onChange={onChange} activeColor={activeColor} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
packages/propel/src/emoji-icon-picker/icon/index.ts
Normal file
1
packages/propel/src/emoji-icon-picker/icon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./icon-root";
|
||||
34
packages/propel/src/emoji-icon-picker/icon/lucide-root.tsx
Normal file
34
packages/propel/src/emoji-icon-picker/icon/lucide-root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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 const LucideIconsList: React.FC<LucideIconsListProps> = (props) => {
|
||||
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="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
name: icon.name,
|
||||
color: activeColor,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<icon.element style={{ color: activeColor }} className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
55
packages/propel/src/emoji-icon-picker/icon/material-root.tsx
Normal file
55
packages/propel/src/emoji-icon-picker/icon/material-root.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
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 const MaterialIconList: React.FC<MaterialIconListProps> = (props) => {
|
||||
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="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
name: icon.name,
|
||||
color: activeColor,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isMaterialSymbolsFontLoaded ? (
|
||||
<span
|
||||
style={{ color: activeColor }}
|
||||
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
|
||||
>
|
||||
{icon.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-5 rounded animate-pulse bg-custom-background-80" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
5
packages/propel/src/emoji-icon-picker/index.ts
Normal file
5
packages/propel/src/emoji-icon-picker/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./emoji-picker";
|
||||
export * from "./helper";
|
||||
export * from "./logo";
|
||||
export * from "./lucide-icons";
|
||||
export * from "./material-icons";
|
||||
105
packages/propel/src/emoji-icon-picker/logo.tsx
Normal file
105
packages/propel/src/emoji-icon-picker/logo.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// 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
|
||||
// eslint-disable-next-line import/order
|
||||
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 const Logo: FC<Props> = (props) => {
|
||||
const { logo, size = 16, type = "material" } = props;
|
||||
|
||||
// destructuring the logo object
|
||||
const { in_use, emoji, icon } = logo;
|
||||
|
||||
// derived values
|
||||
const value = in_use === "emoji" ? emoji?.value : icon?.name;
|
||||
const color = icon?.color;
|
||||
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
// if no value, return empty fragment
|
||||
if (!value) return <></>;
|
||||
|
||||
if (!isMaterialSymbolsFontLoaded) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className="rounded animate-pulse bg-custom-background-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// emoji
|
||||
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
|
||||
if (in_use === "icon") {
|
||||
return (
|
||||
<>
|
||||
{type === "lucide" ? (
|
||||
<>
|
||||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// if no value, return empty fragment
|
||||
return <></>;
|
||||
};
|
||||
312
packages/propel/src/emoji-icon-picker/lucide-icons.tsx
Normal file
312
packages/propel/src/emoji-icon-picker/lucide-icons.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
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,
|
||||
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";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from "../icons";
|
||||
|
||||
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: ChevronDownIcon },
|
||||
{ name: "ChevronLeft", element: ChevronLeftIcon },
|
||||
{ name: "ChevronRight", element: ChevronRightIcon },
|
||||
{ name: "ChevronUp", element: ChevronUpIcon },
|
||||
{ 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 },
|
||||
];
|
||||
602
packages/propel/src/emoji-icon-picker/material-icons.tsx
Normal file
602
packages/propel/src/emoji-icon-picker/material-icons.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
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,391 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { stringToEmoji } from "../emoji-icon-picker";
|
||||
import { EmojiReactionGroup, EmojiReactionType } 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 items-center justify-center rounded-md px-2 size-8 text-xl">
|
||||
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-6 text-custom-text-100" />}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && (
|
||||
<div className="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
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="px-4 py-2 bg-custom-background-80 border border-custom-border-200 rounded hover:bg-custom-background-90 flex items-center gap-2">
|
||||
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-4 w-4" />}
|
||||
<span className="text-sm">Add Reaction</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-sm">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 items-center justify-center rounded-full border border-dashed border-custom-border-300 bg-custom-background-100 text-custom-text-400 transition-all duration-200 hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5 h-7 w-7">
|
||||
<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="p-8 space-y-8">
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Bottom Start:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen1}
|
||||
handleToggle={setIsOpen1}
|
||||
onChange={() => {}}
|
||||
placement="bottom-start"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Bottom End:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen2}
|
||||
handleToggle={setIsOpen2}
|
||||
onChange={() => {}}
|
||||
placement="bottom-end"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">Top Start:</span>
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen3}
|
||||
handleToggle={setIsOpen3}
|
||||
onChange={() => {}}
|
||||
placement="top-start"
|
||||
label={<SmilePlus className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm w-32">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="px-4 py-2 bg-custom-background-80 border border-custom-border-200 rounded hover:bg-custom-background-90">
|
||||
No Search
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-sm">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="px-4 py-2 bg-custom-background-80 border border-custom-border-200 rounded hover:bg-custom-background-90">
|
||||
Custom Search
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{selectedEmoji && <div className="text-sm">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 gap-2 items-center">
|
||||
<EmojiReactionPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={setIsOpen}
|
||||
onChange={handleChange}
|
||||
closeOnSelect={false}
|
||||
label={
|
||||
<button className="px-4 py-2 bg-custom-background-80 border border-custom-border-200 rounded hover:bg-custom-background-90">
|
||||
Select Multiple (Stays Open)
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-custom-background-80 rounded hover:bg-custom-background-90"
|
||||
onClick={() => setSelectedEmojis([])}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{selectedEmojis.length > 0 && (
|
||||
<div className="text-sm p-4 bg-custom-background-80 rounded border border-custom-border-200">
|
||||
<div className="font-medium mb-2">Selected ({selectedEmojis.length}):</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedEmojis.map((emoji, idx) => (
|
||||
<span key={idx} className="text-xl">
|
||||
{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 border border-custom-border-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-custom-primary-100 flex items-center justify-center text-white text-sm">
|
||||
AB
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">Alice Brown</div>
|
||||
<div className="text-sm text-custom-text-300 mt-1">
|
||||
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 items-center justify-center rounded-full border border-dashed border-custom-border-300 bg-custom-background-100 text-custom-text-400 transition-all duration-200 hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5 h-7 w-7">
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
85
packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx
Normal file
85
packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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, type TPlacement, type TSide, type 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 const EmojiReactionPicker: React.FC<EmojiReactionPickerProps> = (props) => {
|
||||
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 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
|
||||
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>
|
||||
);
|
||||
};
|
||||
234
packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx
Normal file
234
packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EmojiReaction, EmojiReactionGroup, EmojiReactionButton, EmojiReactionType } 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 gap-4 items-center">
|
||||
<EmojiReaction
|
||||
emoji="👍"
|
||||
count={count}
|
||||
reacted={reacted}
|
||||
users={["Alice", "Bob", "Charlie"]}
|
||||
onReactionClick={handleClick}
|
||||
/>
|
||||
<p className="text-sm text-custom-text-400">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 border border-custom-border-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-custom-primary-100 flex items-center justify-center text-white text-sm">
|
||||
AB
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">Alice Brown</div>
|
||||
<div className="text-sm text-custom-text-300 mt-1">
|
||||
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"],
|
||||
},
|
||||
};
|
||||
134
packages/propel/src/emoji-reaction/emoji-reaction.tsx
Normal file
134
packages/propel/src/emoji-reaction/emoji-reaction.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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";
|
||||
|
||||
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<HTMLButtonElement, EmojiReactionProps>(
|
||||
({ emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, ...props }, ref) => {
|
||||
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-xs">
|
||||
<div className="font-medium mb-1">{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 rounded-full border px-1.5 text-xs gap-0.5 transition-all duration-200",
|
||||
reacted
|
||||
? "bg-custom-primary-100/10 border-custom-primary-100 text-custom-primary-100"
|
||||
: "bg-custom-background-100 border-custom-border-200 text-custom-text-300 hover:border-custom-border-300 hover:bg-custom-background-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-base leading-unset">{emoji}</span>
|
||||
{showCount && count > 0 && <AnimatedCounter count={count} size="sm" className="text-xs leading-normal" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tooltipContent && users.length > 0) {
|
||||
return <Tooltip tooltipContent={tooltipContent}>{button}</Tooltip>;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
);
|
||||
|
||||
const EmojiReactionButton = React.forwardRef<HTMLButtonElement, EmojiReactionButtonProps>(
|
||||
({ onAddReaction, className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={onAddReaction}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full border border-dashed border-custom-border-300",
|
||||
"bg-custom-background-100 text-custom-text-400 transition-all duration-200",
|
||||
"hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5",
|
||||
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
|
||||
"h-6 w-6",
|
||||
className
|
||||
)}
|
||||
title="Add reaction"
|
||||
{...props}
|
||||
>
|
||||
<AddReactionIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupProps>(
|
||||
(
|
||||
{ reactions, onReactionClick, onAddReaction, className, showAddButton = true, maxDisplayUsers = 5, ...props },
|
||||
ref
|
||||
) => (
|
||||
<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 };
|
||||
10
packages/propel/src/emoji-reaction/index.ts
Normal file
10
packages/propel/src/emoji-reaction/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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";
|
||||
204
packages/propel/src/empty-state/assets-showcase.stories.tsx
Normal file
204
packages/propel/src/empty-state/assets-showcase.stories.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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-xl font-semibold text-custom-text-100">Horizontal Stack Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">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-custom-border-200 bg-custom-background-100 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-xs font-medium text-custom-text-200">{item.title}</p>
|
||||
<code className="rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-300">
|
||||
{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-xl font-semibold text-custom-text-100">Vertical Stack Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">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-custom-border-200 bg-custom-background-100 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-xs font-medium text-custom-text-200">
|
||||
{item.title.replace(/VerticalStackIllustration$/, "")}
|
||||
</p>
|
||||
<code className="rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-300">
|
||||
{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-xl font-semibold text-custom-text-100">Illustration Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">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-custom-border-200 bg-custom-background-100 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-xs font-medium text-custom-text-200">{item.title}</p>
|
||||
<code className="rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-300">
|
||||
{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-xl font-semibold text-custom-text-100">Horizontal Stack Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">
|
||||
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 border border-custom-border-200 bg-custom-background-100 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-[10px] text-custom-text-400">{item.title.toLowerCase().replace(/\s+/g, "-")}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Stack */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-custom-text-100">Vertical Stack Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">
|
||||
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 border border-custom-border-200 bg-custom-background-100 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-[10px] text-custom-text-400">
|
||||
{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-xl font-semibold text-custom-text-100">Illustration Assets</h2>
|
||||
<p className="text-sm text-custom-text-300">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 border border-custom-border-200 bg-custom-background-100 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-[10px] text-custom-text-400">{item.title.toLowerCase()}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
132
packages/propel/src/empty-state/assets/asset-registry.tsx
Normal file
132
packages/propel/src/empty-state/assets/asset-registry.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
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,
|
||||
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} />;
|
||||
};
|
||||
53
packages/propel/src/empty-state/assets/asset-types.ts
Normal file
53
packages/propel/src/empty-state/assets/asset-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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"
|
||||
| "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;
|
||||
15
packages/propel/src/empty-state/assets/helper.tsx
Normal file
15
packages/propel/src/empty-state/assets/helper.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const ILLUSTRATION_COLOR_TOKEN_MAP = {
|
||||
fill: {
|
||||
primary: "rgba(var(--color-background-100))", // white or #fff,
|
||||
secondary: "rgba(var(--color-background-90))", // #F4F5F5
|
||||
tertiary: "rgba(var(--color-background-80))", // #E5E7E8
|
||||
},
|
||||
stroke: {
|
||||
primary: "rgba(var(--color-text-200))", // #1D1F20
|
||||
secondary: "rgba(var(--color-border-400))", // #7A8185 or #CFD2D3
|
||||
},
|
||||
};
|
||||
|
||||
export type TIllustrationAssetProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
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="w-20 h-20" />,
|
||||
title: "Customer",
|
||||
},
|
||||
{
|
||||
asset: <EpicHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Epic",
|
||||
},
|
||||
{
|
||||
asset: <EstimateHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Estimate",
|
||||
},
|
||||
{
|
||||
asset: <ExportHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Export",
|
||||
},
|
||||
{
|
||||
asset: <IntakeHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Intake",
|
||||
},
|
||||
{
|
||||
asset: <LabelHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Label",
|
||||
},
|
||||
{
|
||||
asset: <LinkHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Link",
|
||||
},
|
||||
{
|
||||
asset: <MembersHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Members",
|
||||
},
|
||||
{
|
||||
asset: <NoteHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Note",
|
||||
},
|
||||
{
|
||||
asset: <PriorityHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Priority",
|
||||
},
|
||||
{
|
||||
asset: <ProjectHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Project",
|
||||
},
|
||||
{
|
||||
asset: <SettingsHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Settings",
|
||||
},
|
||||
{
|
||||
asset: <StateHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "State",
|
||||
},
|
||||
{
|
||||
asset: <TemplateHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Template",
|
||||
},
|
||||
{
|
||||
asset: <TokenHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Token",
|
||||
},
|
||||
{
|
||||
asset: <UnknownHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Unknown",
|
||||
},
|
||||
{
|
||||
asset: <UpdateHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Update",
|
||||
},
|
||||
{
|
||||
asset: <WebhookHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Webhook",
|
||||
},
|
||||
{
|
||||
asset: <WorkItemHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "WorkItem",
|
||||
},
|
||||
{
|
||||
asset: <WorklogHorizontalStackIllustration className="w-20 h-20" />,
|
||||
title: "Worklog",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type TIllustrationAssetProps, ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
|
||||
export const CustomerHorizontalStackIllustration = ({ className }: TIllustrationAssetProps) => (
|
||||
<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.8782 2.57309C46.9628 2.10751 45.787 2.14718 44.5087 2.79426L9.267 20.7542C6.37097 22.2299 4.0195 26.2861 4.0195 29.8055V72.6934C4.0195 74.6583 4.74547 76.0394 5.88968 76.6313L2.37019 74.84C1.22598 74.256 0.5 72.867 0.5 70.9021V28.0142C0.5 24.4869 2.85157 20.4386 5.7476 18.9629L40.9893 1.00296C42.2755 0.347996 43.4513 0.316215 44.3588 0.781791L47.8782 2.57309Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.878 2.57281C49.0222 3.15675 49.7482 4.54577 49.7482 6.51066V49.3986C49.7482 52.9259 47.3966 56.9742 44.5006 58.4498L9.25889 76.4102C7.97264 77.0652 6.79695 77.0966 5.88947 76.631C4.74526 76.0471 4.01929 74.658 4.01929 72.6931V29.8052C4.01929 26.2779 6.37076 22.2296 9.26679 20.754L44.5085 2.79397C45.7947 2.13901 46.9705 2.10723 47.878 2.57281Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.2578 9.36801C62.3425 8.90244 61.1667 8.94211 59.8883 9.58918L24.6466 27.5492C21.7506 29.0248 19.3991 33.081 19.3991 36.6004V79.4887C19.3991 81.4536 20.1251 82.8344 21.2693 83.4262L17.7498 81.6349C16.6056 81.0509 15.8796 79.6623 15.8796 77.6974V34.8091C15.8796 31.2818 18.2312 27.2335 21.1272 25.7579L56.3689 7.79788C57.6552 7.14292 58.8309 7.11114 59.7384 7.57671L63.2578 9.36801Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.2584 9.36578C64.4026 9.94972 65.1285 11.3387 65.1285 13.3036V56.1919C65.1285 59.7192 62.777 63.7672 59.8809 65.2428L24.6394 83.2032C23.3531 83.8581 22.1773 83.8895 21.2698 83.424C20.1256 82.84 19.3997 81.4514 19.3997 79.4865V36.5982C19.3997 33.0709 21.7511 29.0226 24.6472 27.5469L59.8888 9.58694C61.1751 8.93198 62.3509 8.9002 63.2584 9.36578Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.6299 16.1631C77.7145 15.6975 76.5387 15.7368 75.2604 16.3839L40.0188 34.3442C37.1228 35.8199 34.7712 39.8757 34.7712 43.3951V86.2834C34.7712 88.2483 35.4972 89.6294 36.6414 90.2213L33.122 88.43C31.9778 87.846 31.2517 86.457 31.2517 84.4921V41.6038C31.2517 38.0765 33.6033 34.0286 36.4993 32.5529L71.741 14.5926C73.0272 13.9376 74.203 13.9062 75.1105 14.3718L78.6299 16.1631Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.6299 16.1609C79.7741 16.7448 80.5001 18.1334 80.5001 20.0983V62.9866C80.5001 66.5139 78.1486 70.5622 75.2525 72.0379L40.0109 89.9979C38.7247 90.6528 37.5489 90.6846 36.6414 90.219C35.4972 89.6351 34.7712 88.2461 34.7712 86.2812V43.3929C34.7712 39.8656 37.1228 35.8177 40.0188 34.342L75.2604 16.3816C76.5467 15.7267 77.7225 15.6953 78.6299 16.1609Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M46.6233 45.5414C46.6233 44.9023 47.0494 44.1683 47.5702 43.9L62.7685 36.1588C63.2893 35.8905 63.7154 36.1904 63.7154 36.8296C63.7154 37.4688 63.2893 38.2027 62.7685 38.471V43.0951L70.3676 39.2205C70.8884 38.9522 71.3145 39.2521 71.3145 39.8913C71.3145 40.5305 70.8884 41.2644 70.3676 41.5327V60.0294L71.3145 59.5482C71.8353 59.2799 72.2615 59.5798 72.2615 60.219C72.2615 60.8582 71.8353 61.5917 71.3145 61.86L46.6154 74.4465C46.0946 74.7148 45.6685 74.4149 45.6685 73.7757C45.6685 73.1365 46.0946 72.4026 46.6154 72.1343L47.5623 71.653V46.22C47.0415 46.4883 46.6154 46.1883 46.6154 45.5492L46.6233 45.5414ZM49.472 45.2417V70.6747L51.3737 69.7041V65.6557C51.3737 64.0617 52.439 62.2233 53.7489 61.5525L56.5977 60.1003C57.9076 59.4296 58.9728 60.1874 58.9728 61.7814V65.8295L60.8746 64.8589V39.4258L49.4799 45.2336L49.472 45.2417ZM62.7764 45.4074V63.9041L68.4738 61V42.5033L62.7764 45.4074ZM57.0712 66.8001V62.752C57.0712 62.4364 56.8581 62.2863 56.5977 62.4126L53.7489 63.8644C53.4885 63.9985 53.2755 64.3695 53.2755 64.6851V68.7332L57.0712 66.8001ZM51.3737 48.9029C51.3737 48.2637 51.7999 47.5298 52.3207 47.2615L53.2676 46.7802C53.7884 46.5119 54.2146 46.8119 54.2146 47.451C54.2146 48.0902 53.7884 48.8242 53.2676 49.0925L52.3207 49.5737C51.7999 49.842 51.3737 49.5421 51.3737 48.9029ZM56.1242 46.4804C56.1242 45.8413 56.5503 45.1073 57.0712 44.839L58.018 44.3578C58.5389 44.0895 58.965 44.3894 58.965 45.0286C58.965 45.6678 58.5389 46.4013 58.018 46.6696L57.0712 47.1513C56.5503 47.4196 56.1242 47.1196 56.1242 46.4804ZM51.3737 53.5273C51.3737 52.8882 51.7999 52.1542 52.3207 51.8859L53.2676 51.4043C53.7884 51.136 54.2146 51.4359 54.2146 52.0751C54.2146 52.7143 53.7884 53.4482 53.2676 53.7165L52.3207 54.1978C51.7999 54.4661 51.3737 54.1665 51.3737 53.5273ZM56.1242 51.1045C56.1242 50.4653 56.5503 49.7314 57.0712 49.4631L58.018 48.9819C58.5389 48.7136 58.965 49.0135 58.965 49.6527C58.965 50.2919 58.5389 51.0258 58.018 51.2941L57.0712 51.7754C56.5503 52.0436 56.1242 51.7437 56.1242 51.1045ZM64.6703 49.0609C64.6703 48.4217 65.0964 47.6877 65.6172 47.4194H65.6251C66.1459 47.1433 66.572 47.4509 66.572 48.0822V48.098C66.572 48.7372 66.1459 49.4711 65.6251 49.7394H65.6172C65.0964 50.0156 64.6703 49.7079 64.6703 49.0767V49.0609ZM51.3737 58.1433C51.3737 57.5042 51.7999 56.7702 52.3207 56.5019L53.2676 56.0207C53.7884 55.7524 54.2146 56.0523 54.2146 56.6915C54.2146 57.3307 53.7884 58.0646 53.2676 58.3329L52.3207 58.8142C51.7999 59.0825 51.3737 58.7825 51.3737 58.1433ZM56.1242 55.7286C56.1242 55.0894 56.5503 54.3559 57.0712 54.0876L58.018 53.606C58.5389 53.3377 58.965 53.6376 58.965 54.2768C58.965 54.916 58.5389 55.6499 58.018 55.9182L57.0712 56.3994C56.5503 56.6677 56.1242 56.3678 56.1242 55.7286ZM64.6703 53.6849C64.6703 53.0458 65.0964 52.3118 65.6172 52.0435H65.6251C66.1459 51.7673 66.572 52.0754 66.572 52.7066V52.7224C66.572 53.3616 66.1459 54.0952 65.6251 54.3635H65.6172C65.0964 54.6397 64.6703 54.332 64.6703 53.7007V53.6849ZM64.6703 58.309C64.6703 57.6698 65.0964 56.9363 65.6172 56.668H65.6251C66.1459 56.3918 66.572 56.6994 66.572 57.3307V57.3465C66.572 57.9857 66.1459 58.7196 65.6251 58.9879H65.6172C65.0964 59.2641 64.6703 58.9561 64.6703 58.3248V58.309Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type TIllustrationAssetProps, ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
|
||||
export const EpicHorizontalStackIllustration = ({ className }: TIllustrationAssetProps) => (
|
||||
<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.8729 3.07316C46.9576 2.60763 45.782 2.6473 44.5038 3.2943L9.26602 21.2523C6.37031 22.7277 4.01901 26.7835 4.01901 30.3025V73.1855C4.01901 75.1502 4.7449 76.5312 5.88899 77.1229L2.36998 75.3318C1.2259 74.748 0.5 73.3595 0.5 71.3948V28.5114C0.5 24.9845 2.8513 20.9366 5.74701 19.4612L40.9848 1.5032C42.2709 0.848314 43.4464 0.816538 44.3538 1.28206L47.8729 3.07316Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.8722 3.07317C49.0163 3.65704 49.7422 5.04591 49.7422 7.01057V49.894C49.7422 53.4209 47.3909 57.4684 44.4952 58.9439L9.25743 76.9022C7.97132 77.5571 6.79566 77.5885 5.88829 77.1229C4.74421 76.5391 4.01831 75.1502 4.01831 73.1855V30.3025C4.01831 26.7756 6.36962 22.7277 9.26533 21.2523L44.5031 3.29431C45.7892 2.63942 46.9648 2.60764 47.8722 3.07317Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.2511 9.86669C62.3358 9.40117 61.1602 9.44084 59.8819 10.0878L24.6442 28.0458C21.7485 29.5213 19.3972 33.577 19.3972 37.096V79.9795C19.3972 81.9441 20.1231 83.3247 21.2672 83.9165L17.7482 82.1254C16.6041 81.5415 15.8782 80.153 15.8782 78.1884V35.3049C15.8782 31.778 18.2295 27.7302 21.1252 26.2547L56.3629 8.29674C57.649 7.64185 58.8246 7.61046 59.732 8.07598L63.2511 9.86669Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.2519 9.86653C64.3959 10.4504 65.1218 11.8393 65.1218 13.8039V56.6874C65.1218 60.2143 62.7705 64.2617 59.8748 65.7372L24.6371 83.6956C23.351 84.3504 22.1753 84.3818 21.2679 83.9163C20.1238 83.3324 19.3979 81.944 19.3979 79.9793V37.0959C19.3979 33.569 21.7493 29.5211 24.645 28.0456L59.8827 10.0877C61.1688 9.43279 62.3445 9.40101 63.2519 9.86653Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.6293 16.668C77.714 16.2025 76.5383 16.2421 75.2601 16.8891L40.0224 34.8471C37.1267 36.3225 34.7755 40.3783 34.7755 43.8973V86.7807C34.7755 88.7454 35.5013 90.126 36.6454 90.7178L33.1263 88.9267C31.9822 88.3428 31.2563 86.9543 31.2563 84.9896V42.1062C31.2563 38.5793 33.6077 34.5314 36.5034 33.056L71.7411 15.098C73.0272 14.4431 74.2029 14.4114 75.1102 14.8769L78.6293 16.668Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.6299 16.668C79.774 17.2519 80.4999 18.6407 80.4999 20.6054V63.4888C80.4999 67.0157 78.1486 71.0632 75.2529 72.5387L40.0151 90.497C38.729 91.1519 37.5535 91.1833 36.6461 90.7178C35.502 90.1339 34.7761 88.7454 34.7761 86.7807V43.8973C34.7761 40.3704 37.1273 36.3225 40.023 34.8471L75.2608 16.8891C76.5469 16.2342 77.7225 16.2025 78.6299 16.668Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M73.7365 51.6298V55.4643C73.7365 55.8589 73.6103 56.2928 73.4051 56.6795C73.1842 57.0661 72.9001 57.3741 72.5924 57.524L47.4226 70.3533C46.7914 70.6768 46.2786 70.2743 46.2786 69.4537V63.3469L53.9084 46.7774C54.3345 46.0042 54.8315 45.3175 55.4391 45.0098C56.0466 44.7021 56.5359 44.8836 56.962 45.2229L63.5582 54.6044L66.4065 50.0441C66.8326 49.2708 67.4164 48.663 68.024 48.3473C68.6315 48.0317 69.2154 48.0555 69.6336 48.3947L73.7365 51.6221V51.6298Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type TIllustrationAssetProps, ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
|
||||
export const EstimateHorizontalStackIllustration = ({ className }: TIllustrationAssetProps) => (
|
||||
<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.8734 3.07267C46.9581 2.60715 45.7825 2.6466 44.5042 3.2936L9.26616 21.2519C6.37042 22.7274 4.01909 26.783 4.01909 30.3021V73.1858C4.01909 75.1505 4.745 76.5313 5.88909 77.1231L2.37005 75.332C1.22595 74.7481 0.5 73.3594 0.5 71.3948V28.511C0.5 24.984 2.85133 20.9363 5.74707 19.4608L40.9851 1.5025C42.2713 0.847607 43.4469 0.816046 44.3543 1.28157L47.8734 3.07267Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.8732 3.07177C49.0173 3.65565 49.7432 5.04434 49.7432 7.00903V49.8928C49.7432 53.4197 47.3919 57.4675 44.4962 58.943L9.25809 76.9013C7.97197 77.5562 6.7963 77.5877 5.88892 77.1222C4.74482 76.5383 4.01892 75.1496 4.01892 73.1849V30.3012C4.01892 26.7742 6.37025 22.7265 9.26599 21.251L44.5041 3.29269C45.7902 2.6378 46.9658 2.60624 47.8732 3.07177Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M63.2514 9.86595C62.3361 9.40042 61.1605 9.43987 59.8822 10.0869L24.6442 28.0452C21.7484 29.5207 19.3971 33.5763 19.3971 37.0954V79.9791C19.3971 81.9438 20.123 83.3246 21.2671 83.9164L17.748 82.1253C16.604 81.5414 15.8781 80.1527 15.8781 78.188V35.3043C15.8781 31.7773 18.2293 27.7296 21.1251 26.2541L56.3632 8.29578C57.6493 7.64088 58.8249 7.60932 59.7323 8.07485L63.2514 9.86595Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M63.2514 9.86614C64.3955 10.45 65.1214 11.8387 65.1214 13.8034V56.6872C65.1214 60.2141 62.7701 64.2618 59.8743 65.7373L24.6363 83.6956C23.3501 84.3505 22.1745 84.3821 21.2671 83.9166C20.123 83.3327 19.3971 81.944 19.3971 79.9793V37.0955C19.3971 33.5686 21.7484 29.5209 24.6442 28.0454L59.8822 10.0871C61.1684 9.43217 62.344 9.40061 63.2514 9.86614Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M78.6301 16.6678C77.7148 16.2023 76.5391 16.2417 75.2609 16.8887L40.0228 34.8471C37.1271 36.3225 34.7758 40.3781 34.7758 43.8972V86.781C34.7758 88.7457 35.5017 90.1265 36.6458 90.7182L33.1267 88.9271C31.9826 88.3433 31.2567 86.9546 31.2567 84.9899V42.1061C31.2567 38.5792 33.608 34.5315 36.5037 33.056L71.7419 15.0977C73.028 14.4428 74.2036 14.4112 75.111 14.8767L78.6301 16.6678Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M78.6299 16.668C79.774 17.2519 80.4999 18.6406 80.4999 20.6053V63.489C80.4999 67.016 78.1487 71.0637 75.2529 72.5392L40.0148 90.4975C38.7287 91.1524 37.553 91.184 36.6456 90.7184C35.5015 90.1346 34.7756 88.7459 34.7756 86.7812V43.8974C34.7756 40.3705 37.127 36.3227 40.0227 34.8473L75.2608 16.8889C76.5469 16.2341 77.7226 16.2025 78.6299 16.668Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M57.2395 40.7963C57.7524 40.1808 58.3283 39.6916 58.9201 39.3918C59.5119 39.092 60.0879 38.9815 60.6008 39.0841C61.1136 39.1788 61.5397 39.4786 61.8316 39.952L70.8029 54.4938C71.0949 54.9673 71.2527 55.5906 71.2527 56.3086C71.2527 57.0266 71.0949 57.8156 70.8029 58.5889C70.511 59.3621 70.0849 60.0959 69.572 60.7193C69.0592 61.3426 68.4832 61.8239 67.8914 62.1238L49.9331 71.2686C49.3413 71.5684 48.7653 71.671 48.2524 71.5763C47.7395 71.4738 47.3135 71.1739 47.0215 70.7005C46.7296 70.2271 46.5718 69.6038 46.5718 68.8857C46.5718 68.1677 46.7296 67.3866 47.0215 66.6133L56.0007 42.9188C56.3005 42.1455 56.7187 41.4117 57.2316 40.7963H57.2395ZM58.9201 42.1218C58.7229 42.2244 58.5335 42.3822 58.3599 42.5874C58.1942 42.7925 58.0522 43.0371 57.9496 43.2896L48.9705 66.9763C48.8758 67.2367 48.8205 67.4971 48.8205 67.7338C48.8205 67.9705 48.8758 68.1835 48.9705 68.3413C49.0651 68.4991 49.2071 68.6017 49.3807 68.6332C49.5543 68.6648 49.7437 68.6332 49.941 68.5307L67.8914 59.3858C68.0886 59.2832 68.278 59.1254 68.4516 58.9203C68.6252 58.7151 68.7672 58.4706 68.8619 58.2102C68.9566 57.9498 69.0118 57.6894 69.0118 57.4527C69.0118 57.216 68.9566 57.0029 68.8619 56.8451L59.8906 42.3033C59.7959 42.1455 59.6539 42.0508 59.4803 42.0193C59.3067 41.9877 59.1174 42.0193 58.9201 42.1218Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type TIllustrationAssetProps, ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
|
||||
export const ExportHorizontalStackIllustration = ({ className }: TIllustrationAssetProps) => (
|
||||
<svg width="71" height="81" viewBox="0 0 71 81" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.9519 2.94243C41.151 2.53509 40.1223 2.5698 39.0038 3.13593L8.17037 18.8494C5.63659 20.1404 3.57921 23.6892 3.57921 26.7684V64.2916C3.57921 66.0107 4.21438 67.2191 5.21547 67.7369L2.13625 66.1696C1.13517 65.6587 0.5 64.4435 0.5 62.7244V25.2012C0.5 22.1151 2.55742 18.5732 5.0912 17.2821L35.9246 1.5687C37.05 0.995669 38.0787 0.967864 38.8726 1.3752L41.9519 2.94243Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.9519 2.94356C42.9529 3.45446 43.5881 4.66973 43.5881 6.38884V43.912C43.5881 46.9981 41.5307 50.54 38.9969 51.8311L8.16351 67.5448C7.03816 68.1179 6.00944 68.1453 5.21548 67.738C4.21439 67.2271 3.57922 66.0118 3.57922 64.2927V26.7696C3.57922 23.6835 5.6366 20.1415 8.17038 18.8505L39.0038 3.13706C40.1292 2.56403 41.1579 2.53622 41.9519 2.94356Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.4078 8.88579C54.6069 8.47845 53.5782 8.51316 52.4597 9.07929L21.6263 24.7927C19.0926 26.0838 17.0351 29.6326 17.0351 32.7118V70.2353C17.0351 71.9544 17.6703 73.1624 18.6714 73.6802L15.5922 72.113C14.5911 71.6021 13.9559 70.3872 13.9559 68.6681V31.1446C13.9559 28.0585 16.0134 24.5165 18.5471 23.2255L49.3805 7.51206C50.5059 6.93903 51.5346 6.91122 52.3286 7.31856L55.4078 8.88579Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.4079 8.88692C56.4089 9.39782 57.0441 10.6131 57.0441 12.3322V49.8557C57.0441 52.9418 54.9867 56.4834 52.4529 57.7744L21.6195 73.4882C20.4941 74.0612 19.4654 74.0887 18.6715 73.6814C17.6704 73.1704 17.0352 71.9555 17.0352 70.2364V32.7129C17.0352 29.6268 19.0926 26.0849 21.6264 24.7938L52.4598 9.08042C53.5852 8.50739 54.6139 8.47958 55.4079 8.88692Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.8637 14.8332C68.0629 14.4259 67.0342 14.4602 65.9157 15.0264L35.0823 30.7401C32.5485 32.0312 30.4911 35.5797 30.4911 38.6589V76.1824C30.4911 77.9015 31.1263 79.1098 32.1274 79.6276L29.0482 78.0604C28.0471 77.5495 27.4119 76.3342 27.4119 74.6151V37.0916C27.4119 34.0055 29.4693 30.4639 32.0031 29.1729L62.8365 13.4591C63.9619 12.8861 64.9906 12.8586 65.7845 13.266L68.8637 14.8332Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.8637 14.8304C69.8648 15.3413 70.5 16.5563 70.5 18.2754V55.7989C70.5 58.885 68.4426 62.4269 65.9088 63.7179L35.0754 79.4313C33.95 80.0044 32.9213 80.0322 32.1273 79.6248C31.1263 79.1139 30.4911 77.8987 30.4911 76.1796V38.6561C30.4911 35.57 32.5485 32.0284 35.0823 30.7373L65.9157 15.0236C67.041 14.4505 68.0698 14.4231 68.8637 14.8304Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M43.5881 41.1008C43.5881 40.4104 44.0438 39.6233 44.6099 39.3333L58.9289 32.0359C59.495 31.7459 59.9507 32.0702 59.9507 32.7607C59.9507 33.4511 59.495 34.2382 58.9289 34.5281L44.6099 41.8259C44.0438 42.1159 43.5881 41.7912 43.5881 41.1008Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M58.639 46.7626C58.2385 47.453 57.5895 47.7774 57.1891 47.5013L52.7981 44.3944V58.81C52.7981 59.5004 52.3356 60.2875 51.7764 60.5775C51.2171 60.8674 50.7546 60.5362 50.7546 59.8527V45.43L46.3636 53.0107C45.9631 53.7011 45.3142 54.0323 44.9137 53.7493C44.5202 53.4662 44.5202 52.6793 44.9137 51.9889L51.0514 41.3982C51.0514 41.3982 51.0928 41.336 51.1135 41.3014C51.1205 41.2945 51.1274 41.2878 51.1274 41.2809C51.2033 41.1704 51.2862 41.0598 51.3759 40.9701C51.4104 40.9355 51.445 40.9009 51.4795 40.8733C51.5071 40.8457 51.5416 40.8182 51.5761 40.7975C51.6107 40.7698 51.6521 40.7421 51.6866 40.7145C51.7211 40.6938 51.7487 40.68 51.7833 40.6592C51.8178 40.6385 51.8523 40.6248 51.8868 40.611C52.018 40.5558 52.1492 40.5419 52.2666 40.5558C52.2942 40.5558 52.3218 40.5628 52.3494 40.5767C52.3494 40.5767 52.4046 40.5972 52.4322 40.611C52.4737 40.6318 52.5082 40.6525 52.5427 40.6801L58.6459 44.9952C59.0394 45.2782 59.0394 46.0652 58.6459 46.7556L58.639 46.7626Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from "./customer";
|
||||
export * from "./epic";
|
||||
export * from "./estimate";
|
||||
export * from "./export";
|
||||
export * from "./intake";
|
||||
export * from "./label";
|
||||
export * from "./link";
|
||||
export * from "./members";
|
||||
export * from "./note";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./settings";
|
||||
export * from "./state";
|
||||
export * from "./template";
|
||||
export * from "./token";
|
||||
export * from "./unknown";
|
||||
export * from "./update";
|
||||
export * from "./webhook";
|
||||
export * from "./work-item";
|
||||
export * from "./worklog";
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type TIllustrationAssetProps, ILLUSTRATION_COLOR_TOKEN_MAP } from "../helper";
|
||||
|
||||
export const IntakeHorizontalStackIllustration = ({ className }: TIllustrationAssetProps) => (
|
||||
<svg width="71" height="80" viewBox="0 0 71 80" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M41.9519 2.42126C41.151 2.01392 40.1223 2.04846 39.0038 2.61459L8.1704 18.3282C5.63661 19.6192 3.57922 23.1679 3.57922 26.2471V63.7706C3.57922 65.4897 4.21438 66.6979 5.21547 67.2157L2.13625 65.6485C1.13517 65.1376 0.5 63.9225 0.5 62.2034V24.6799C0.5 21.5938 2.55742 18.052 5.0912 16.761L35.9246 1.04736C37.05 0.47433 38.0787 0.446693 38.8727 0.854032L41.9519 2.42126Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M41.952 2.4216C42.953 2.9325 43.5882 4.1476 43.5882 5.86671V43.3902C43.5882 46.4763 41.5308 50.0181 38.997 51.3091L8.16357 67.0227C7.03821 67.5958 6.00952 67.6234 5.21555 67.216C4.21447 66.7052 3.5793 65.49 3.5793 63.7709V26.2474C3.5793 23.1613 5.6367 19.6196 8.17048 18.3285L39.0039 2.61493C40.1293 2.0419 41.158 2.01426 41.952 2.4216Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
d="M55.4078 8.39993C54.607 7.99259 53.5783 8.02713 52.4598 8.59326L21.6264 24.3069C19.0926 25.5979 17.0352 29.1466 17.0352 32.2258V69.7493C17.0352 71.4684 17.6704 72.6766 18.6714 73.1944L15.5923 71.6272C14.5912 71.1163 13.956 69.9012 13.956 68.1821V30.6585C13.956 27.5724 16.0134 24.0307 18.5472 22.7396L49.3806 7.02603C50.506 6.453 51.5347 6.42536 52.3286 6.8327L55.4078 8.39993Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.4079 8.40042C56.409 8.91132 57.0442 10.1264 57.0442 11.8455V49.369C57.0442 52.4551 54.9867 55.9969 52.4529 57.2879L21.6195 73.0015C20.4942 73.5746 19.4655 73.6022 18.6715 73.1949C17.6704 72.684 17.0352 71.4689 17.0352 69.7498V32.2263C17.0352 29.1402 19.0926 25.5984 21.6264 24.3073L52.4599 8.59375C53.5852 8.02072 54.6139 7.99308 55.4079 8.40042Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M68.8638 14.3513C68.0629 13.944 67.0342 13.9785 65.9157 14.5446L35.0823 30.2582C32.5485 31.5493 30.4911 35.098 30.4911 38.1772V75.7005C30.4911 77.4196 31.1263 78.6278 32.1274 79.1456L29.0482 77.5785C28.0471 77.0676 27.4119 75.8524 27.4119 74.1333V36.6099C27.4119 33.5238 29.4693 29.9821 32.0031 28.691L62.8365 12.9774C63.9619 12.4044 64.9906 12.3767 65.7845 12.7841L68.8638 14.3513Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.secondary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M68.8636 14.3517C69.8647 14.8626 70.4999 16.0777 70.4999 17.7968V55.3201C70.4999 58.4062 68.4425 61.9481 65.9087 63.2392L35.0753 78.9528C33.9499 79.5258 32.9212 79.5533 32.1273 79.1459C31.1262 78.635 30.491 77.4199 30.491 75.7008V38.1775C30.491 35.0914 32.5484 31.5496 35.0822 30.2586L65.9156 14.545C67.041 13.9719 68.0697 13.9443 68.8636 14.3517Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.fill.primary}
|
||||
stroke={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M57.8519 35.2914C58.1349 35.3467 58.3075 35.6296 58.3075 36.0369V46.4414C58.3075 46.8349 58.1419 47.2836 57.8726 47.6288L52.1699 54.8159C51.8937 55.168 51.5485 55.3476 51.2654 55.2924C50.9824 55.2372 50.8097 54.954 50.8097 54.5467V44.0941C50.8097 43.7005 50.9754 43.2447 51.2516 42.8995L56.9543 35.7608C57.2305 35.4156 57.5757 35.2362 57.8588 35.2914H57.8519ZM52.5979 43.8178V51.647L56.5056 46.7244V38.9297L52.5979 43.8178Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M55.3249 34.8766C55.5804 35.284 55.4354 36.0434 55.0073 36.575L49.7395 43.1615V52.979C49.7395 53.6004 49.3391 54.3115 48.842 54.5601C48.3449 54.8086 47.9445 54.5118 47.9445 53.8973V43.4445C47.9445 43.051 48.1102 42.5953 48.3863 42.2501L54.0891 35.1114C54.5171 34.5798 55.0695 34.4693 55.3249 34.8766Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M52.4253 34.1796C52.6808 34.587 52.5358 35.3464 52.1077 35.878L46.84 42.4645V52.282C46.84 52.9034 46.4395 53.6145 45.9424 53.8631C45.4454 54.1116 45.0449 53.8148 45.0449 53.2003V42.7475C45.0449 42.354 45.2106 41.8983 45.4868 41.5531L51.1895 34.4144C51.6175 33.8828 52.1699 33.7723 52.4253 34.1796Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M42.0347 52.1577C42.5318 51.9023 42.9323 52.206 42.9323 52.8205V60.8223C42.9323 61.5749 43.4293 61.9407 44.03 61.6369L59.2465 53.8837C59.8471 53.5799 60.3442 52.71 60.3442 51.9575V43.9556C60.3442 43.3343 60.7446 42.6231 61.2417 42.3746C61.7388 42.126 62.1393 42.423 62.1393 43.0375V51.0392C62.1393 53.0344 60.8413 55.3198 59.2465 56.1344L44.03 63.8877C42.4351 64.7023 41.1372 63.7357 41.1372 61.7473V53.7457C41.1372 53.1243 41.5376 52.4132 42.0347 52.1646V52.1577Z"
|
||||
fill={ILLUSTRATION_COLOR_TOKEN_MAP.stroke.secondary}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user