snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { StorybookConfig } from "@storybook/react-webpack5";
import { createRequire } from "module";
import { join, dirname } from "path";
const require = createRequire(import.meta.url);
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Plug'n'Play (PnP) or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-webpack5-compiler-swc"),
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-docs"),
getAbsolutePath("@chromatic-com/storybook"),
"@storybook/addon-styling-webpack",
],
framework: {
name: getAbsolutePath("@storybook/react-webpack5"),
options: {},
},
};
export default config;

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Preview } from "@storybook/react";
import "../styles/output.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

1
packages/ui/README.md Normal file
View File

@@ -0,0 +1 @@
# UI Package

76
packages/ui/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "@plane/ui",
"version": "1.4.2",
"private": true,
"description": "UI components shared across multiple apps internally",
"license": "AGPL-3.0",
"type": "module",
"sideEffects": false,
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch --no-clean",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"postcss": "postcss styles/globals.css -o styles/output.css --watch",
"check:lint": "oxlint --max-warnings=66 .",
"check:types": "tsc --noEmit",
"check:format": "oxfmt --check .",
"fix:lint": "oxlint --fix .",
"fix:format": "oxfmt .",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "catalog:",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "catalog:",
"@headlessui/react": "catalog:",
"@makeplane/propel": "catalog:",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/propel": "workspace:*",
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"@popperjs/core": "catalog:",
"clsx": "catalog:",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"react-color": "catalog:",
"react-day-picker": "catalog:",
"react-popper": "catalog:",
"tailwind-merge": "catalog:",
"use-font-face-observer": "catalog:"
},
"devDependencies": {
"@chromatic-com/storybook": "catalog:",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-links": "catalog:",
"@storybook/addon-onboarding": "catalog:",
"@storybook/addon-styling-webpack": "catalog:",
"@storybook/addon-webpack5-compiler-swc": "catalog:",
"@storybook/react": "catalog:",
"@storybook/react-webpack5": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-color": "catalog:",
"@types/react-dom": "catalog:",
"autoprefixer": "catalog:",
"postcss-cli": "catalog:",
"postcss-nested": "catalog:",
"storybook": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@@ -0,0 +1,3 @@
import postcssConfig from "@plane/tailwind-config/postcss.config.js";
export default postcssConfig;

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { cn } from "../utils";
// types
import type { TAvatarSize } from "./helper";
import { getSizeInfo, isAValidNumber } from "./helper";
type Props = {
/**
* The children of the avatar group.
* These should ideally should be `Avatar` components
*/
children: React.ReactNode;
/**
* The maximum number of avatars to display.
* If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars.
* @default 2
*/
max?: number;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
};
export function AvatarGroup(props: Props) {
const { children, max = 2, showTooltip = true, size = "md" } = props;
// calculate total length of avatars inside the group
const totalAvatars = React.Children.toArray(children).length;
// if avatars are equal to max + 1, then we need to show the last avatar as well, if avatars are more than max + 1, then we need to show the count of the remaining avatars
const maxAvatarsToRender = totalAvatars <= max + 1 ? max + 1 : max;
// slice the children to the maximum number of avatars
const avatars = React.Children.toArray(children).slice(0, maxAvatarsToRender);
// assign the necessary props from the AvatarGroup component to the Avatar components
const avatarsWithUpdatedProps = avatars.map((avatar) => {
const updatedProps: Partial<Props> = {
showTooltip,
size,
};
return React.cloneElement(avatar as React.ReactElement, updatedProps);
});
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<div className={cn("flex", sizeInfo.spacing)}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div key={index} className="rounded-full border border-subtle-1">
{avatar}
</div>
))}
{maxAvatarsToRender < totalAvatars && (
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
<div
className={cn(
"grid place-items-center rounded-full border border-subtle-1 bg-accent-subtle text-9 text-accent-primary",
{
[sizeInfo.avatarSize]: !isAValidNumber(size),
}
)}
style={
isAValidNumber(size)
? {
width: `${size}px`,
height: `${size}px`,
}
: {}
}
>
+{totalAvatars - max}
</div>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import { Avatar } from "./avatar";
const meta: Meta<typeof Avatar> = {
title: "Avatar",
component: Avatar,
};
export default meta;
type Story = StoryObj<typeof Avatar>;
export const Default: Story = {
args: { name: "John Doe" },
};
export const Large: Story = {
args: { name: "John Doe" },
};

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// ui
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { cn } from "../utils";
import type { TAvatarSize } from "./helper";
import { getBorderRadius, getSizeInfo, isAValidNumber } from "./helper";
type Props = {
/**
* The name of the avatar which will be displayed on the tooltip
*/
name?: string;
/**
* The background color if the avatar image fails to load
*/
fallbackBackgroundColor?: string;
/**
* The text to display if the avatar image fails to load
*/
fallbackText?: string;
/**
* The text color if the avatar image fails to load
*/
fallbackTextColor?: string;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
/**
* The shape of the avatar
* Possible values: "circle", "square"
* @default "circle"
*/
shape?: "circle" | "square";
/**
* The source of the avatar image
*/
src?: string;
/**
* The custom CSS class name to apply to the component
*/
className?: string;
};
export function Avatar(props: Props) {
const {
name,
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
showTooltip = true,
size = "md",
shape = "circle",
src,
className = "",
} = props;
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
<div
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
[sizeInfo.avatarSize]: !isAValidNumber(size),
})}
style={
isAValidNumber(size)
? {
height: `${size}px`,
width: `${size}px`,
}
: {}
}
tabIndex={-1}
>
{src ? (
<img src={src} className={cn("h-full w-full", getBorderRadius(shape), className)} alt={name} />
) : (
<div
className={cn(
sizeInfo.fontSize,
"grid h-full w-full place-items-center",
getBorderRadius(shape),
className
)}
style={{
backgroundColor: fallbackBackgroundColor ?? "#028375",
color: fallbackTextColor ?? "#ffffff",
}}
>
{name?.[0]?.toUpperCase() ?? fallbackText ?? "?"}
</div>
)}
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
/**
* Get the size details based on the size prop
* @param size The size of the avatar
* @returns The size details
*/
export const getSizeInfo = (size: TAvatarSize) => {
switch (size) {
case "sm":
return {
avatarSize: "h-4 w-4",
fontSize: "text-11",
spacing: "-space-x-1",
};
case "md":
return {
avatarSize: "h-5 w-5",
fontSize: "text-11",
spacing: "-space-x-1",
};
case "base":
return {
avatarSize: "h-6 w-6",
fontSize: "text-13",
spacing: "-space-x-1.5",
};
case "lg":
return {
avatarSize: "h-7 w-7",
fontSize: "text-13",
spacing: "-space-x-1.5",
};
default:
return {
avatarSize: "h-5 w-5",
fontSize: "text-11",
spacing: "-space-x-1",
};
}
};
/**
* Get the border radius based on the shape prop
* @param shape The shape of the avatar
* @returns The border radius
*/
export const getBorderRadius = (shape: "circle" | "square") => {
switch (shape) {
case "circle":
return "rounded-full";
case "square":
return "rounded-sm";
default:
return "rounded-full";
}
};
/**
* Check if the value is a valid number
* @param value The value to check
* @returns Whether the value is a valid number or not
*/
export const isAValidNumber = (value: unknown) => typeof value === "number" && !isNaN(value);

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./avatar-group";
export * from "./avatar";

View File

@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import { HomeOutline, SettingsOutline } from "@makeplane/propel/icons";
import * as React from "react";
import { Breadcrumbs } from "./breadcrumbs";
const meta: Meta<typeof Breadcrumbs> = {
title: "UI/Breadcrumbs",
component: Breadcrumbs,
tags: ["autodocs"],
};
type TBreadcrumbBlockProps = {
href?: string;
label?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
};
// TODO: remove this component and use web Link component
function BreadcrumbBlock(props: TBreadcrumbBlockProps) {
const { label, icon, disableTooltip = false } = props;
return (
<>
<Breadcrumbs.ItemWrapper label={label} disableTooltip={disableTooltip}>
{icon && <div className="flex size-4 items-center justify-center overflow-hidden !text-16">{icon}</div>}
{label && <div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>}
</Breadcrumbs.ItemWrapper>
</>
);
}
export default meta;
type Story = StoryObj<typeof Breadcrumbs>;
export const Default: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
<Breadcrumbs.Item
key="current"
component={<BreadcrumbBlock href="/projects/current" label="Current Project" />}
/>,
],
},
};
export const WithLoading: Story = {
args: {
isLoading: true,
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
],
},
};
export const WithCustomComponent: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="custom"
component={
<div className="flex items-center gap-2">
<span className="bg-blue-500 size-4 rounded-full" />
<span>Custom Component</span>
</div>
}
/>,
],
},
};
export const SingleItem: Story = {
args: {
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
},
};
export const WithIcons: Story = {
args: {
children: [
<Breadcrumbs.Item
key="home"
component={<BreadcrumbBlock href="/" label="Home" icon={<HomeOutline className="size-3.5" />} />}
/>,
<Breadcrumbs.Item
key="settings"
component={
<BreadcrumbBlock href="/settings" label="Settings" icon={<SettingsOutline className="size-3.5" />} />
}
isLast
/>,
],
},
};

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { ChevronRightOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "../utils";
type BreadcrumbsProps = {
className?: string;
children: React.ReactNode;
onBack?: () => void;
isLoading?: boolean;
};
export function BreadcrumbItemLoader() {
return (
<div className="flex h-7 animate-pulse items-center gap-2">
<div className="group flex h-full items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium">
<span className="h-full w-5 rounded-sm bg-layer-1" />
<span className="h-full w-16 rounded-sm bg-layer-1" />
</div>
</div>
);
}
function Breadcrumbs({ className, children, onBack, isLoading = false }: BreadcrumbsProps) {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
const handleResize = () => {
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
};
window.addEventListener("resize", handleResize);
handleResize(); // Call it initially to set the correct state
return () => window.removeEventListener("resize", handleResize);
}, []);
const childrenArray = React.Children.toArray(children);
return (
<div className={cn("flex flex-grow items-center gap-0.5 overflow-hidden", className)}>
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => {
if (isLoading) {
return (
<>
<BreadcrumbItemLoader />
</>
);
}
if (React.isValidElement<BreadcrumbItemProps>(child)) {
return React.cloneElement(child, {
isLast: index === childrenArray.length - 1,
});
}
return child;
})}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5 p-1">
{onBack && (
<span onClick={onBack} className="text-secondary">
...
</span>
)}
<ChevronRightOutline className="h-3.5 w-3.5 flex-shrink-0 text-placeholder" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5 p-1">
{isLoading ? (
<BreadcrumbItemLoader />
) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? (
React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement<BreadcrumbItemProps>, {
isLast: true,
})
) : (
childrenArray[childrenArray.length - 1]
)}
</div>
</>
)}
{isSmallScreen && childrenArray.length === 1 && childrenArray}
</div>
);
}
// breadcrumb item
type BreadcrumbItemProps = {
component?: React.ReactNode;
showSeparator?: boolean;
isLast?: boolean;
};
function BreadcrumbItem(props: BreadcrumbItemProps) {
const { component, showSeparator = true, isLast = false } = props;
return (
<div className="flex h-6 items-center gap-0.5">
{component}
{showSeparator && !isLast && <BreadcrumbSeparator />}
</div>
);
}
// breadcrumb icon
type BreadcrumbIconProps = {
children: React.ReactNode;
className?: string;
};
function BreadcrumbIcon(props: BreadcrumbIconProps) {
const { children, className } = props;
return <div className={cn("flex size-4 items-center justify-start overflow-hidden", className)}>{children}</div>;
}
// breadcrumb label
type BreadcrumbLabelProps = {
children: React.ReactNode;
className?: string;
};
function BreadcrumbLabel(props: BreadcrumbLabelProps) {
const { children, className } = props;
return (
<div className={cn("relative line-clamp-1 block max-w-[150px] truncate overflow-hidden", className)}>
{children}
</div>
);
}
// breadcrumb separator
type BreadcrumbSeparatorProps = {
className?: string;
containerClassName?: string;
iconClassName?: string;
showDivider?: boolean;
};
function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
const { className, containerClassName, iconClassName, showDivider = false } = props;
return (
<div className={cn("relative flex h-full items-center justify-center px-1.5 py-1", className)}>
{showDivider && <span className="absolute top-0 -left-0.5 h-full w-[1.8px] bg-surface-1" />}
<div
className={cn(
"flex flex-shrink-0 items-center justify-center rounded-sm text-placeholder transition-all",
containerClassName
)}
>
<ChevronRightOutline className={cn("h-3.5 w-3.5 flex-shrink-0", iconClassName)} />
</div>
</div>
);
}
// breadcrumb wrapper
type BreadcrumbItemWrapperProps = {
label?: string;
disableTooltip?: boolean;
children: React.ReactNode;
className?: string;
type?: "link" | "text";
isLast?: boolean;
};
function BreadcrumbItemWrapper(props: BreadcrumbItemWrapperProps) {
const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props;
return (
<Tooltip tooltipContent={label} position="bottom" disabled={!label || label === "" || disableTooltip}>
<div
className={cn(
"group flex h-full cursor-default items-center gap-2 rounded-sm px-1.5 py-1 text-13 font-medium",
{
"text-primary": isLast,
"text-tertiary": !isLast,
"cursor-pointer hover:bg-layer-transparent-hover hover:text-primary": type === "link" && !isLast,
},
className
)}
>
{children}
</div>
</Tooltip>
);
}
Breadcrumbs.Item = BreadcrumbItem;
Breadcrumbs.Icon = BreadcrumbIcon;
Breadcrumbs.Label = BreadcrumbLabel;
Breadcrumbs.Separator = BreadcrumbSeparator;
Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper;
export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper };

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./breadcrumbs";
export * from "./navigation-search-dropdown";

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "../dropdowns";
import { cn } from "../utils";
import { Breadcrumbs } from "./breadcrumbs";
type TBreadcrumbNavigationSearchDropdownProps = {
icon?: React.ReactNode;
title?: string;
selectedItem: string;
navigationItems: ICustomSearchSelectOption[];
onChange?: (value: string) => void;
navigationDisabled?: boolean;
isLast?: boolean;
handleOnClick?: () => void;
disableRootHover?: boolean;
shouldTruncate?: boolean;
};
export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) {
const {
icon,
title,
selectedItem,
navigationItems,
onChange,
navigationDisabled = false,
isLast = false,
handleOnClick,
shouldTruncate = false,
} = props;
// state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<CustomSearchSelect
onOpen={() => {
setIsDropdownOpen(true);
}}
onClose={() => {
setIsDropdownOpen(false);
}}
options={navigationItems}
value={selectedItem}
onChange={(value: string) => {
if (value !== selectedItem) {
onChange?.(value);
}
}}
customButton={
<>
<Tooltip tooltipContent={title} position="bottom">
<button
onClick={(e) => {
if (!isLast) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
className={cn(
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
{
"hover:bg-layer-1 hover:text-primary": !isLast,
}
)}
>
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>}
<div
className={cn("flex gap-2", {
"hidden items-center gap-2 @4xl:flex": shouldTruncate,
})}
>
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</div>
</button>
</Tooltip>
<Breadcrumbs.Separator
className={cn("rounded-r-sm", {
"bg-layer-1": isDropdownOpen && !isLast,
"hover:bg-layer-1": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
"text-primary": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
})}
showDivider={!isLast}
/>
</>
}
disabled={navigationDisabled}
className="h-full rounded-sm"
customButtonClassName={cn(
"group flex h-full cursor-pointer items-center gap-0.5 rounded-sm outline-none hover:bg-surface-2",
{
"bg-surface-2": isDropdownOpen,
}
)}
/>
);
}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
import type { TButtonVariant, TButtonSizes } from "./helper";
import { getIconStyling, getButtonStyling } 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(function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
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 };

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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-11 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
md = `px-4 py-1.5 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
lg = `px-5 py-2 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
xl = `px-5 py-3.5 font-medium text-13 rounded-sm 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-on-color bg-accent-primary`,
hover: `hover:bg-accent-primary/80`,
pressed: `focus:text-custom-brand-40 focus:bg-accent-primary/80`,
disabled: `cursor-not-allowed !bg-layer-1 !text-on-color-disabled`,
},
"accent-primary": {
default: `bg-accent-primary/20 text-accent-primary`,
hover: `hover:bg-accent-primary/10 hover:text-accent-secondary`,
pressed: `focus:bg-accent-primary/10`,
disabled: `cursor-not-allowed !text-accent-primary/60`,
},
"outline-primary": {
default: `text-accent-primary bg-transparent border border-accent-strong`,
hover: `hover:bg-accent-primary/20`,
pressed: `focus:text-accent-primary focus:bg-accent-primary/30`,
disabled: `cursor-not-allowed !text-accent-primary/60 !border-accent-strong-60 `,
},
"neutral-primary": {
default: `text-secondary bg-surface-1 border border-subtle`,
hover: `hover:bg-surface-2`,
pressed: `focus:text-tertiary focus:bg-surface-2`,
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
},
"link-primary": {
default: `text-accent-primary bg-surface-1`,
hover: `hover:text-accent-secondary`,
pressed: `focus:text-accent-primary/80 `,
disabled: `cursor-not-allowed !text-accent-primary/60`,
},
danger: {
default: `bg-danger-primary text-on-color`,
hover: ` hover:bg-danger-primary-hover`,
pressed: `focus:bg-danger-primary-active`,
disabled: `cursor-not-allowed bg-layer-disabled! text-disabled!`,
},
"accent-danger": {
default: `text-danger-primary bg-red-50`,
hover: `hover:text-danger-primary hover:bg-red-100`,
pressed: `focus:text-danger-primary focus:bg-red-100`,
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
},
"outline-danger": {
default: `bg-layer-2 text-danger-primary border border-danger-strong`,
hover: `hover:bg-danger-subtle`,
pressed: `focus:bg-danger-subtle-hover`,
disabled: `cursor-not-allowed text-disabled! border-subtle-1!`,
},
"link-danger": {
default: `text-danger-primary bg-surface-1`,
hover: `hover:text-danger-primary`,
pressed: `focus:text-danger-primary`,
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
},
"tertiary-danger": {
default: `text-danger-primary bg-surface-1 border border-danger-subtle`,
hover: `hover:bg-red-50 hover:border-danger-subtle`,
pressed: `focus:text-danger-primary`,
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
},
"link-neutral": {
default: `text-tertiary`,
hover: `hover:text-secondary`,
pressed: `focus:text-primary`,
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
},
};
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;
};

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
import type { TCardDirection, TCardSpacing, TCardVariant } from "./helper";
import { ECardDirection, ECardSpacing, ECardVariant, getCardStyle } from "./helper";
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: TCardVariant;
spacing?: TCardSpacing;
direction?: TCardDirection;
className?: string;
children: React.ReactNode;
}
const Card = React.forwardRef(function Card(props: CardProps, ref: React.ForwardedRef<HTMLDivElement>) {
const {
variant = ECardVariant.WITH_SHADOW,
direction = ECardDirection.COLUMN,
className = "",
spacing = ECardSpacing.LG,
children,
...rest
} = props;
const style = getCardStyle(variant, spacing, direction);
return (
<div ref={ref} className={cn(style, className)} {...rest}>
{children}
</div>
);
});
Card.displayName = "plane-ui-card";
export { Card, ECardVariant, ECardSpacing, ECardDirection };

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum ECardVariant {
WITHOUT_SHADOW = "without-shadow",
WITH_SHADOW = "with-shadow",
}
export enum ECardDirection {
ROW = "row",
COLUMN = "column",
}
export enum ECardSpacing {
SM = "sm",
LG = "lg",
}
export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW;
export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN;
export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG;
export interface ICardProperties {
[key: string]: string;
}
const DEFAULT_STYLE = "bg-surface-1 rounded-lg border-[0.5px] border-subtle w-full flex flex-col";
export const containerStyle: ICardProperties = {
[ECardVariant.WITHOUT_SHADOW]: "",
[ECardVariant.WITH_SHADOW]: "hover:shadow-raised-200 duration-300",
};
export const spacings = {
[ECardSpacing.SM]: "p-4",
[ECardSpacing.LG]: "p-6",
};
export const directions = {
[ECardDirection.ROW]: "flex-row space-x-3",
[ECardDirection.COLUMN]: "flex-col space-y-3",
};
export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) =>
DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing];

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./card";

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import type { ISvgIcons } from "@plane/propel/icons";
import { DropdownOutline } from "@makeplane/propel/icons";
import { cn } from "../utils";
type Props = {
isOpen: boolean;
title: React.ReactNode;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;
className?: string;
titleClassName?: string;
ChevronIcon?: React.FC<ISvgIcons>;
};
export function CollapsibleButton(props: Props) {
const {
isOpen,
title,
hideChevron = false,
indicatorElement,
actionItemElement,
className = "",
titleClassName = "",
ChevronIcon = DropdownOutline,
} = props;
return (
<div className={cn("flex h-12 items-center justify-between gap-3 border-b border-subtle px-2.5 py-3", className)}>
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
<ChevronIcon
className={cn("size-2 text-tertiary duration-300 hover:text-secondary", {
"-rotate-90": !isOpen,
})}
/>
)}
<span className={cn("text-14 font-medium text-primary", titleClassName)}>{title}</span>
</div>
{indicatorElement && indicatorElement}
</div>
{actionItemElement && isOpen && actionItemElement}
</div>
);
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Disclosure, Transition } from "@headlessui/react";
import React, { useState, useEffect, useCallback } from "react";
export type TCollapsibleProps = {
title: string | React.ReactNode;
children: React.ReactNode;
buttonRef?: React.RefObject<HTMLButtonElement | null>;
className?: string;
buttonClassName?: string;
isOpen?: boolean;
onToggle?: () => void;
defaultOpen?: boolean;
};
export function Collapsible(props: TCollapsibleProps) {
const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
// state
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
useEffect(() => {
if (isOpen !== undefined) {
setLocalIsOpen(isOpen);
}
}, [isOpen]);
// handlers
const handleOnClick = useCallback(() => {
if (isOpen !== undefined) {
if (onToggle) onToggle();
} else {
setLocalIsOpen((prev) => !prev);
}
}, [isOpen, onToggle]);
return (
<Disclosure as="div" className={className}>
<Disclosure.Button ref={buttonRef} className={buttonClassName} onClick={handleOnClick}>
{title}
</Disclosure.Button>
<Transition
as="div"
show={localIsOpen}
enter="transition-all duration-300 ease-in-out"
enterFrom="grid-rows-[0fr] opacity-0"
enterTo="grid-rows-[1fr] opacity-100"
leave="transition-all duration-300 ease-in-out"
leaveFrom="grid-rows-[1fr] opacity-100"
leaveTo="grid-rows-[0fr] opacity-0"
className="grid overflow-hidden"
>
<Disclosure.Panel static className="min-h-0">
{children}
</Disclosure.Panel>
</Transition>
</Disclosure>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./collapsible";
export * from "./collapsible-button";

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { Row } from "../row";
import type { TRowVariant } from "../row/helper";
import { ERowVariant } from "../row/helper";
import { cn } from "../utils";
export interface ContentWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: TRowVariant;
className?: string;
children: React.ReactNode;
}
const DEFAULT_STYLE = "flex flex-col vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto";
const ContentWrapper = React.forwardRef(function ContentWrapper(
props: ContentWrapperProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { variant = ERowVariant.REGULAR, className = "", children, ...rest } = props;
return (
<Row
ref={ref}
variant={variant}
className={cn(
DEFAULT_STYLE,
{
"py-page-y": variant === ERowVariant.REGULAR,
},
className
)}
{...rest}
>
{children}
</Row>
);
});
ContentWrapper.displayName = "plane-ui-wrapper";
export { ContentWrapper };

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./content-wrapper";

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
children: React.ReactNode;
target?: string;
disabled?: boolean;
className?: string;
draggable?: boolean;
};
export const ControlLink = React.forwardRef(function ControlLink(
props: TControlLink,
ref: React.ForwardedRef<HTMLAnchorElement>
) {
const { href, onClick, children, target = "_blank", disabled = false, className, draggable = false, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE;
if (!clickCondition) {
event.preventDefault();
onClick(event);
}
};
// if disabled but still has a ref or a className then it has to be rendered without a href
if (disabled && (ref || className))
return (
<a ref={ref} className={className}>
{children}
</a>
);
// else if just disabled return without the parent wrapper
if (disabled) return <>{children}</>;
return (
<a
href={href}
target={target}
onClick={handleOnClick}
{...rest}
ref={ref}
className={className}
draggable={draggable}
>
{children}
</a>
);
});
ControlLink.displayName = "ControlLink";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./control-link";

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { MoreVerticalOutline } from "@makeplane/propel/icons";
import React, { forwardRef } from "react";
// helpers
import { cn } from "./utils";
interface IDragHandle {
className?: string;
disabled?: boolean;
}
export const DragHandle = forwardRef(function DragHandle(
props: IDragHandle,
ref: React.ForwardedRef<HTMLButtonElement | null>
) {
const { className, disabled = false } = props;
if (disabled) {
return <div className="h-[18px] w-[14px]" />;
}
return (
<button
type="button"
className={cn("flex flex-shrink-0 cursor-grab rounded-sm bg-surface-2 p-0.5 text-secondary", className)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
ref={ref}
>
<MoreVerticalOutline className="h-3.5 w-3.5 text-placeholder" />
<MoreVerticalOutline className="-ml-5 h-3.5 w-3.5 text-placeholder" />
</button>
);
});
DragHandle.displayName = "DragHandle";

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { cn } from "./utils";
type Props = {
isVisible: boolean;
classNames?: string;
};
export function DropIndicator(props: Props) {
const { isVisible, classNames = "" } = props;
return (
<div
className={cn(
`relative block h-[2px] w-full before:relative before:top-[-2px] before:left-0 before:block before:h-[6px] before:w-[6px] before:rounded-sm after:relative after:top-[-8px] after:left-[calc(100%-6px)] after:block after:h-[6px] after:w-[6px] after:rounded-sm`,
{
"bg-accent-primary before:bg-accent-primary after:bg-accent-primary": isVisible,
},
classNames
)}
/>
);
}

View File

@@ -0,0 +1,48 @@
Below is a detailed list of the props included:
### Root Props
- value: string | string[]; - Current selected value.
- onChange: (value: string | string []) => void; - Callback function for handling value changes.
- options: TDropdownOption[] | undefined; - Array of options.
- onOpen?: () => void; - Callback function triggered when the dropdown opens.
- onClose?: () => void; - Callback function triggered when the dropdown closes.
- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state.
- tabIndex?: number; - Sets the tab index for the dropdown.
- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right).
- disabled?: boolean; - Disables the dropdown if set to true.
---
### Button Props
- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state.
- buttonContainerClassName?: string; - Class name for the button container.
- buttonClassName?: string; - Class name for the button itself.
---
### Input Props
- disableSearch?: boolean; - Disables the search input if set to true.
- inputPlaceholder?: string; - Placeholder text for the search input.
- inputClassName?: string; - Class name for the search input.
- inputIcon?: React.ReactNode; - Icon to be displayed in the search input.
- inputContainerClassName?: string; - Class name for the search input container.
---
### Options Props
- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option.
- optionsContainerClassName?: string; - Class name for the options container.
- queryArray: string[]; - Array of strings to be used for querying the options.
- sortByKey: string; - Key to sort the options by.
- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item.
- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option.
- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded.
- disableSorting?: boolean; - Disables sorting of the options if set to true.
---
These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios.

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import React, { Fragment } from "react";
// helper
import { cn } from "../../utils";
import type { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
export function DropdownButton(props: IMultiSelectDropdownButton | ISingleSelectDropdownButton) {
const {
isOpen,
buttonContent,
buttonClassName,
buttonContainerClassName,
handleOnClick,
value,
setReferenceElement,
disabled,
} = props;
return (
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
{buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
</button>
</Combobox.Button>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./input-search";
export * from "./button";
export * from "./options";
export * from "./loader";

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import React, { useEffect, useRef } from "react";
import { SearchOutline } from "@makeplane/propel/icons";
// helpers
import { cn } from "../../utils";
interface IInputSearch {
isOpen: boolean;
query: string;
updateQuery: (query: string) => void;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
inputClassName?: string;
inputPlaceholder?: string;
isMobile: boolean;
}
export function InputSearch(props: IInputSearch) {
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder, isMobile } =
props;
const inputRef = useRef<HTMLInputElement | null>(null);
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
updateQuery("");
}
};
useEffect(() => {
if (isOpen && !isMobile) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputRef.current && inputRef.current.focus();
}
}, [isOpen, isMobile]);
return (
<div
className={cn(
"flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2",
inputContainerClassName
)}
>
{inputIcon ? <>{inputIcon}</> : <SearchOutline className="h-4 w-4 text-tertiary" aria-hidden="true" />}
<Combobox.Input
as="input"
ref={inputRef}
className={cn(
"w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none",
inputClassName
)}
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={inputPlaceholder ?? "Search"}
onKeyDown={searchInputKeyDown}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { range } from "lodash-es";
import React from "react";
export function DropdownOptionsLoader() {
return (
<div className="flex animate-pulse flex-col gap-1">
{range(6).map((index) => (
<div key={index} className="flex h-[1.925rem] w-full rounded-sm bg-surface-2 px-1 py-1.5" />
))}
</div>
);
}

View File

@@ -0,0 +1,100 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import React from "react";
import { TickOutline } from "@makeplane/propel/icons";
// helpers
import { cn } from "../../utils";
// types
import type { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
// components
import { DropdownOptionsLoader, InputSearch } from ".";
export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSelectDropdownOptions) {
const {
isOpen,
query,
setQuery,
inputIcon,
inputPlaceholder,
inputClassName,
inputContainerClassName,
disableSearch,
keyExtractor,
options,
handleClose,
renderItem,
loader,
isMobile = false,
} = props;
return (
<>
{!disableSearch && (
<InputSearch
isOpen={isOpen}
query={query}
updateQuery={(query) => setQuery(query)}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
isMobile={isMobile}
/>
)}
<div className={cn("max-h-48 overflow-y-scroll", !disableSearch && "mt-2")}>
<>
{options ? (
options.length > 0 ? (
<ul className="space-y-1">
{options?.map((option) => (
<Combobox.Option
as="li"
key={keyExtractor(option)}
value={keyExtractor(option)}
disabled={option.disabled}
className={({ active, selected }) =>
cn(
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
{
"bg-layer-1": active,
"text-primary": selected,
"text-secondary": !selected,
},
option.className && option.className({ active, selected })
)
}
onClick={handleClose}
>
{({ selected }) => (
<>
{renderItem ? (
<>{renderItem({ value: keyExtractor(option), selected, disabled: option.disabled })}</>
) : (
<>
<span className="flex-grow truncate">{option.value}</span>
{selected && <TickOutline className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</>
)}
</Combobox.Option>
))}
</ul>
) : (
<p className="px-1.5 py-1 text-placeholder italic">No matching results</p>
)
) : loader ? (
<> {loader} </>
) : (
<DropdownOptionsLoader />
)}
</>
</div>
</>
);
}

108
packages/ui/src/dropdown/dropdown.d.ts vendored Normal file
View File

@@ -0,0 +1,108 @@
import type { Placement } from "@popperjs/core";
export interface IDropdown {
// root props
onOpen?: () => void;
onClose?: () => void;
containerClassName?: string | ((isOpen: boolean) => string);
tabIndex?: number;
placement?: Placement;
disabled?: boolean;
// button props
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
buttonContainerClassName?: string;
buttonClassName?: string;
// input props
disableSearch?: boolean;
inputPlaceholder?: string;
inputClassName?: string;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
// options props
keyExtractor: (option: TDropdownOption) => string;
optionsContainerClassName?: string;
queryArray?: string[];
sortByKey?: string;
firstItem?: (optionValue: string) => boolean;
renderItem?: ({
value,
selected,
disabled,
}: {
value: string;
selected: boolean;
disabled?: boolean;
}) => React.ReactNode;
loader?: React.ReactNode;
disableSorting?: boolean;
}
export interface TDropdownOption {
data: any;
value: string;
className?: ({ active, selected }: { active: boolean; selected?: boolean }) => string;
disabled?: boolean;
}
export interface IMultiSelectDropdown extends IDropdown {
value: string[];
onChange: (value: string[]) => void;
options: TDropdownOption[] | undefined;
}
export interface ISingleSelectDropdown extends IDropdown {
value: string;
onChange: (value: string) => void;
options: TDropdownOption[] | undefined;
}
export interface IDropdownButton {
isOpen: boolean;
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
buttonClassName?: string;
buttonContainerClassName?: string;
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
setReferenceElement: (element: HTMLButtonElement | null) => void;
disabled?: boolean;
}
export interface IMultiSelectDropdownButton extends IDropdownButton {
value: string[];
}
export interface ISingleSelectDropdownButton extends IDropdownButton {
value: string;
}
export interface IDropdownOptions {
isOpen: boolean;
query: string;
setQuery: (query: string) => void;
inputPlaceholder?: string;
inputClassName?: string;
inputIcon?: React.ReactNode;
inputContainerClassName?: string;
disableSearch?: boolean;
handleClose?: () => void;
keyExtractor: (option: TDropdownOption) => string;
renderItem:
| (({ value, selected, disabled }: { value: string; selected: boolean; disabled?: boolean }) => React.ReactNode)
| undefined;
options: TDropdownOption[] | undefined;
loader?: React.ReactNode;
isMobile?: boolean;
}
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
value: string[];
}
export interface ISingleSelectDropdownOptions extends IDropdownOptions {
value: string;
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./single-select";

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es";
import React, { useMemo, useRef, useState } from "react";
import { usePopper } from "react-popper";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
// local imports
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
import { cn } from "../utils";
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
import type { ISingleSelectDropdown } from "./dropdown";
export function Dropdown(props: ISingleSelectDropdown) {
const {
value,
onChange,
options,
onOpen,
onClose,
containerClassName,
tabIndex,
placement,
disabled,
buttonContent,
buttonContainerClassName,
buttonClassName,
disableSearch,
inputPlaceholder,
inputClassName,
inputIcon,
inputContainerClassName,
keyExtractor,
optionsContainerClassName,
queryArray,
sortByKey,
firstItem,
renderItem,
loader = false,
disableSorting,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// handlers
const toggleDropdown = () => {
if (!isOpen) onOpen?.();
setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose?.();
};
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose?.();
setQuery?.("");
};
// options
const sortedOptions = useMemo(() => {
if (!options) return undefined;
const filteredOptions = queryArray
? (options || []).filter((options) => {
const queryString = queryArray.map((query) => options.data[query]).join(" ");
return queryString.toLowerCase().includes(query.toLowerCase());
})
: options;
if (disableSorting || !sortByKey) return filteredOptions;
return sortBy(filteredOptions, [
(option) => firstItem && firstItem(option.data[option.value]),
(option) => !(value ?? []).includes(option.data[option.value]),
() => sortByKey && sortByKey.toLowerCase(),
]);
}, [query, options]);
// hooks
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
useOutsideClickDetector(dropdownRef, handleClose, true);
return (
<Combobox
as="div"
ref={dropdownRef}
value={value}
// Headless UI v2 widens a non-multiple Combobox to `T | null`. v1 never emitted
// null, so drop it here and keep this component's non-nullable onChange contract.
onChange={(selected) => {
if (selected !== null) onChange(selected);
}}
className={cn(
"h-full",
typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName
)}
tabIndex={tabIndex}
onKeyDown={handleKeyDown}
disabled={disabled}
>
<DropdownButton
value={value}
isOpen={isOpen}
setReferenceElement={setReferenceElement}
handleOnClick={handleOnClick}
buttonContent={buttonContent}
buttonClassName={buttonClassName}
buttonContainerClassName={buttonContainerClassName}
disabled={disabled}
/>
{isOpen && (
<Combobox.Options as="ul" className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
optionsContainerClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
handleClose={handleClose}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import type { ElementType, KeyboardEventHandler, ReactNode, Ref } from "react";
import React, { Fragment, forwardRef, useEffect, useRef, useState } from "react";
type Props = {
as?: ElementType | undefined;
ref?: Ref<HTMLElement> | undefined;
tabIndex?: number | undefined;
className?: string | undefined;
value?: string | string[] | null;
onChange?: (value: any) => void;
disabled?: boolean | undefined;
onKeyDown?: KeyboardEventHandler<HTMLDivElement> | undefined;
multiple?: boolean;
renderByDefault?: boolean;
button: ReactNode;
children: ReactNode;
};
const ComboDropDown = forwardRef(function ComboDropDown(props: Props, ref) {
const { button, renderByDefault = true, children, ...rest } = props;
const dropDownButtonRef = useRef<HTMLDivElement | null>(null);
const [shouldRender, setShouldRender] = useState(renderByDefault);
const onHover = () => {
setShouldRender(true);
};
useEffect(() => {
const element = dropDownButtonRef.current as any;
if (!element) return;
element.addEventListener("mouseenter", onHover);
return () => {
element?.removeEventListener("mouseenter", onHover);
};
}, [dropDownButtonRef, shouldRender]);
if (!shouldRender) {
return (
<div ref={dropDownButtonRef} className="flex h-full items-center">
{button}
</div>
);
}
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
<Combobox {...rest} ref={ref}>
<Combobox.Button as={Fragment}>{button}</Combobox.Button>
{children}
</Combobox>
);
});
ComboDropDown.displayName = "ComboDropDown";
export { ComboDropDown };

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./item";
export * from "./root";

View File

@@ -0,0 +1,249 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState, useRef, useContext } from "react";
import { usePopper } from "react-popper";
import { ChevronRightOutline } from "@makeplane/propel/icons";
// helpers
import { cn } from "../../utils";
// types
import type { TContextMenuItem } from "./root";
import { ContextMenuContext, Portal } from "./root";
type ContextMenuItemProps = {
handleActiveItem: () => void;
handleClose: () => void;
isActive: boolean;
item: TContextMenuItem;
};
export function ContextMenuItem(props: ContextMenuItemProps) {
const { handleActiveItem, handleClose, isActive, item } = props;
// Nested menu state
const [isNestedOpen, setIsNestedOpen] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [activeNestedIndex, setActiveNestedIndex] = useState<number>(0);
const nestedMenuRef = useRef<HTMLDivElement | null>(null);
const contextMenuContext = useContext(ContextMenuContext);
const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0;
const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || [];
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right-start",
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, 4],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 8,
},
},
],
});
const closeNestedMenu = React.useCallback(() => {
setIsNestedOpen(false);
setActiveNestedIndex(0);
}, []);
// Register this nested menu with the main context
React.useEffect(() => {
if (contextMenuContext && hasNestedItems) {
return contextMenuContext.registerSubmenu(closeNestedMenu);
}
}, [contextMenuContext, hasNestedItems, closeNestedMenu]);
const handleItemClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (hasNestedItems) {
// Toggle nested menu
if (!isNestedOpen && contextMenuContext) {
contextMenuContext.closeAllSubmenus();
}
setIsNestedOpen(!isNestedOpen);
} else {
// Execute action for regular items
item.action();
if (item.closeOnClick !== false) handleClose();
}
};
const handleMouseEnter = () => {
handleActiveItem();
if (hasNestedItems) {
// Close other submenus and open this one
if (contextMenuContext) {
contextMenuContext.closeAllSubmenus();
}
setIsNestedOpen(true);
}
};
const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
nestedItem.action();
if (nestedItem.closeOnClick !== false) {
handleClose(); // Close the entire context menu
}
};
// Handle keyboard navigation for nested items
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isNestedOpen || !hasNestedItems) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length);
}
if (e.key === "Enter") {
e.preventDefault();
const nestedItem = renderedNestedItems[activeNestedIndex];
if (!nestedItem.disabled) {
handleNestedItemClick(nestedItem);
}
}
if (e.key === "ArrowLeft") {
e.preventDefault();
closeNestedMenu();
}
};
if (isNestedOpen && nestedMenuRef.current) {
const menuElement = nestedMenuRef.current;
menuElement.addEventListener("keydown", handleKeyDown);
// Ensure the menu can receive keyboard events
menuElement.setAttribute("tabindex", "-1");
menuElement.focus();
return () => {
menuElement.removeEventListener("keydown", handleKeyDown);
};
}
}, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]);
if (item.shouldRender === false) return null;
return (
<>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
{
"bg-layer-transparent-hover": isActive,
"text-placeholder": item.disabled,
},
item.className
)}
onClick={handleItemClick}
onMouseEnter={handleMouseEnter}
disabled={item.disabled}
>
{item.customContent ?? (
<>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div className="flex-1">
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
{hasNestedItems && <ChevronRightOutline className="h-3 w-3 flex-shrink-0" />}
</>
)}
</button>
{/* Nested Menu */}
{hasNestedItems && isNestedOpen && (
<Portal container={contextMenuContext?.portalContainer}>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11"
data-context-submenu="true"
>
<div ref={nestedMenuRef} className="vertical-scrollbar scrollbar-sm max-h-72 overflow-y-scroll">
{renderedNestedItems.map((nestedItem, index) => (
<button
key={nestedItem.key}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
{
"bg-layer-transparent-hover": index === activeNestedIndex,
"text-placeholder": nestedItem.disabled,
},
nestedItem.className
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleNestedItemClick(nestedItem, e);
}}
onMouseEnter={() => setActiveNestedIndex(index)}
disabled={nestedItem.disabled}
data-context-submenu="true"
>
{nestedItem.customContent ?? (
<>
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
<div>
<h5>{nestedItem.title}</h5>
{nestedItem.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</>
)}
</button>
))}
</div>
</div>
</Portal>
)}
</>
);
}

View File

@@ -0,0 +1,243 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// hooks
import { usePlatformOS } from "../../hooks/use-platform-os";
// helpers
import { cn } from "../../utils";
// components
import { ContextMenuItem } from "./item";
export type TContextMenuItem = {
key: string;
customContent?: React.ReactNode;
title?: string;
description?: string;
icon?: React.FC<any>;
action: () => void;
shouldRender?: boolean;
closeOnClick?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
nestedMenuItems?: TContextMenuItem[];
};
// Portal component for nested menus
interface PortalProps {
children: React.ReactNode;
container?: Element | null;
}
export function Portal({ children, container }: PortalProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) {
return null;
}
const targetContainer = container || document.body;
return ReactDOM.createPortal(children, targetContainer);
}
// Context for managing nested menus
export const ContextMenuContext = React.createContext<{
closeAllSubmenus: () => void;
registerSubmenu: (closeSubmenu: () => void) => () => void;
portalContainer?: Element | null;
} | null>(null);
type ContextMenuProps = {
parentRef: React.RefObject<HTMLElement | null>;
items: TContextMenuItem[];
portalContainer?: Element | null;
};
function ContextMenuWithoutPortal(props: ContextMenuProps) {
const { parentRef, items, portalContainer } = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
x: 0,
y: 0,
});
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
// refs
const contextMenuRef = useRef<HTMLDivElement>(null);
const submenuClosersRef = useRef<Set<() => void>>(new Set());
// derived values
const renderedItems = items.filter((item) => item.shouldRender !== false);
const { isMobile } = usePlatformOS();
const closeAllSubmenus = React.useCallback(() => {
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
}, []);
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
submenuClosersRef.current.add(closeSubmenu);
return () => {
submenuClosersRef.current.delete(closeSubmenu);
};
}, []);
const handleClose = () => {
closeAllSubmenus();
setIsOpen(false);
setActiveItemIndex(0);
};
// calculate position of context menu
useEffect(() => {
const parentElement = parentRef.current;
const contextMenu = contextMenuRef.current;
if (!parentElement || !contextMenu) return;
const handleContextMenu = (e: MouseEvent) => {
if (isMobile) return;
e.preventDefault();
e.stopPropagation();
const contextMenuWidth = contextMenu.clientWidth;
const contextMenuHeight = contextMenu.clientHeight;
const clickX = e?.pageX || 0;
const clickY = e?.pageY || 0;
// check if there's enough space at the bottom, otherwise show at the top
let top = clickY;
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
// check if there's enough space on the right, otherwise show on the left
let left = clickX;
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
setPosition({ x: left, y: top });
setIsOpen(true);
};
const hideContextMenu = (e: KeyboardEvent) => {
if (isOpen && e.key === "Escape") handleClose();
};
parentElement.addEventListener("contextmenu", handleContextMenu);
window.addEventListener("keydown", hideContextMenu);
return () => {
parentElement.removeEventListener("contextmenu", handleContextMenu);
window.removeEventListener("keydown", hideContextMenu);
};
}, [contextMenuRef, isMobile, isOpen, parentRef, setIsOpen, setPosition]);
// handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
}
if (e.key === "Enter") {
e.preventDefault();
const item = renderedItems[activeItemIndex];
if (!item.disabled) {
renderedItems[activeItemIndex].action();
if (item.closeOnClick !== false) handleClose();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
// Custom handler for nested menu portal clicks
React.useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Check if the click is on a nested menu element
const isNestedMenuClick = target.closest('[data-context-submenu="true"]');
const isMainMenuClick = contextMenuRef.current?.contains(target);
// Also check if the target itself has the data attribute
const isNestedMenuElement = target.hasAttribute("data-context-submenu");
// If it's a nested menu click, main menu click, or nested menu element, don't close
if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) {
return;
}
// If menu is open and it's an outside click, close it
if (isOpen) {
handleClose();
}
};
if (isOpen) {
// Use capture phase to ensure we handle the event before other handlers
document.addEventListener("mousedown", handleDocumentClick, true);
return () => {
document.removeEventListener("mousedown", handleDocumentClick, true);
};
}
}, [isOpen, handleClose]);
return (
<div
className={cn(
"pointer-events-none fixed top-0 left-0 z-30 h-screen w-screen cursor-default opacity-0 transition-opacity",
{
"pointer-events-auto opacity-100": isOpen,
}
)}
>
<div
ref={contextMenuRef}
className="vertical-scrollbar fixed scrollbar-sm max-h-72 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5"
style={{
top: position.y,
left: position.x,
}}
data-context-menu="true"
>
<ContextMenuContext.Provider value={{ closeAllSubmenus, registerSubmenu, portalContainer }}>
{renderedItems.map((item, index) => (
<ContextMenuItem
key={item.key}
handleActiveItem={() => setActiveItemIndex(index)}
handleClose={handleClose}
isActive={index === activeItemIndex}
item={item}
/>
))}
</ContextMenuContext.Provider>
</div>
</div>
);
}
export function ContextMenu(props: ContextMenuProps) {
let contextMenu = <ContextMenuWithoutPortal {...props} />;
const portal = document.querySelector("#context-menu-portal");
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
return contextMenu;
}

View File

@@ -0,0 +1,544 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Menu } from "@headlessui/react";
import { ChevronDownOutline, ChevronRightOutline, MoreHorizontalOutline } from "@makeplane/propel/icons";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers
// helpers
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
// hooks
// types
import type {
ICustomMenuDropdownProps,
ICustomMenuItemProps,
ICustomSubMenuProps,
ICustomSubMenuTriggerProps,
ICustomSubMenuContentProps,
} from "./helper";
interface PortalProps {
children: React.ReactNode;
container?: Element | null;
asChild?: boolean;
}
function Portal({ children, container, asChild = false }: PortalProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) {
return null;
}
const targetContainer = container || document.body;
if (asChild) {
return ReactDOM.createPortal(children, targetContainer);
}
return ReactDOM.createPortal(<div data-radix-portal="">{children}</div>, targetContainer);
}
// Context for main menu to communicate with submenus
const MenuContext = React.createContext<{
closeAllSubmenus: () => void;
registerSubmenu: (closeSubmenu: () => void) => () => void;
} | null>(null);
function CustomMenu(props: ICustomMenuDropdownProps) {
const {
ariaLabel,
buttonClassName = "",
customButtonClassName = "",
customButtonTabIndex = 0,
placement,
children,
className = "",
customButton,
disabled = false,
ellipsis = false,
label,
maxHeight = "md",
noBorder = false,
noChevron = false,
optionsClassName = "",
menuItemsClassName = "",
verticalEllipsis = false,
portalElement,
menuButtonOnClick,
onMenuClose,
tabIndex,
closeOnSelect,
openOnHover = false,
useCaptureForOutsideClick = false,
} = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false);
// refs
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
const closeAllSubmenus = React.useCallback(() => {
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
}, []);
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
submenuClosersRef.current.add(closeSubmenu);
return () => {
submenuClosersRef.current.delete(closeSubmenu);
};
}, []);
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = React.useCallback(() => {
if (isOpen) {
closeAllSubmenus();
onMenuClose?.();
}
setIsOpen(false);
}, [isOpen, closeAllSubmenus, onMenuClose]);
const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button`
);
activeItem?.click();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
const handleOnClick = () => {
if (closeOnSelect) closeDropdown();
};
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
if (isOpen) {
closeDropdown();
} else {
openDropdown();
}
if (menuButtonOnClick) menuButtonOnClick();
};
const handleMouseEnter = () => {
if (openOnHover) openDropdown();
};
const handleMouseLeave = () => {
if (openOnHover && isOpen) {
setTimeout(() => {
// Only close if menu is still open
if (isOpen) {
closeDropdown();
}
}, 150); // Small delay to allow moving to submenu
}
};
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
// Custom handler for submenu portal clicks
React.useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]');
const isMainMenuClick = dropdownRef.current?.contains(target);
// If it's a submenu click or main menu click, don't close
if (isSubmenuClick || isMainMenuClick) {
return;
}
// If menu is open and it's an outside click, close it
if (isOpen) {
closeDropdown();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
return () => {
document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
};
}
}, [isOpen, closeDropdown, useCaptureForOutsideClick]);
const menuContextValue = React.useMemo(
() => ({ closeAllSubmenus, registerSubmenu }),
[closeAllSubmenus, registerSubmenu]
);
let menuItems = (
<Menu.Items
data-prevent-outside-click={!!portalElement}
className={cn(
"fixed z-30 translate-y-0",
menuItemsClassName
)} /** translate-y-0 is a hack to create new stacking context. Required for safari */
static
>
<div
className={cn(
"shadow-md my-1 min-w-[12rem] overflow-y-scroll rounded-md border border-strong-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap ring-1 ring-strong-1/15 outline-none focus:outline-none",
{
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
},
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<MenuContext.Provider value={menuContextValue}>{children}</MenuContext.Provider>
</div>
</Menu.Items>
);
if (portalElement) {
menuItems = ReactDOM.createPortal(menuItems, portalElement);
}
return (
<Menu
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("relative w-min text-left", className)}
onKeyDown={handleKeyDown}
role="presentation"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleOnClick();
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-main-menu="true"
>
{({ open }) => (
<>
{customButton ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{customButton}
</button>
</Menu.Button>
) : (
<>
{ellipsis || verticalEllipsis ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
disabled={disabled}
className={`relative grid place-items-center rounded-sm p-1 text-secondary outline-none hover:text-primary ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${buttonClassName}`}
tabIndex={customButtonTabIndex}
aria-label={ariaLabel}
>
<MoreHorizontalOutline className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
</button>
</Menu.Button>
) : (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 ${
open ? "text-primary" : "text-secondary"
} ${noBorder ? "" : "shadow-sm border border-strong focus:outline-none"} ${
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${buttonClassName}`}
onClick={handleMenuButtonClick}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{label}
{!noChevron && <ChevronDownOutline className="h-3.5 w-3.5" />}
</button>
</Menu.Button>
)}
</>
)}
{isOpen && menuItems}
</>
)}
</Menu>
);
}
// SubMenu context for closing submenu from nested items
const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null);
// Hook to use submenu context
const useSubMenu = () => React.useContext(SubMenuContext);
// SubMenu implementation
function SubMenu(props: ICustomSubMenuProps) {
const {
children,
trigger,
disabled = false,
className = "",
contentClassName = "",
placement = "right-start",
} = props;
const [isOpen, setIsOpen] = React.useState(false);
const [referenceElement, setReferenceElement] = React.useState<HTMLSpanElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const submenuRef = React.useRef<HTMLDivElement | null>(null);
const menuContext = React.useContext(MenuContext);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement,
strategy: "fixed", // Use fixed positioning to escape overflow constraints
modifiers: [
{
name: "offset",
options: {
offset: [0, 4],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 8,
},
},
],
});
const closeSubmenu = React.useCallback(() => {
setIsOpen(false);
}, []);
// Register this submenu with the main menu context
React.useEffect(() => {
if (menuContext) {
return menuContext.registerSubmenu(closeSubmenu);
}
}, [menuContext, closeSubmenu]);
const toggleSubmenu = () => {
if (!disabled) {
// Close other submenus when opening this one
if (!isOpen && menuContext) {
menuContext.closeAllSubmenus();
}
setIsOpen(!isOpen);
}
};
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
toggleSubmenu();
};
const subMenuContextValue = React.useMemo(() => ({ closeSubmenu }), [closeSubmenu]);
// Close submenu when clicking on other menu items
React.useEffect(() => {
const handleMenuItemClick = (e: Event) => {
const target = e.target as HTMLElement;
// Check if the click is on a menu item that's not part of this submenu
if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) {
closeSubmenu();
}
};
document.addEventListener("click", handleMenuItemClick);
return () => {
document.removeEventListener("click", handleMenuItemClick);
};
}, [closeSubmenu]);
return (
<div ref={submenuRef} className={cn("relative", className)}>
<span ref={setReferenceElement} className="w-full">
<Menu.Item as="div" disabled={disabled}>
{({ active }) => (
<button
type="button"
className={cn(
"font-inherit flex w-full cursor-pointer items-center justify-between rounded-sm border-0 bg-transparent px-1 py-1.5 text-left text-secondary outline-none select-none",
{
"bg-layer-transparent-hover": active && !disabled,
"text-placeholder": disabled,
"cursor-not-allowed": disabled,
}
)}
onClick={handleClick}
disabled={disabled}
>
<span className="flex-1">{trigger}</span>
<ChevronRightOutline className="h-3.5 w-3.5 flex-shrink-0" />
</button>
)}
</Menu.Item>
</span>
{isOpen && (
<Portal>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className={cn(
"shadow-md fixed z-30 min-w-[12rem] overflow-hidden rounded-md border border-strong-1 bg-surface-1 p-1 text-11 ring-1 ring-strong-1/15",
contentClassName
)}
data-prevent-outside-click="true"
onMouseEnter={() => {
// Notify parent menu that we're hovering over submenu
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
if (mainMenuElement) {
const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true });
mainMenuElement.dispatchEvent(mouseEnterEvent);
}
}}
onMouseLeave={() => {
// Notify parent menu that we're leaving submenu
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
if (mainMenuElement) {
const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true });
mainMenuElement.dispatchEvent(mouseLeaveEvent);
}
}}
>
<SubMenuContext.Provider value={subMenuContextValue}>{children}</SubMenuContext.Provider>
</div>
</Portal>
)}
</div>
);
}
function MenuItem(props: ICustomMenuItemProps) {
const { children, disabled = false, onClick, className } = props;
const submenuContext = useSubMenu();
return (
<Menu.Item as="div" disabled={disabled}>
{({ active, close }) => (
<button
type="button"
className={cn(
"w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none",
{
"bg-layer-transparent-hover": active && !disabled,
"text-placeholder": disabled,
},
className
)}
onClick={(e) => {
close();
onClick?.(e);
// Close submenu if this item is inside a submenu
submenuContext?.closeSubmenu();
}}
disabled={disabled}
>
{children}
</button>
)}
</Menu.Item>
);
}
function SubMenuTrigger(props: ICustomSubMenuTriggerProps) {
const { children, disabled = false, className } = props;
return (
<Menu.Item as="div" disabled={disabled}>
{({ active }) => (
<div
className={cn(
"flex w-full items-center justify-between rounded-sm px-1 py-1.5 text-left text-secondary select-none",
{
"bg-layer-transparent-hover": active && !disabled,
"text-placeholder": disabled,
"cursor-pointer": !disabled,
"cursor-not-allowed": disabled,
},
className
)}
>
<span className="flex-1">{children}</span>
<ChevronRightOutline className="h-3.5 w-3.5 flex-shrink-0" />
</div>
)}
</Menu.Item>
);
}
function SubMenuContent(props: ICustomSubMenuContentProps) {
const { children, className } = props;
return (
<div
className={cn(
"shadow-md z-[15] min-w-[12rem] overflow-hidden rounded-md border border-strong-1 bg-surface-1 p-1 text-11 ring-1 ring-strong-1/15",
className
)}
>
{children}
</div>
);
}
// Add all components as static properties for external use
CustomMenu.Portal = Portal;
CustomMenu.MenuItem = MenuItem;
CustomMenu.SubMenu = SubMenu;
CustomMenu.SubMenuTrigger = SubMenuTrigger;
CustomMenu.SubMenuContent = SubMenuContent;
export { CustomMenu };

View File

@@ -0,0 +1,232 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import { ChevronDownOutline, InfoOutline, SearchOutline, TickOutline } from "@makeplane/propel/icons";
import React, { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
// plane imports
// local imports
import { Tooltip } from "@plane/propel/tooltip";
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
import type { ICustomSearchSelectProps } from "./helper";
export function CustomSearchSelect(props: ICustomSearchSelectProps) {
const {
customButtonClassName = "",
buttonClassName = "",
className = "",
chevronClassName = "",
customButton,
placement,
disabled = false,
footerOption,
input = false,
label,
maxHeight = "md",
multiple = false,
noChevron = false,
onChange,
options,
onOpen,
onClose,
optionsClassName = "",
value,
tabIndex,
noResultsMessage = "No matches found",
defaultOpen = false,
} = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const comboboxProps: any = {
value,
onChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
const openDropdown = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
if (onOpen) onOpen();
};
const closeDropdown = () => {
setIsOpen(false);
onClose && onClose();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = () => {
if (isOpen) closeDropdown();
else openDropdown();
};
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("relative flex-shrink-0 text-left", className)}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
{({ open }: { open: boolean }) => {
if (open && onOpen) onOpen();
return (
<>
{customButton ? (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 text-11",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
customButtonClassName
)}
onClick={toggleDropdown}
>
{customButton}
</button>
</Combobox.Button>
) : (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong",
{
"px-3 py-2 text-13": input,
"px-2 py-1 text-11": !input,
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && (
<ChevronDownOutline className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
)}
</button>
</Combobox.Button>
)}
{isOpen &&
createPortal(
<Combobox.Options as="ul" data-prevent-outside-click static>
<div
className={cn(
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 py-2.5 text-11 whitespace-nowrap focus:outline-none",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="mx-2 flex items-center gap-1.5 rounded-sm border border-subtle px-2">
<SearchOutline className="h-3.5 w-3.5 text-placeholder" />
<Combobox.Input
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div
className={cn("vertical-scrollbar mt-2 scrollbar-xs space-y-1 overflow-y-scroll px-2", {
"max-h-96": maxHeight === "2xl",
"max-h-80": maxHeight === "xl",
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
})}
>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
as="li"
key={option.value}
value={option.value}
className={({ active }) =>
cn(
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
{
"bg-layer-transparent-hover": active,
"cursor-not-allowed text-placeholder opacity-60": option.disabled,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
disabled={option.disabled}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <TickOutline className="h-3.5 w-3.5 flex-shrink-0" />}
{option.tooltip && (
<>
{typeof option.tooltip === "string" ? (
<Tooltip tooltipContent={option.tooltip}>
<InfoOutline className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-secondary" />
</Tooltip>
) : (
option.tooltip
)}
</>
)}
</>
)}
</Combobox.Option>
))
) : (
<p className="px-1.5 py-1 text-placeholder italic">{noResultsMessage}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">Loading...</p>
)}
</div>
{footerOption}
</div>
</Combobox.Options>,
document.body
)}
</>
);
}}
</Combobox>
);
}

View File

@@ -0,0 +1,191 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Combobox } from "@headlessui/react";
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronDownOutline, TickOutline } from "@makeplane/propel/icons";
// plane helpers
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
import { cn } from "../utils";
// types
import type { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
// Context to share the close handler with option components
const DropdownContext = createContext<() => void>(() => {});
function CustomSelect(props: ICustomSelectProps) {
const {
customButtonClassName = "",
buttonClassName = "",
placement,
children,
className = "",
customButton,
disabled = false,
input = false,
label,
maxHeight = "md",
noChevron = false,
onChange,
optionsClassName = "",
value,
tabIndex,
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const openDropdown = useCallback(() => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
}, [referenceElement]);
const closeDropdown = useCallback(() => setIsOpen(false), []);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = useCallback(() => {
if (isOpen) closeDropdown();
else openDropdown();
}, [closeDropdown, isOpen, openDropdown]);
return (
<DropdownContext.Provider value={closeDropdown}>
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value}
onChange={(val) => {
onChange?.(val);
closeDropdown();
}}
className={cn("relative flex-shrink-0 text-left", className)}
onKeyDown={handleKeyDown}
disabled={disabled}
>
<>
{customButton ? (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded text-11 ${
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
</button>
</Combobox.Button>
) : (
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 rounded border border-strong",
{
"px-3 py-2 text-13": input,
"px-2 py-1 text-11": !input,
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDownOutline className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
)}
</>
{isOpen &&
createPortal(
<Combobox.Options as="ul" data-prevent-outside-click>
<div
className={cn(
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div
className={cn("space-y-1 overflow-y-scroll", {
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
})}
>
{children}
</div>
</div>
</Combobox.Options>,
document.body
)}
</Combobox>
</DropdownContext.Provider>
);
}
function Option(props: ICustomSelectItemProps) {
const { children, value, className } = props;
const closeDropdown = useContext(DropdownContext);
const handleClick = useCallback(() => {
// Close dropdown for both new and already-selected options.
// Use setTimeout to ensure HeadlessUI's onChange handler fires first for new selections.
// For already-selected options, this ensures the dropdown closes since onChange won't fire.
setTimeout(() => {
closeDropdown();
}, 0);
}, [closeDropdown]);
return (
<Combobox.Option
as="li"
value={value}
className={({ active }) =>
cn(
"flex cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none",
{
"bg-layer-transparent-hover": active,
},
className
)
}
onClick={handleClick}
>
{({ selected }) => (
<div className="flex w-full items-center justify-between gap-2">
{children}
{selected && <TickOutline className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
)}
</Combobox.Option>
);
}
CustomSelect.Option = Option;
export { CustomSelect };

View File

@@ -0,0 +1,127 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// FIXME: fix this!!!
import type { ICustomSearchSelectOption } from "@plane/types";
type Placement =
| "top"
| "top-start"
| "top-end"
| "bottom"
| "bottom-start"
| "bottom-end"
| "left"
| "left-start"
| "left-end"
| "right"
| "right-start"
| "right-end";
export interface IDropdownProps {
customButtonClassName?: string;
customButtonTabIndex?: number;
buttonClassName?: string;
className?: string;
customButton?: React.ReactNode;
disabled?: boolean;
input?: boolean;
label?: string | React.ReactNode;
maxHeight?: "sm" | "rg" | "md" | "lg" | "xl" | "2xl";
noChevron?: boolean;
chevronClassName?: string;
onOpen?: () => void;
optionsClassName?: string;
placement?: Placement;
tabIndex?: number;
useCaptureForOutsideClick?: boolean;
defaultOpen?: boolean;
}
export interface IPortalProps {
children: React.ReactNode;
container?: Element | null;
asChild?: boolean;
}
export interface ICustomMenuDropdownProps extends IDropdownProps {
children: React.ReactNode;
ellipsis?: boolean;
noBorder?: boolean;
verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void;
menuItemsClassName?: string;
onMenuClose?: () => void;
closeOnSelect?: boolean;
portalElement?: Element | null;
openOnHover?: boolean;
ariaLabel?: string;
}
export interface ICustomSelectProps extends IDropdownProps {
children: React.ReactNode;
value: any;
onChange: any;
}
interface CustomSearchSelectProps {
footerOption?: React.ReactNode;
onChange: any;
onClose?: () => void;
noResultsMessage?: string;
options?: ICustomSearchSelectOption[];
}
interface SingleValueProps {
multiple?: false;
value: any;
}
interface MultipleValuesProps {
multiple?: true;
value: any[] | null;
}
export type ICustomSearchSelectProps = IDropdownProps &
CustomSearchSelectProps &
(SingleValueProps | MultipleValuesProps);
export interface ICustomMenuItemProps {
children: React.ReactNode;
disabled?: boolean;
onClick?: (args?: any) => void;
className?: string;
}
export interface ICustomSelectItemProps {
children: React.ReactNode;
value: any;
className?: string;
}
// Submenu interfaces
export interface ICustomSubMenuProps {
children: React.ReactNode;
trigger: React.ReactNode;
disabled?: boolean;
className?: string;
contentClassName?: string;
placement?: Placement;
}
export interface ICustomSubMenuTriggerProps {
children: React.ReactNode;
disabled?: boolean;
className?: string;
}
export interface ICustomSubMenuContentProps {
children: React.ReactNode;
className?: string;
placement?: Placement;
sideOffset?: number;
alignOffset?: number;
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./context-menu";
export * from "./custom-menu";
export * from "./custom-select";
export * from "./custom-search-select";
export * from "./combo-box";

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { StarFilled, StarOutline } from "@makeplane/propel/icons";
import React from "react";
// helpers
import { cn } from "./utils";
type Props = {
buttonClassName?: string;
iconClassName?: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
selected: boolean;
};
export function FavoriteStar(props: Props) {
const { buttonClassName, iconClassName, onClick, selected } = props;
// Propel splits the star into two glyphs; the filled one matches the old solid Lucide star.
const Icon = selected ? StarFilled : StarOutline;
return (
<button type="button" className={cn("grid h-4 w-4 place-items-center", buttonClassName)} onClick={onClick}>
<Icon
className={cn(
"h-4 w-4 text-tertiary transition-all",
{
"text-(--color-label-yellow-icon)": selected,
},
iconClassName
)}
/>
</button>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./password";

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Popover, Transition } from "@headlessui/react";
import * as React from "react";
import * as ColorPicker from "react-color";
import type { ColorResult } from "react-color";
import { usePopper } from "react-popper";
// helpers
import { Button } from "../button/button";
import { cn } from "../utils";
// components
import { Input } from "./input";
export interface InputColorPickerProps {
hasError: boolean;
value: string | undefined;
onChange: (value: string) => void;
name: string;
className?: string;
style?: React.CSSProperties;
placeholder: string;
}
export function InputColorPicker(props: InputColorPickerProps) {
const { value, hasError, onChange, name, className, style, placeholder } = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "auto",
});
const handleColorChange = (newColor: ColorResult) => {
const { hex } = newColor;
onChange(hex);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className="relative">
<Input
id={name}
name={name}
type="text"
value={value}
onChange={handleInputChange}
hasError={hasError}
placeholder={placeholder}
className={cn("border-[0.5px] border-subtle", className)}
style={style}
/>
<Popover as="div" className="absolute top-1/2 right-1 z-10 -translate-y-1/2">
{() => (
<>
<Popover.Button as={React.Fragment}>
<Button ref={setReferenceElement} variant="neutral-primary" className="border-none !bg-transparent">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-palette"
>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</svg>
</Button>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
<div
className="z-10 overflow-hidden rounded-sm border border-subtle bg-surface-1 shadow-raised-200"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ColorPicker.SketchPicker color={value} onChange={handleColorChange} />
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
// helpers
import { cn } from "../utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
mode?: "primary" | "transparent" | "true-transparent";
inputSize?: "xs" | "sm" | "md";
hasError?: boolean;
className?: string;
}
const Input = React.forwardRef(function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
const {
id,
type,
name,
mode = "primary",
inputSize = "sm",
hasError = false,
className = "",
autoComplete = "off",
...rest
} = props;
return (
<input
id={id}
ref={ref}
type={type}
name={name}
className={cn(
"placeholder-tertiary block rounded-md border-subtle-1 bg-layer-2 text-13 focus:outline-none",
{
"rounded-md border-[0.5px]": mode === "primary",
"rounded-sm border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-accent-strong":
mode === "transparent",
"rounded-sm border-none bg-transparent ring-0": mode === "true-transparent",
"border-danger-strong": hasError,
"px-1.5 py-1": inputSize === "xs",
"px-3 py-2": inputSize === "sm",
"p-3": inputSize === "md",
},
className
)}
autoComplete={autoComplete}
{...rest}
/>
);
});
Input.displayName = "form-input-field";
export { Input };

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { E_PASSWORD_STRENGTH } from "@plane/constants";
export interface StrengthInfo {
message: string;
textColor: string;
activeFragments: number;
}
/**
* Get strength information including message, color, and active fragments
*/
export const getStrengthInfo = (strength: E_PASSWORD_STRENGTH): StrengthInfo => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY:
return {
message: "Please enter your password",
textColor: "text-primary",
activeFragments: 0,
};
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID:
return {
message: "Password is too short",
textColor: "text-danger-primary",
activeFragments: 1,
};
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID:
return {
message: "Password is weak",
textColor: "text-orange-500",
activeFragments: 2,
};
case E_PASSWORD_STRENGTH.STRENGTH_VALID:
return {
message: "Password is strong",
textColor: "text-success-primary",
activeFragments: 3,
};
default:
return {
message: "Please enter your password",
textColor: "text-primary",
activeFragments: 0,
};
}
};
/**
* Get fragment color based on position and active state
*/
export const getFragmentColor = (fragmentIndex: number, activeFragments: number): string => {
if (fragmentIndex >= activeFragments) {
return "bg-layer-1";
}
switch (activeFragments) {
case 1:
return "bg-danger-primary";
case 2:
return "bg-orange-500";
case 3:
return "bg-success-primary";
default:
return "bg-layer-1";
}
};

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./indicator";
export * from "./password-input";

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { TickCircleOutline } from "@makeplane/propel/icons";
import React from "react";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength, getPasswordCriteria } from "@plane/utils";
import { getStrengthInfo, getFragmentColor } from "./helper";
export interface PasswordStrengthIndicatorProps {
password: string;
showCriteria?: boolean;
isFocused?: boolean;
}
export function PasswordStrengthIndicator({
password,
showCriteria = true,
isFocused = false,
}: PasswordStrengthIndicatorProps) {
const strength = getPasswordStrength(password);
const criteria = getPasswordCriteria(password);
const strengthInfo = getStrengthInfo(strength);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if ((!password && !showCriteria) || !isPasswordMeterVisible) {
return null;
}
return (
<div className={cn("space-y-3")}>
{/* Strength Indicator */}
<div className="space-y-2">
<div className="flex w-full gap-1 transition-all duration-300 ease-linear">
{[0, 1, 2].map((fragmentIndex) => (
<div
key={fragmentIndex}
className={cn(
"h-1 flex-1 rounded-xs transition-all duration-300 ease-in-out",
getFragmentColor(fragmentIndex, strengthInfo.activeFragments)
)}
/>
))}
</div>
{/* Strength Message */}
{password && <p className={cn("!text-13 font-medium", strengthInfo.textColor)}>{strengthInfo.message}</p>}
</div>
{/* Criteria list */}
{showCriteria && (
<div className="flex flex-wrap gap-2">
{criteria.map((criterion) => (
<div key={criterion.key} className="flex items-center gap-1.5">
<div className="flex items-center justify-center p-0.5">
<TickCircleOutline
className={cn("h-3 w-3 flex-shrink-0", {
"text-success-primary": criterion.isValid,
"text-primary": !criterion.isValid,
})}
/>
</div>
<span
className={cn("!text-11", {
"text-success-primary": criterion.isValid,
"text-primary": !criterion.isValid,
})}
>
{criterion.label}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { HideOutline, ShowOutline } from "@makeplane/propel/icons";
import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
type TPasswordInputProps = {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
showToggle?: boolean;
error?: boolean;
autoComplete?: React.HTMLInputAutoCompleteAttribute;
};
export function PasswordInput({
id,
value,
onChange,
placeholder = "Enter your password",
className,
showToggle = true,
error = false,
autoComplete = "off",
}: TPasswordInputProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<input
id={id}
type={showPassword ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full rounded-md border bg-surface-1 px-3 py-2 pr-10 text-secondary transition-all duration-200 placeholder:text-placeholder focus:border-transparent focus:ring-2 focus:ring-accent-strong focus:outline-none",
{
"border-strong": !error,
"border-danger-strong": error,
},
className
)}
placeholder={placeholder}
autoComplete={autoComplete}
/>
{showToggle && (
<Tooltip tooltipContent={showPassword ? "Hide password" : "Show password"} position="top">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary transition-colors duration-200 hover:text-primary"
>
<div className="relative h-4 w-4">
<ShowOutline
className={cn(
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
showPassword ? "scale-75 rotate-12 opacity-0" : "scale-100 rotate-0 opacity-100"
)}
/>
<HideOutline
className={cn(
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
showPassword ? "scale-100 rotate-0 opacity-100" : "scale-75 -rotate-12 opacity-0"
)}
/>
</div>
</button>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef } from "react";
// helpers
import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea";
import { cn } from "../utils";
// hooks
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
mode?: "primary" | "transparent" | "true-transparent";
textAreaSize?: "xs" | "sm" | "md";
hasError?: boolean;
className?: string;
}
const TextArea = React.forwardRef(function TextArea(
props: TextAreaProps,
ref: React.ForwardedRef<HTMLTextAreaElement>
) {
const {
id,
name,
value = "",
mode = "primary",
textAreaSize = "sm",
hasError = false,
className = "",
...rest
} = props;
// refs
const textAreaRef = useRef<any>(ref);
// auto re-size
useAutoResizeTextArea(textAreaRef, value);
return (
<textarea
id={id}
name={name}
ref={textAreaRef}
value={value}
className={cn(
"no-scrollbar w-full bg-layer-2 placeholder-(--text-color-placeholder) outline-none",
{
"rounded-md border-[0.5px] border-subtle-1": mode === "primary",
"focus:ring-theme rounded-sm border-none bg-transparent ring-0 transition-all focus:ring-1":
mode === "transparent",
"rounded-sm border-none bg-transparent ring-0": mode === "true-transparent",
"px-1.5 py-1": textAreaSize === "xs",
"px-3 py-2": textAreaSize === "sm",
"p-3": textAreaSize === "md",
"border-danger-strong": hasError,
"bg-danger-subtle": hasError && mode === "primary",
},
className
)}
{...rest}
/>
);
});
TextArea.displayName = "TextArea";
export { TextArea };

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { ERowVariant, Row } from "../row";
import { cn } from "../utils";
import type { THeaderVariant } from "./helper";
import { EHeaderVariant, getHeaderStyle } from "./helper";
export interface HeaderProps {
variant?: THeaderVariant;
setHeight?: boolean;
className?: string;
children: React.ReactNode;
showOnMobile?: boolean;
}
const HeaderContext = React.createContext<THeaderVariant | null>(null);
function Header(props: HeaderProps) {
const {
variant = EHeaderVariant.PRIMARY,
className = "",
showOnMobile = true,
setHeight = true,
children,
...rest
} = props;
const style = getHeaderStyle(variant, setHeight, showOnMobile);
return (
<HeaderContext.Provider value={variant}>
<Row
variant={variant === EHeaderVariant.PRIMARY ? ERowVariant.HUGGING : ERowVariant.REGULAR}
className={cn(style, className)}
{...rest}
>
{children}
</Row>
</HeaderContext.Provider>
);
}
function LeftItem(props: HeaderProps) {
return (
<div
className={cn(
"flex max-w-[80%] flex-grow flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap",
props.className
)}
>
{props.children}
</div>
);
}
function RightItem(props: HeaderProps) {
const variant = React.useContext(HeaderContext);
if (variant === undefined) throw new Error("RightItem must be used within Header");
return (
<div
className={cn(
"flex w-auto items-center justify-end gap-2",
{
"items-baseline": variant === EHeaderVariant.TERNARY,
},
props.className
)}
>
{props.children}
</div>
);
}
Header.LeftItem = LeftItem;
Header.RightItem = RightItem;
Header.displayName = "plane-ui-header";
export { Header, EHeaderVariant };

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum EHeaderVariant {
PRIMARY = "primary",
SECONDARY = "secondary",
TERNARY = "ternary",
}
export type THeaderVariant = EHeaderVariant.PRIMARY | EHeaderVariant.SECONDARY | EHeaderVariant.TERNARY;
export interface IHeaderProperties {
[key: string]: string;
}
export const headerStyle: IHeaderProperties = {
[EHeaderVariant.PRIMARY]:
"relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-surface-1 bg-surface-1 z-[18]",
[EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-subtle justify-between bg-surface-1 z-[15]",
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-subtle gap-2 bg-surface-1 z-[12]",
};
export const minHeights: IHeaderProperties = {
[EHeaderVariant.PRIMARY]: "",
[EHeaderVariant.SECONDARY]: "min-h-[52px]",
[EHeaderVariant.TERNARY]: "",
};
export const getHeaderStyle = (variant: THeaderVariant, setMinHeight: boolean, showOnMobile: boolean) => {
const height = setMinHeight ? minHeights[variant] : "";
const display = variant === EHeaderVariant.SECONDARY ? (showOnMobile ? "flex" : "hidden md:flex") : "";
return " @container " + headerStyle[variant] + " " + height + " " + display;
};

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./header";

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useLayoutEffect } from "react";
export const useAutoResizeTextArea = (
textAreaRef: React.RefObject<HTMLTextAreaElement | null>,
value: string | number | readonly string[]
) => {
useLayoutEffect(() => {
const textArea = textAreaRef.current;
if (!textArea) return;
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textArea.style.height = "0px";
const scrollHeight = textArea.scrollHeight;
textArea.style.height = scrollHeight + "px";
}, [textAreaRef, value]);
};

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(
onOpen: () => void,
onClose: () => void,
isOpen: boolean,
selectActiveItem?: () => void
): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
if (!isOpen) {
event.stopPropagation();
onOpen();
} else {
selectActiveItem && selectActiveItem();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
onClose();
}
},
[isOpen, onOpen, onClose]
);
return handleKeyDown;
};

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
type TUseDropdownKeyPressed = {
(
onEnterKeyDown: () => void,
onEscKeyDown: () => void,
stopPropagation?: boolean
): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
const stopEventPropagation = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
},
[stopPropagation]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
stopEventPropagation(event);
onEnterKeyDown();
} else if (event.key === "Escape") {
stopEventPropagation(event);
onEscKeyDown();
} else if (event.key === "Tab") onEscKeyDown();
},
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
);
return handleKeyDown;
};

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export const usePlatformOS = () => {
const userAgent = window.navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
return { isMobile };
};

30
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./card";
export * from "./collapsible";
export * from "./content-wrapper";
export * from "./control-link";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./dropdown";
export * from "./dropdowns";
export * from "./favorite-star";
export * from "./form-fields";
export * from "./header";
export * from "./link";
export * from "./loader";
export * from "./modals";
export * from "./popovers";
export * from "./row";
export * from "./sortable";
export * from "./spinners";
export * from "./tables";
export * from "./tag";
export * from "./utils";
export * from "./oauth";

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// plane utils
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
// plane ui
import type { TContextMenuItem } from "../dropdowns/context-menu/root";
import { CustomMenu } from "../dropdowns/custom-menu";
export type TLinkItemBlockProps = {
title: string;
url: string;
createdAt?: Date | string;
menuItems?: TContextMenuItem[];
onClick?: () => void;
};
export function LinkItemBlock(props: TLinkItemBlockProps) {
// props
const { title, url, createdAt, menuItems, onClick } = props;
// icons
const Icon = getIconForLink(url);
return (
<div
onClick={onClick}
className="group flex h-[56px] w-[230px] cursor-pointer items-center gap-4 rounded-md border-[0.5px] border-subtle bg-surface-1 px-4"
>
<div className="grid size-8 flex-shrink-0 place-items-center rounded-sm bg-surface-2 p-2">
<Icon className="size-4 stroke-2 text-tertiary group-hover:text-primary" />
</div>
<div className="flex-1 truncate">
<div className="truncate text-13 font-medium">{title}</div>
{createdAt && <div className="text-11 font-medium text-placeholder">{calculateTimeAgo(createdAt)}</div>}
</div>
{menuItems && (
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{menuItems.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex w-full items-center gap-2", {
"text-placeholder": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./block";

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// helpers
import { cn } from "./utils";
type Props = {
children: React.ReactNode;
className?: string;
};
function Loader({ children, className = "" }: Props) {
return (
<div className={cn("animate-pulse", className)} role="status">
{children}
</div>
);
}
type ItemProps = {
height?: string;
width?: string;
className?: string;
};
function Item({ height = "auto", width = "auto", className = "" }: ItemProps) {
return <div className={cn("rounded-md bg-layer-1", className)} style={{ height: height, width: width }} />;
}
Loader.Item = Item;
Loader.displayName = "plane-ui-loader";
export { Loader };

View File

@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { InfoOutline, WarningTriangleOutline } from "@makeplane/propel/icons";
import React from "react";
// components
import type { TButtonVariant } from "@plane/propel/button";
import { Button } from "@plane/propel/button";
import { cn } from "../utils";
import { EModalPosition, EModalWidth } from "./constants";
import { ModalCore } from "./modal-core";
// constants
// helpers
export type TModalVariant = "danger" | "primary";
type Props = {
content: React.ReactNode | string;
handleClose: () => void;
handleSubmit: () => void;
hideIcon?: boolean;
isSubmitting: boolean;
isOpen: boolean;
position?: EModalPosition;
primaryButtonText?: {
loading: string;
default: string;
};
secondaryButtonText?: string;
title: string;
variant?: TModalVariant;
width?: EModalWidth;
customIcon?: React.ReactNode;
};
const VARIANT_ICONS: Record<TModalVariant, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
danger: WarningTriangleOutline,
primary: InfoOutline,
};
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
danger: "error-fill",
primary: "primary",
};
const VARIANT_CLASSES: Record<TModalVariant, string> = {
danger: "bg-danger-subtle text-danger-primary",
primary: "bg-accent-primary/20 text-accent-primary",
};
export function AlertModalCore(props: Props) {
const {
content,
handleClose,
handleSubmit,
hideIcon = false,
isSubmitting,
isOpen,
position = EModalPosition.CENTER,
primaryButtonText = {
loading: "Deleting",
default: "Delete",
},
secondaryButtonText = "Cancel",
title,
variant = "danger",
width = EModalWidth.XL,
customIcon,
} = props;
const Icon = VARIANT_ICONS[variant];
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={position} width={width}>
<div className="flex flex-col items-center gap-4 p-5 sm:flex-row sm:items-start">
{!hideIcon && (
<span
className={cn(
"grid size-12 flex-shrink-0 place-items-center rounded-full sm:size-10",
VARIANT_CLASSES[variant]
)}
>
{customIcon ? <>{customIcon}</> : <Icon className="size-5" aria-hidden="true" />}
</span>
)}
<div className="text-center sm:text-left">
<h3 className="text-16 font-medium">{title}</h3>
<p className="mt-1 text-13 text-secondary">{content}</p>
</div>
</div>
<div className="flex flex-col-reverse gap-2 border-t-[0.5px] border-subtle px-5 py-4 sm:flex-row sm:justify-end">
<Button variant="secondary" onClick={handleClose}>
{secondaryButtonText}
</Button>
<Button variant={BUTTON_VARIANTS[variant]} tabIndex={1} onClick={handleSubmit} loading={isSubmitting}>
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
</Button>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum EModalPosition {
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
}
export enum EModalWidth {
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",
}

View File

@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./alert-modal";
export * from "./constants";
export * from "./modal-core";

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Dialog, Transition } from "@headlessui/react";
import React, { Fragment } from "react";
// constants
import { cn } from "../utils";
import { EModalPosition, EModalWidth } from "./constants";
// helpers
type Props = {
children: React.ReactNode;
handleClose?: () => void;
isOpen: boolean;
position?: EModalPosition;
width?: EModalWidth;
className?: string;
};
export function ModalCore(props: Props) {
const {
children,
handleClose,
isOpen,
position = EModalPosition.CENTER,
width = EModalWidth.XXL,
className = "",
} = props;
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => handleClose && handleClose()}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className={position}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={cn(
"relative w-full transform rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all",
width,
className
)}
>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./oauth-options";

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
export interface OAuthButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
icon: React.ReactNode;
compact?: boolean;
}
const OAuthButton = React.forwardRef(function OAuthButton(
props: OAuthButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>
) {
const { text, icon, compact = false, className = "", ...rest } = props;
return (
<button
ref={ref}
className={cn(
"bg-onboarding-background-200 hover:bg-onboarding-background-300 flex h-9 w-full items-center justify-center gap-2 rounded-md border border-strong px-4 py-2.5 text-13 font-medium text-primary duration-300",
className
)}
{...rest}
>
<div className="flex flex-shrink-0 items-center justify-center">{icon}</div>
{!compact && (
<span className="flex flex-grow items-center justify-center text-body-sm-regular transition-opacity duration-300">
{text}
</span>
)}
</button>
);
});
OAuthButton.displayName = "plane-ui-oauth-button";
export { OAuthButton };

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
import { OAuthButton } from "./oauth-button";
export type TOAuthOption = {
id: string;
text: string;
icon: React.ReactNode;
onClick: () => void;
enabled?: boolean;
};
type OAuthOptionsProps = {
options: TOAuthOption[];
compact?: boolean;
showDivider?: boolean;
className?: string;
containerClassName?: string;
};
export function OAuthOptions(props: OAuthOptionsProps) {
const { options, compact = false, showDivider = true, className = "", containerClassName = "" } = props;
// Filter enabled options
const enabledOptions = options.filter((option) => option.enabled !== false);
if (enabledOptions.length === 0) return null;
return (
<div className={cn("w-full", containerClassName)}>
<div
className={cn(
"flex gap-4 overflow-hidden transition-all duration-500 ease-in-out",
compact ? "flex-row" : "flex-col",
className
)}
>
{enabledOptions.map((option) => (
<OAuthButton
key={option.id}
text={option.text}
icon={option.icon}
onClick={option.onClick}
compact={compact}
className="transition-all duration-300 ease-in-out"
/>
))}
</div>
{showDivider && (
<div className="mt-4 flex items-center transition-all duration-300">
<hr className="w-full border-strong transition-colors duration-300" />
<p className="mx-3 flex-shrink-0 text-center text-13 text-placeholder transition-colors duration-300">or</p>
<hr className="w-full border-strong transition-colors duration-300" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./popover";
export * from "./popover-menu";

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { PopoverMenu } from "./popover-menu";
type TPopoverMenu = {
id: number;
name: string;
};
const meta: Meta<typeof PopoverMenu<TPopoverMenu>> = {
title: "Components/PopoverMenu",
component: PopoverMenu,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
popperPosition: "bottom-start",
panelClassName: "rounded-sm bg-gray-100 p-2",
data: [
{ id: 1, name: "John Doe" },
{ id: 2, name: "Jane Doe" },
{ id: 3, name: "John Smith" },
{ id: 4, name: "Jane Smith" },
],
keyExtractor: (item, index: number) => `${item.id}-${index}`,
render: (item: TPopoverMenu) => (
<div className="text-gray-600 hover:text-gray-700 hover:bg-gray-200 cursor-pointer rounded-xs px-1.5 py-0.5 text-13 capitalize transition-all">
{item.name}
</div>
),
},
};
export default meta;
type Story = StoryObj<typeof PopoverMenu<TPopoverMenu>>;
export const Default: Story = {};

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { Fragment } from "react";
// components
import { cn } from "../utils";
import { Popover } from "./popover";
// helpers
// types
import type { TPopoverMenu } from "./types";
export function PopoverMenu<T>(props: TPopoverMenu<T>) {
const {
popperPosition = "bottom-end",
popperPadding = 0,
buttonClassName = "",
button,
disabled,
panelClassName = "",
data,
popoverClassName = "",
keyExtractor,
render,
popoverButtonRef,
} = props;
return (
<Popover
popperPosition={popperPosition}
popperPadding={popperPadding}
buttonClassName={buttonClassName}
button={button}
disabled={disabled}
panelClassName={cn(
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
panelClassName
)}
popoverClassName={popoverClassName}
popoverButtonRef={popoverButtonRef}
>
<Fragment>
{data.map((item, index) => (
<Fragment key={keyExtractor(item, index)}>{render(item, index)}</Fragment>
))}
</Fragment>
</Popover>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Popover } from "./popover";
const meta: Meta<typeof Popover> = {
title: "Popover",
component: Popover,
};
export default meta;
// types
type Story = StoryObj<typeof Popover>;
// data
// components
const RenderCustomPopoverComponent = (
<div className="space-y-2">
<div className="text-gray-500 text-13 font-medium">Your custom component</div>
<div>
{["option1", "option2", "option3"].map((option) => (
<div
key={option}
className="text-gray-600 hover:text-gray-700 hover:bg-gray-200 cursor-pointer rounded-xs px-1.5 py-0.5 text-13 capitalize transition-all"
>
{option}
</div>
))}
</div>
</div>
);
// stories
export const Default: Story = {
args: {
popperPosition: "bottom-start",
panelClassName: "rounded-sm bg-gray-100 p-2",
children: RenderCustomPopoverComponent,
},
};
export const CustomMenuButton: Story = {
args: {
popperPosition: "bottom-start",
button: (
<div className="bg-gray-100 hover:bg-gray-200 rounded-sm p-2 text-13 font-medium transition-all">
Custom Menu Button
</div>
),
panelClassName: "rounded-sm bg-gray-100 p-2",
children: RenderCustomPopoverComponent,
},
};

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Popover as HeadlessReactPopover, Transition } from "@headlessui/react";
import { MoreVerticalOutline } from "@makeplane/propel/icons";
import type { Ref } from "react";
import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
// helpers
import { cn } from "../utils";
// types
import type { TPopover } from "./types";
export function Popover(props: TPopover) {
const {
popperPosition = "bottom-end",
popperPadding = 0,
buttonClassName = "",
popoverClassName = "",
button,
disabled = false,
panelClassName = "",
children,
popoverButtonRef,
buttonRefClassName = "",
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
// Headless UI v2 types Panel's ref as Ref<HTMLElement> rather than the concrete tag.
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
// react-popper derived values
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: popperPosition,
modifiers: [
{
name: "preventOverflow",
options: {
padding: popperPadding,
},
},
],
});
return (
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
<div ref={setReferenceElement} className={cn("w-full", buttonRefClassName)}>
<HeadlessReactPopover.Button
ref={popoverButtonRef as Ref<HTMLButtonElement>}
className={cn(
{
"flex h-6 w-6 items-center justify-center rounded-sm bg-surface-2 text-14 transition-all hover:bg-layer-1":
!button,
},
buttonClassName
)}
disabled={disabled}
>
{button ? button : <MoreVerticalOutline className="h-3 w-3" />}
</HeadlessReactPopover.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<HeadlessReactPopover.Panel
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className={cn("absolute top-full left-0 z-20 mt-2 w-screen max-w-xs", panelClassName)}
>
{children}
</HeadlessReactPopover.Panel>
</Transition>
</HeadlessReactPopover>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Placement } from "@popperjs/core";
import type { MutableRefObject, ReactNode } from "react";
export type TPopoverButtonDefaultOptions = {
// button and button styling
button?: ReactNode;
buttonClassName?: string;
buttonRefClassName?: string;
disabled?: boolean;
};
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
// popper styling
popperPosition?: Placement | undefined;
popperPadding?: number | undefined;
// panel styling
panelClassName?: string;
popoverClassName?: string;
popoverButtonRef?: MutableRefObject<HTMLButtonElement | null>;
};
export type TPopover = TPopoverDefaultOptions & {
// children
children: ReactNode;
};
export type TPopoverMenu<T> = TPopoverDefaultOptions & {
data: T[];
keyExtractor: (item: T, index: number) => string;
render: (item: T, index: number) => ReactNode;
};

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum ERowVariant {
REGULAR = "regular",
HUGGING = "hugging",
}
export type TRowVariant = ERowVariant.REGULAR | ERowVariant.HUGGING;
export interface IRowProperties {
[key: string]: string;
}
export const rowStyle: IRowProperties = {
[ERowVariant.REGULAR]: "px-page-x",
[ERowVariant.HUGGING]: "px-0",
};

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./row";

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
import type { TRowVariant } from "./helper";
import { ERowVariant, rowStyle } from "./helper";
export interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: TRowVariant;
className?: string;
children: React.ReactNode;
}
const Row = React.forwardRef(function Row(props: RowProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { variant = ERowVariant.REGULAR, className = "", children, ...rest } = props;
const style = rowStyle[variant];
return (
<div ref={ref} className={cn(style, className)} {...rest}>
{children}
</div>
);
});
Row.displayName = "plane-ui-row";
export { Row, ERowVariant };

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// @ts-expect-error Due to live server dependencies
import { combine } from "@atlaskit/pragmatic-drag-and-drop/dist/cjs/entry-point/combine.js";
import {
draggable,
dropTargetForElements,
// @ts-expect-error Due to live server dependencies
} from "@atlaskit/pragmatic-drag-and-drop/dist/cjs/entry-point/element/adapter.js";
import {
attachClosestEdge,
extractClosestEdge,
// @ts-expect-error Due to live server dependencies
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/dist/cjs/closest-edge.js";
import { isEqual } from "lodash-es";
import React, { useEffect, useRef, useState } from "react";
import { DropIndicator } from "../drop-indicator";
import { cn } from "../utils";
type Props = {
children: React.ReactNode;
data: any; //@todo make this generic
className?: string;
};
function Draggable({ children, data, className }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<boolean>(false); // NEW
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
useEffect(() => {
const el = ref.current;
if (el) {
combine(
draggable({
element: el,
onDragStart: () => setDragging(true), // NEW
onDrop: () => setDragging(false), // NEW
getInitialData: () => data,
}),
dropTargetForElements({
element: el,
// @ts-expect-error Due to live server dependencies
onDragEnter: (args) => {
setIsDraggedOver(true);
setClosestEdge(extractClosestEdge(args.self.data));
},
onDragLeave: () => setIsDraggedOver(false),
onDrop: () => {
setIsDraggedOver(false);
},
// @ts-expect-error Due to live server dependencies
canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__,
// @ts-expect-error Due to live server dependencies
getData: ({ input, element }) =>
attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
}),
})
);
}
}, [data]);
return (
<div ref={ref} className={cn(dragging && "opacity-25", className)}>
{<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />}
{children}
{<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />}
</div>
);
}
export { Draggable };

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./sortable";

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Sortable } from "./sortable";
type StoryItem = { id: string; name: string };
const meta: Meta<typeof Sortable<StoryItem>> = {
title: "Sortable",
component: Sortable,
args: {
data: [
{ id: "1", name: "John Doe" },
{ id: "2", name: "Satish" },
{ id: "3", name: "Alice" },
{ id: "4", name: "Bob" },
{ id: "5", name: "Charlie" },
],
render: (item: StoryItem) => (
// <Draggable data={item} className="rounded-lg">
<div className="border">{item.name}</div>
// </Draggable>
),
onChange: (data) => console.log(data.map(({ id }) => id)),
keyExtractor: (item) => item.id,
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// @ts-expect-error Due to live server dependencies
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/dist/cjs/entry-point/element/adapter.js";
import React, { Fragment, useEffect, useMemo } from "react";
import { Draggable } from "./draggable";
type TEnhancedData<T> = T & { __uuid__?: string };
type Props<T> = {
data: TEnhancedData<T>[];
render: (item: T, index: number) => React.ReactNode;
onChange: (data: T[], movedItem?: T) => void;
keyExtractor: (item: T, index: number) => string;
containerClassName?: string;
id?: string;
};
const moveItem = <T,>(
data: TEnhancedData<T>[],
source: TEnhancedData<T>,
destination: TEnhancedData<T> & Record<symbol, string>,
keyExtractor: (item: T, index: number) => string
): {
newData: T[];
movedItem: T | undefined;
} => {
const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
if (sourceIndex === -1) return { newData: data, movedItem: undefined };
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
if (destinationIndex === -1) return { newData: data, movedItem: undefined };
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
// Calculate final position before removing source item
const finalIndex = position === "bottom" ? destinationIndex + 1 : destinationIndex;
// Adjust for the fact that we're removing the source item first
// If source is before destination, removing it shifts everything back by 1
const adjustedDestinationIndex = finalIndex > sourceIndex ? finalIndex - 1 : finalIndex;
const newData = [...data];
const [movedItem] = newData.splice(sourceIndex, 1);
// Insert at the calculated position (bounds check is implicit in splice)
newData.splice(adjustedDestinationIndex, 0, movedItem);
const { __uuid__: movedItemId, ...movedItemData } = movedItem;
return {
newData: newData.map((item) => {
const { __uuid__: uuid, ...rest } = item;
return rest as T;
}),
movedItem: movedItemData as T,
};
};
export function Sortable<T>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) {
useEffect(() => {
const unsubscribe = monitorForElements({
// @ts-expect-error Due to live server dependencies
onDrop({ source, location }) {
const destination = location?.current?.dropTargets[0];
if (!destination) return;
const { newData, movedItem } = moveItem(
data,
source.data as TEnhancedData<T>,
destination.data as TEnhancedData<T> & { closestEdge: string },
keyExtractor
);
onChange(newData, movedItem);
},
});
// Clean up the subscription on unmount
return () => {
if (unsubscribe) unsubscribe();
};
}, [data, keyExtractor, onChange]);
const enhancedData = useMemo(() => {
const uuid = id ? id : Math.random().toString(36).substring(7);
return data.map((item) => ({ ...item, __uuid__: uuid }));
}, [data, id]);
return (
<>
{data.map((item, index) => (
<Draggable
key={keyExtractor(enhancedData[index], index)}
data={enhancedData[index]}
className={containerClassName}
>
<Fragment>{render(item, index)}</Fragment>
</Draggable>
))}
</>
);
}
export default Sortable;

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
// helpers
import { cn } from "../utils";
export interface ISpinner extends React.SVGAttributes<SVGElement> {
height?: string;
width?: string;
className?: string | undefined;
}
export function Spinner({ height = "32px", width = "32px", className = "" }: ISpinner) {
return (
<div role="status">
<svg
aria-hidden="true"
height={height}
width={width}
className={cn("animate-spin fill-accent-primary text-secondary", className)}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./circular-spinner";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./table";

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Table } from "./table";
const meta: Meta<typeof Table> = {
title: "Table",
component: Table,
};
export default meta;
// types
type TTableData = {
id: string;
name: string;
age: number;
};
type Story = StoryObj<typeof Table<TTableData>>;
// data
const tableData: TTableData[] = [
{ id: "1", name: "Ernest", age: 25 },
{ id: "2", name: "Ann", age: 30 },
{ id: "3", name: "Russell", age: 35 },
{ id: "4", name: "Verna", age: 40 },
];
const tableColumns = [
{
key: "id",
content: "Id",
tdRender: (rowData: TTableData) => <span>{rowData.id}</span>,
},
{
key: "name",
content: "Name",
tdRender: (rowData: TTableData) => <span>{rowData.name}</span>,
},
{
key: "age",
content: "Age",
tdRender: (rowData: TTableData) => <span>{rowData.age}</span>,
},
];
// stories
export const Default: Story = {
args: {
data: tableData,
columns: tableColumns,
keyExtractor: (rowData) => rowData.id,
tableClassName: "bg-gray-100",
tHeadClassName: "bg-gray-200",
tHeadTrClassName: "text-gray-600 text-left text-13 font-medium",
thClassName: "font-medium",
tBodyClassName: "bg-gray-100",
tBodyTrClassName: "text-gray-600",
tdClassName: "font-medium",
},
};

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// helpers
import { cn } from "../utils";
// types
import type { TTableData } from "./types";
export function Table<T>(props: TTableData<T>) {
const {
data,
columns,
keyExtractor,
tableClassName = "",
tHeadClassName = "",
tHeadTrClassName = "",
thClassName = "",
tBodyClassName = "",
tBodyTrClassName = "",
tdClassName = "",
} = props;
return (
<table className={cn("w-full table-auto overflow-hidden whitespace-nowrap", tableClassName)}>
<thead className={cn("divide-y divide-subtle", tHeadClassName)}>
<tr className={cn("divide-x divide-subtle text-13 text-primary", tHeadTrClassName)}>
{columns.map((column) => (
<th key={column.key} className={cn("px-2.5 py-2", thClassName)}>
{(column?.thRender && column?.thRender()) || column.content}
</th>
))}
</tr>
</thead>
<tbody className={cn("divide-y divide-subtle", tBodyClassName)}>
{data.map((item) => (
<tr
key={keyExtractor(item)}
className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)}
>
{columns.map((column) => (
<td key={`${column.key}-${keyExtractor(item)}`} className={cn("px-2.5 py-2", tdClassName)}>
{column.tdRender(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type TTableColumn<T> = {
key: string;
content: string;
thRender?: () => React.ReactNode;
tdRender: (rowData: T) => React.ReactNode;
};
export type TTableData<T> = {
data: T[];
columns: TTableColumn<T>[];
keyExtractor: (rowData: T) => string;
// classNames
tableClassName?: string;
tHeadClassName?: string;
tHeadTrClassName?: string;
thClassName?: string;
tBodyClassName?: string;
tBodyTrClassName?: string;
tdClassName?: string;
};

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum ETagVariant {
OUTLINED = "outlined",
}
export enum ETagSize {
SM = "sm",
LG = "lg",
}
export type TTagVariant = ETagVariant.OUTLINED;
export type TTagSize = ETagSize.SM | ETagSize.LG;
export interface ITagProperties {
[key: string]: string;
}
export const containerStyle: ITagProperties = {
[ETagVariant.OUTLINED]:
"flex items-center rounded-md border border-subtle text-11 text-tertiary hover:text-secondary min-h-[36px] my-auto capitalize flex-wrap cursor-pointer gap-1.5",
};
export const sizes = {
[ETagSize.SM]: "p-1.5",
[ETagSize.LG]: "p-6",
};
export const getTagStyle = (variant: TTagVariant, size: TTagSize) => containerStyle[variant] + " " + sizes[size];

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./tag";

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { cn } from "../utils";
import type { TTagSize, TTagVariant } from "./helper";
import { ETagSize, ETagVariant, getTagStyle } from "./helper";
export interface TagProps extends React.ComponentProps<"div"> {
variant?: TTagVariant;
size?: TTagSize;
className?: string;
children: React.ReactNode;
}
const Tag = React.forwardRef(function Tag(props: TagProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { variant = ETagVariant.OUTLINED, className = "", size = ETagSize.SM, children, ...rest } = props;
const style = getTagStyle(variant, size);
return (
<div ref={ref} className={cn(style, className)} {...rest}>
{children}
</div>
);
});
Tag.displayName = "plane-ui-container";
export { Tag, ETagVariant, ETagSize };

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export { cn } from "@plane/utils";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./classname";

View File

@@ -0,0 +1 @@
@import "@plane/tailwind-config/index.css";

11
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "@plane/typescript-config/react-library.json",
"include": ["src"],
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": false
},
"exclude": ["dist", "node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More