Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
build/*
dist/*
out/*

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
};

View File

@@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dist/
build/

5
packages/ui/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -0,0 +1,28 @@
import type { StorybookConfig } from "@storybook/react-webpack5";
import { join, dirname } from "path";
/**
* 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-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
"@storybook/addon-styling-webpack",
],
framework: {
name: getAbsolutePath("@storybook/react-webpack5"),
options: {},
},
};
export default config;

View File

@@ -0,0 +1,14 @@
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

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

@@ -0,0 +1,85 @@
{
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "1.1.0",
"sideEffects": false,
"license": "AGPL-3.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"postcss": "postcss styles/globals.css -o styles/output.css --watch",
"check:lint": "eslint . --max-warnings 94",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"peerDependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "catalog:",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "catalog:",
"@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.3",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/propel": "workspace:*",
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-scroll-area": "^1.2.3",
"clsx": "^2.0.0",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"react-color": "^2.19.3",
"react-day-picker": "9.5.0",
"react-popper": "^2.3.0",
"tailwind-merge": "^2.0.0",
"use-font-face-observer": "^1.2.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@storybook/addon-essentials": "^8.1.1",
"@storybook/addon-interactions": "^8.1.1",
"@storybook/addon-links": "^8.1.1",
"@storybook/addon-onboarding": "^8.1.1",
"@storybook/addon-styling-webpack": "^1.0.0",
"@storybook/addon-webpack5-compiler-swc": "^1.0.2",
"@storybook/blocks": "^8.1.1",
"@storybook/react": "^8.1.1",
"@storybook/react-webpack5": "^8.1.1",
"@storybook/test": "^8.1.1",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-color": "^3.0.9",
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.19",
"postcss-cli": "^11.0.0",
"postcss-nested": "^6.0.1",
"storybook": "^8.1.1",
"tsdown": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");

View File

@@ -0,0 +1,77 @@
import React, { useState } from "react";
import { cn } from "@plane/utils";
import { AuthInput } from "./auth-input";
export interface AuthConfirmPasswordInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "autoComplete"> {
password: string;
label?: string;
error?: string;
showPasswordToggle?: boolean;
containerClassName?: string;
labelClassName?: string;
errorClassName?: string;
autoComplete?: "on" | "off";
onPasswordMatchChange?: (matches: boolean) => void;
}
export const AuthConfirmPasswordInput: React.FC<AuthConfirmPasswordInputProps> = ({
password,
label = "Confirm Password",
error,
showPasswordToggle = true,
containerClassName = "",
errorClassName = "",
className = "",
value = "",
onChange,
onPasswordMatchChange,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const confirmPassword = value as string;
const passwordsMatch = password === confirmPassword && password.length > 0;
const showMatchError =
confirmPassword.length > 0 && !passwordsMatch && (!isFocused || confirmPassword.length >= password.length);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newConfirmPassword = e.target.value;
onChange?.(e);
onPasswordMatchChange?.(password === newConfirmPassword && password.length > 0);
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
const getError = () => {
if (error) return error;
if (showMatchError) return "Passwords don't match";
return "";
};
return (
<div className={cn("space-y-2", containerClassName)}>
<AuthInput
{...props}
type="password"
label={label}
error={getError()}
showPasswordToggle={showPasswordToggle}
errorClassName={errorClassName}
className={className}
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoComplete="on"
/>
{confirmPassword && passwordsMatch && <p className="text-sm text-green-500">Passwords match</p>}
</div>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { cn } from "../utils";
export interface AuthForgotPasswordProps {
onForgotPassword?: () => void;
className?: string;
text?: string;
disabled?: boolean;
}
export const AuthForgotPassword: React.FC<AuthForgotPasswordProps> = ({
onForgotPassword,
className = "",
text = "Forgot your password?",
disabled = false,
}) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (!disabled && onForgotPassword) {
onForgotPassword();
}
};
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={cn(
"text-sm text-custom-primary-100 hover:text-custom-primary-200 transition-colors duration-200",
{
"opacity-50 cursor-not-allowed": disabled,
"cursor-pointer": !disabled,
},
className
)}
>
{text}
</button>
);
};

View File

@@ -0,0 +1,207 @@
import React, { useState, useMemo } from "react";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { Button } from "../button/button";
import { Spinner } from "../spinners/circular-spinner";
import { cn } from "../utils";
import { AuthConfirmPasswordInput } from "./auth-confirm-password-input";
import { AuthForgotPassword } from "./auth-forgot-password";
import { AuthInput } from "./auth-input";
import { AuthPasswordInput } from "./auth-password-input";
export type AuthMode = "sign-in" | "sign-up";
export interface AuthFormData {
email: string;
password: string;
confirmPassword?: string;
}
export interface AuthFormProps {
mode: AuthMode;
initialData?: Partial<AuthFormData>;
onSubmit?: (data: AuthFormData) => void;
onForgotPassword?: () => void;
onModeChange?: (mode: AuthMode) => void;
loading?: boolean;
disabled?: boolean;
className?: string;
showForgotPassword?: boolean;
showPasswordStrength?: boolean;
emailError?: string;
passwordError?: string;
confirmPasswordError?: string;
submitButtonText?: string;
alternateModeText?: string;
alternateModeButtonText?: string;
}
export const AuthForm: React.FC<AuthFormProps> = ({
mode,
initialData = {},
onSubmit,
onForgotPassword,
onModeChange,
loading = false,
disabled = false,
className = "",
showForgotPassword = true,
showPasswordStrength = true,
emailError,
passwordError,
confirmPasswordError,
submitButtonText,
alternateModeText,
alternateModeButtonText,
}) => {
const [formData, setFormData] = useState<AuthFormData>({
email: initialData.email || "",
password: initialData.password || "",
confirmPassword: initialData.confirmPassword || "",
});
const [passwordStrength, setPasswordStrength] = useState<E_PASSWORD_STRENGTH>(E_PASSWORD_STRENGTH.EMPTY);
const [_passwordsMatch, setPasswordsMatch] = useState(false);
const handleInputChange = (field: keyof AuthFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[field]: e.target.value,
}));
};
const handlePasswordChange = (password: string) => {
setFormData((prev) => ({
...prev,
password,
}));
};
const handlePasswordStrengthChange = (strength: E_PASSWORD_STRENGTH) => {
setPasswordStrength(strength);
};
const handleConfirmPasswordChange = (matches: boolean) => {
setPasswordsMatch(matches);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onSubmit && isFormValid) {
onSubmit(formData);
}
};
const handleModeChange = () => {
const newMode = mode === "sign-in" ? "sign-up" : "sign-in";
onModeChange?.(newMode);
};
const isFormValid = useMemo(() => {
const hasEmail = formData.email.length > 0;
const hasPassword = formData.password.length > 0;
if (mode === "sign-in") {
return hasEmail && hasPassword && !loading && !disabled;
} else {
const isPasswordStrong = passwordStrength === E_PASSWORD_STRENGTH.STRENGTH_VALID;
const passwordsMatch = formData.password === formData.confirmPassword && formData.password.length > 0;
return hasEmail && hasPassword && isPasswordStrong && passwordsMatch && !loading && !disabled;
}
}, [mode, formData, passwordStrength, loading, disabled]);
const getSubmitButtonText = () => {
if (submitButtonText) return submitButtonText;
return mode === "sign-in" ? "Sign In" : "Create Account";
};
const getAlternateModeText = () => {
if (alternateModeText) return alternateModeText;
return mode === "sign-in" ? "Don't have an account?" : "Already have an account?";
};
const getAlternateModeButtonText = () => {
if (alternateModeButtonText) return alternateModeButtonText;
return mode === "sign-in" ? "Sign Up" : "Sign In";
};
return (
<form onSubmit={handleSubmit} className={cn("space-y-4", className)}>
{/* Email Input */}
<AuthInput
id="email"
name="email"
type="email"
label="Email"
value={formData.email}
onChange={handleInputChange("email")}
placeholder="name@company.com"
error={emailError}
disabled={disabled}
// autoComplete="email"
required
/>
{/* Password Input */}
<AuthPasswordInput
id="password"
name="password"
label={mode === "sign-in" ? "Password" : "Set a password"}
value={formData.password}
onChange={handleInputChange("password")}
onPasswordChange={handlePasswordChange}
onPasswordStrengthChange={handlePasswordStrengthChange}
placeholder="Enter password"
error={passwordError}
showPasswordStrength={showPasswordStrength && mode === "sign-up"}
disabled={disabled}
// autoComplete={mode === "sign-in" ? "current-password" : "new-password"}
required
/>
{/* Confirm Password Input (Sign Up Only) */}
{mode === "sign-up" && (
<AuthConfirmPasswordInput
id="confirmPassword"
name="confirmPassword"
password={formData.password}
value={formData.confirmPassword}
onChange={handleInputChange("confirmPassword")}
onPasswordMatchChange={handleConfirmPasswordChange}
error={confirmPasswordError}
disabled={disabled}
// autoComplete="new-password"
required
/>
)}
{/* Forgot Password Link (Sign In Only) */}
{mode === "sign-in" && showForgotPassword && (
<div className="flex justify-end">
<AuthForgotPassword onForgotPassword={onForgotPassword} disabled={disabled} />
</div>
)}
{/* Submit Button */}
<div className="space-y-2.5">
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={!isFormValid} loading={loading}>
{loading ? <Spinner height="20px" width="20px" /> : getSubmitButtonText()}
</Button>
{/* Alternate Mode Button */}
{onModeChange && (
<div className="text-center">
<span className="text-sm text-custom-text-300">{getAlternateModeText()}</span>
<button
type="button"
onClick={handleModeChange}
className="ml-1 text-sm text-custom-primary-100 hover:text-custom-primary-200 transition-colors duration-200"
disabled={disabled}
>
{getAlternateModeButtonText()}
</button>
</div>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,66 @@
import { Eye, EyeOff } from "lucide-react";
import React, { useState } from "react";
import { Input } from "../form-fields/input";
import { cn } from "../utils";
export interface AuthInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "autoComplete"> {
label?: string;
error?: string;
showPasswordToggle?: boolean;
errorClassName?: string;
autoComplete?: "on" | "off";
}
const baseContainerClassName = "flex flex-col gap-1.5";
export const AuthInput: React.FC<AuthInputProps> = ({
label,
error,
showPasswordToggle = false,
errorClassName = "",
className = "",
type = "text",
...props
}) => {
const { id } = props;
const [showPassword, setShowPassword] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPasswordToggle && showPassword ? "text" : type;
return (
<div className={cn(baseContainerClassName)}>
{label && (
<label htmlFor={id} className={cn("text-sm font-semibold text-custom-text-300")}>
{label}
</label>
)}
<div
className={cn("relative flex items-center rounded-md border border-custom-border-300 py-2 px-3 transition-all")}
>
<Input
{...props}
type={inputType}
className={cn(
"rounded-md disable-autofill-style h-6 w-full placeholder:text-base placeholder:text-custom-text-400 p-0 border-none",
{
"border-red-500": error,
},
className
)}
/>
{showPasswordToggle && isPasswordType && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
)}
</div>
{error && <p className={cn("text-sm text-red-500", errorClassName)}>{error}</p>}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React, { useState } from "react";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
import { PasswordStrengthIndicator } from "../form-fields/password/indicator";
import { AuthInput } from "./auth-input";
export interface AuthPasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "autoComplete"> {
label?: string;
error?: string;
showPasswordStrength?: boolean;
showPasswordToggle?: boolean;
containerClassName?: string;
errorClassName?: string;
autoComplete?: "on" | "off";
onPasswordChange?: (password: string) => void;
onPasswordStrengthChange?: (strength: E_PASSWORD_STRENGTH) => void;
}
export const AuthPasswordInput: React.FC<AuthPasswordInputProps> = ({
label = "Password",
error,
showPasswordStrength = true,
showPasswordToggle = true,
containerClassName = "",
errorClassName = "",
className = "",
value = "",
onChange,
onPasswordChange,
onPasswordStrengthChange,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value;
onChange?.(e);
onPasswordChange?.(newPassword);
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
const passwordStrength = getPasswordStrength(value as string);
// Notify parent of strength change
React.useEffect(() => {
onPasswordStrengthChange?.(passwordStrength);
}, [passwordStrength, onPasswordStrengthChange]);
return (
<div className={cn("space-y-2", containerClassName)}>
<AuthInput
{...props}
type="password"
label={label}
error={error}
showPasswordToggle={showPasswordToggle}
errorClassName={errorClassName}
className={className}
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoComplete="on"
/>
{showPasswordStrength && value && isFocused && (
<PasswordStrengthIndicator password={value as string} showCriteria />
)}
</div>
);
};

View File

@@ -0,0 +1,11 @@
export { AuthForm } from "./auth-form";
export { AuthInput } from "./auth-input";
export { AuthPasswordInput } from "./auth-password-input";
export { AuthConfirmPasswordInput } from "./auth-confirm-password-input";
export { AuthForgotPassword } from "./auth-forgot-password";
export type { AuthFormProps, AuthFormData, AuthMode } from "./auth-form";
export type { AuthInputProps } from "./auth-input";
export type { AuthPasswordInputProps } from "./auth-password-input";
export type { AuthConfirmPasswordInputProps } from "./auth-confirm-password-input";
export type { AuthForgotPasswordProps } from "./auth-forgot-password";

View File

@@ -0,0 +1,90 @@
import React from "react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { cn } from "../utils";
// types
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
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 const AvatarGroup: React.FC<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 ring-1 ring-custom-background-100">
{avatar}
</div>
))}
{maxAvatarsToRender < totalAvatars && (
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
<div
className={cn(
"grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100",
{
[sizeInfo.avatarSize]: !isAValidNumber(size),
}
)}
style={
isAValidNumber(size)
? {
width: `${size}px`,
height: `${size}px`,
}
: {}
}
>
+{totalAvatars - max}
</div>
</Tooltip>
)}
</div>
);
};

View File

@@ -0,0 +1,18 @@
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,169 @@
import React from "react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { cn } from "../utils";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
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;
};
/**
* Get the size details based on the size prop
* @param size The size of the avatar
* @returns The size details
*/
export const getSizeInfo = (size: TAvatarSize) => {
switch (size) {
case "sm":
return {
avatarSize: "h-4 w-4",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "md":
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "base":
return {
avatarSize: "h-6 w-6",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
case "lg":
return {
avatarSize: "h-7 w-7",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
default:
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
}
};
/**
* Get the border radius based on the shape prop
* @param shape The shape of the avatar
* @returns The border radius
*/
export const getBorderRadius = (shape: "circle" | "square") => {
switch (shape) {
case "circle":
return "rounded-full";
case "square":
return "rounded";
default:
return "rounded-full";
}
};
/**
* Check if the value is a valid number
* @param value The value to check
* @returns Whether the value is a valid number or not
*/
export const isAValidNumber = (value: any) => typeof value === "number" && !isNaN(value);
export const Avatar: React.FC<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,2 @@
export * from "./avatar-group";
export * from "./avatar";

View File

@@ -0,0 +1,45 @@
import * as React from "react";
// helpers
import { cn } from "../utils";
import { getIconStyling, getBadgeStyling, TBadgeVariant, TBadgeSizes } from "./helper";
export interface BadgeProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TBadgeVariant;
size?: TBadgeSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getBadgeStyling(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>
);
});
Badge.displayName = "plane-ui-badge";
export { Badge };

View File

@@ -0,0 +1,142 @@
export type TBadgeVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral"
| "accent-neutral"
| "outline-neutral"
| "success"
| "accent-success"
| "outline-success"
| "warning"
| "accent-warning"
| "outline-warning"
| "destructive"
| "accent-destructive"
| "outline-destructive";
export type TBadgeSizes = "sm" | "md" | "lg" | "xl";
export interface IBadgeStyling {
[key: string]: {
default: string;
hover: string;
disabled: string;
};
}
// TODO: convert them to objects instead of enums
enum badgeSizeStyling {
sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
lg = `px-4 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
}
// TODO: convert them to objects instead of enums
enum badgeIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
}
export const badgeStyling: IBadgeStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-10 text-custom-primary-100`,
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
neutral: {
default: `text-custom-background-100 bg-custom-text-100 border border-custom-border-200`,
hover: `hover:bg-custom-text-200`,
disabled: `cursor-not-allowed bg-custom-border-200 !text-custom-text-400`,
},
"accent-neutral": {
default: `text-custom-text-200 bg-custom-background-80`,
hover: `hover:bg-custom-border-200 hover:text-custom-text-100`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"outline-neutral": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:text-custom-text-100 hover:bg-custom-border-200`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
success: {
default: `text-white bg-green-500`,
hover: `hover:bg-green-600`,
disabled: `cursor-not-allowed !bg-green-300`,
},
"accent-success": {
default: `text-green-500 bg-green-50`,
hover: `hover:bg-green-100 hover:text-green-600`,
disabled: `cursor-not-allowed !text-green-300`,
},
"outline-success": {
default: `text-green-500 bg-custom-background-100 border border-green-500`,
hover: `hover:text-green-600 hover:bg-green-50`,
disabled: `cursor-not-allowed !text-green-300 border-green-300`,
},
warning: {
default: `text-white bg-amber-500`,
hover: `hover:bg-amber-600`,
disabled: `cursor-not-allowed !bg-amber-300`,
},
"accent-warning": {
default: `text-amber-500 bg-amber-50`,
hover: `hover:bg-amber-100 hover:text-amber-600`,
disabled: `cursor-not-allowed !text-amber-300`,
},
"outline-warning": {
default: `text-amber-500 bg-custom-background-100 border border-amber-500`,
hover: `hover:text-amber-600 hover:bg-amber-50`,
disabled: `cursor-not-allowed !text-amber-300 border-amber-300`,
},
destructive: {
default: `text-white bg-red-500`,
hover: `hover:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-destructive": {
default: `text-red-500 bg-red-50`,
hover: `hover:bg-red-100 hover:text-red-600`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-destructive": {
default: `text-red-500 bg-custom-background-100 border border-red-500`,
hover: `hover:text-red-600 hover:bg-red-50`,
disabled: `cursor-not-allowed !text-red-300 border-red-300`,
},
};
export const getBadgeStyling = (variant: TBadgeVariant, size: TBadgeSizes, disabled: boolean = false): string => {
let tempVariant: string = ``;
const currentVariant = badgeStyling[variant];
tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover}`;
let tempSize: string = ``;
if (size) tempSize = badgeSizeStyling[size];
return `${tempVariant} ${tempSize}`;
};
export const getIconStyling = (size: TBadgeSizes): string => {
let icon: string = ``;
if (size) icon = badgeIconStyling[size];
return icon;
};

View File

@@ -0,0 +1 @@
export * from "./badge";

View File

@@ -0,0 +1 @@
export * from "./subscription";

View File

@@ -0,0 +1,209 @@
// plane imports
import { EProductSubscriptionEnum } from "@plane/types";
import { cn } from "@plane/utils";
export const getSubscriptionTextColor = (
planVariant: EProductSubscriptionEnum,
shade: "200" | "400" = "200"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"200": "text-custom-subscription-one-200",
"400": "text-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"200": "text-custom-subscription-pro-200",
"400": "text-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"200": "text-custom-subscription-business-200",
"400": "text-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"200": "text-custom-subscription-enterprise-200",
"400": "text-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"200": "text-custom-subscription-free-200",
"400": "text-custom-subscription-free-400",
},
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
};
export const getSubscriptionBackgroundColor = (
planVariant: EProductSubscriptionEnum,
shade: "50" | "100" | "200" | "400" = "100"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"50": "bg-custom-subscription-one-200/10",
"100": "bg-custom-subscription-one-200/20",
"200": "bg-custom-subscription-one-200",
"400": "bg-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"50": "bg-custom-subscription-pro-200/10",
"100": "bg-custom-subscription-pro-200/20",
"200": "bg-custom-subscription-pro-200",
"400": "bg-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"50": "bg-custom-subscription-business-200/10",
"100": "bg-custom-subscription-business-200/20",
"200": "bg-custom-subscription-business-200",
"400": "bg-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"50": "bg-custom-subscription-enterprise-200/10",
"100": "bg-custom-subscription-enterprise-200/20",
"200": "bg-custom-subscription-enterprise-200",
"400": "bg-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"50": "bg-custom-subscription-free-200/10",
"100": "bg-custom-subscription-free-200/20",
"200": "bg-custom-subscription-free-200",
"400": "bg-custom-subscription-free-400",
},
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
};
export const getSubscriptionBorderColor = (
planVariant: EProductSubscriptionEnum,
shade: "200" | "400" = "200"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"200": "border-custom-subscription-one-200",
"400": "border-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"200": "border-custom-subscription-pro-200",
"400": "border-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"200": "border-custom-subscription-business-200",
"400": "border-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"200": "border-custom-subscription-enterprise-200",
"400": "border-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"200": "border-custom-subscription-free-200",
"400": "border-custom-subscription-free-400",
},
default: "border-custom-subscription-free-400",
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors.default;
};
export const getUpgradeButtonStyle = (
planVariant: EProductSubscriptionEnum,
isDisabled: boolean
): string | undefined => {
const baseClassNames = "border bg-custom-background-100";
const hoverClassNames = !isDisabled ? "hover:text-white hover:bg-gradient-to-br" : "";
const disabledClassNames = isDisabled ? "opacity-70 cursor-not-allowed" : "";
const COMMON_CLASSNAME = cn(baseClassNames, hoverClassNames, disabledClassNames);
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn(
"text-custom-subscription-enterprise-200 from-custom-subscription-enterprise-200 to-custom-subscription-enterprise-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.BUSINESS:
return cn(
"text-custom-subscription-business-200 from-custom-subscription-business-200 to-custom-subscription-business-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.PRO:
return cn(
"text-custom-subscription-pro-200 from-custom-subscription-pro-200 to-custom-subscription-pro-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.ONE:
return cn(
"text-custom-subscription-one-200 from-custom-subscription-one-200 to-custom-subscription-one-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.FREE:
default:
return cn(
"text-custom-subscription-free-200 from-custom-subscription-free-200 to-custom-subscription-free-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
}
};
export const getUpgradeCardVariantStyle = (planVariant: EProductSubscriptionEnum): string | undefined => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-40% border border-custom-border-200 rounded-xl");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getSuccessModalVariantStyle = (planVariant: EProductSubscriptionEnum) => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-30% rounded-2xl");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getBillingAndPlansCardVariantStyle = (planVariant: EProductSubscriptionEnum) => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-50%");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getSubscriptionTextAndBackgroundColor = (planVariant: EProductSubscriptionEnum) =>
cn(getSubscriptionTextColor(planVariant), getSubscriptionBackgroundColor(planVariant));
export const getDiscountPillStyle = (planVariant: EProductSubscriptionEnum): string =>
cn(getSubscriptionBackgroundColor(planVariant, "200"), "text-white");

View File

@@ -0,0 +1,223 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react";
import * as React from "react";
import { ContrastIcon, EpicIcon, LayersIcon } from "@plane/propel/icons";
import { Breadcrumbs } from "./breadcrumbs";
import { BreadcrumbNavigationDropdown } from "./navigation-dropdown";
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
const BreadcrumbBlock: React.FC<TBreadcrumbBlockProps> = (props) => {
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-[1rem]">{icon}</div>}
{label && <div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{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="size-4 rounded-full bg-blue-500" />
<span>Custom Component</span>
</div>
}
/>,
],
},
};
export const SingleItem: Story = {
args: {
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
},
};
export const WithNavigationDropdown: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item key="settings" component={<BreadcrumbBlock href="/settings" label="Settings" />} />,
],
},
};
export const WithNavigationDropdownAndIcons: Story = {
args: {
children: [
<Breadcrumbs.Item
key="home"
component={<BreadcrumbBlock href="/" label="Home" icon={<Home className="size-3.5" />} />}
/>,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
icon: Briefcase,
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
icon: Briefcase,
// disabled: true,
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
icon: Briefcase,
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="features"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="feature-1"
navigationItems={[
{
key: "feature-1",
title: "Epics",
icon: EpicIcon,
action: () => console.log("Feature Alpha selected"),
},
{
key: "feature-2",
title: "Work items",
icon: LayersIcon,
// disabled: true,
action: () => console.log("Feature Beta selected"),
},
{
key: "feature-3",
title: "Cycles",
icon: ContrastIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Modules",
icon: GridIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Views",
icon: Layers2,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Pages",
icon: FileIcon,
action: () => console.log("Feature Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="settings"
component={<BreadcrumbBlock href="/settings" label="Settings" icon={<Settings className="size-3.5" />} />}
isLast
/>,
],
},
};

View File

@@ -0,0 +1,190 @@
import * as React from "react";
import { ChevronRightIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "../utils";
type BreadcrumbsProps = {
className?: string;
children: React.ReactNode;
onBack?: () => void;
isLoading?: boolean;
};
export const BreadcrumbItemLoader = () => (
<div className="flex items-center gap-2 h-7 animate-pulse">
<div className="group h-full flex items-center gap-2 rounded px-2 py-1 text-sm font-medium">
<span className="h-full w-5 bg-custom-background-80 rounded" />
<span className="h-full w-16 bg-custom-background-80 rounded" />
</div>
</div>
);
const 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 items-center overflow-hidden gap-0.5 flex-grow", 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-custom-text-200">
...
</span>
)}
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" 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, {
isLast: true,
})
) : (
childrenArray[childrenArray.length - 1]
)}
</div>
</>
)}
{isSmallScreen && childrenArray.length === 1 && childrenArray}
</div>
);
};
// breadcrumb item
type BreadcrumbItemProps = {
component?: React.ReactNode;
showSeparator?: boolean;
isLast?: boolean;
};
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = (props) => {
const { component, showSeparator = true, isLast = false } = props;
return (
<div className="flex items-center gap-0.5 h-6">
{component}
{showSeparator && !isLast && <BreadcrumbSeparator />}
</div>
);
};
// breadcrumb icon
type BreadcrumbIconProps = {
children: React.ReactNode;
className?: string;
};
const BreadcrumbIcon: React.FC<BreadcrumbIconProps> = (props) => {
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;
};
const BreadcrumbLabel: React.FC<BreadcrumbLabelProps> = (props) => {
const { children, className } = props;
return (
<div className={cn("relative line-clamp-1 block max-w-[150px] overflow-hidden truncate", className)}>
{children}
</div>
);
};
// breadcrumb separator
type BreadcrumbSeparatorProps = {
className?: string;
containerClassName?: string;
iconClassName?: string;
showDivider?: boolean;
};
const BreadcrumbSeparator: React.FC<BreadcrumbSeparatorProps> = (props) => {
const { className, containerClassName, iconClassName, showDivider = false } = props;
return (
<div className={cn("relative flex items-center justify-center h-full px-1.5 py-1", className)}>
{showDivider && <span className="absolute -left-0.5 top-0 h-full w-[1.8px] bg-custom-background-100" />}
<div
className={cn(
"flex items-center justify-center flex-shrink-0 rounded text-custom-text-400 transition-all",
containerClassName
)}
>
<ChevronRightIcon 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;
};
const BreadcrumbItemWrapper: React.FC<BreadcrumbItemWrapperProps> = (props) => {
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 h-full flex items-center gap-2 rounded px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-default",
{
"hover:text-custom-text-100 hover:bg-custom-background-90 cursor-pointer": 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,3 @@
export * from "./breadcrumbs";
export * from "./navigation-dropdown";
export * from "./navigation-search-dropdown";

View File

@@ -0,0 +1,135 @@
"use client";
import { CheckIcon } from "lucide-react";
import * as React from "react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, TContextMenuItem } from "../dropdowns";
import { cn } from "../utils";
import { Breadcrumbs } from "./breadcrumbs";
type TBreadcrumbNavigationDropdownProps = {
selectedItemKey: string;
navigationItems: TContextMenuItem[];
navigationDisabled?: boolean;
handleOnClick?: () => void;
isLast?: boolean;
};
export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => {
const { selectedItemKey, navigationItems, navigationDisabled = false, handleOnClick, isLast = false } = props;
const [isOpen, setIsOpen] = React.useState(false);
// derived values
const selectedItem = navigationItems.find((item) => item.key === selectedItemKey);
const selectedItemIcon = selectedItem?.icon ? (
<selectedItem.icon className={cn("size-4", selectedItem.iconClassName)} />
) : undefined;
// if no selected item, return null
if (!selectedItem) return null;
const NavigationButton = () => (
<Tooltip tooltipContent={selectedItem.title} position="bottom" disabled={isOpen}>
<button
onClick={(e) => {
if (!isLast) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
className={cn(
"group h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
{
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
}
)}
>
<div className="flex @4xl:hidden text-custom-text-300">...</div>
<div className="hidden @4xl:flex gap-2">
{selectedItemIcon && <Breadcrumbs.Icon>{selectedItemIcon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{selectedItem.title}</Breadcrumbs.Label>
</div>
</button>
</Tooltip>
);
if (navigationDisabled) {
return <NavigationButton />;
}
return (
<CustomMenu
customButton={
<>
<NavigationButton />
<Breadcrumbs.Separator
className={cn("rounded-r", {
"bg-custom-background-80": isOpen && !isLast,
"hover:bg-custom-background-80": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
"text-custom-text-100": isOpen,
"rotate-90": isOpen || isLast,
})}
showDivider={!isLast}
/>
</>
}
placement="bottom-start"
className="h-full rounded"
customButtonClassName={cn(
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
{
"bg-custom-background-90": isOpen,
}
)}
closeOnSelect
menuButtonOnClick={() => {
setIsOpen(!isOpen);
}}
onMenuClose={() => {
setIsOpen(false);
}}
>
{navigationItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (item.key === selectedItemKey) return;
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-4 flex-shrink-0", item.iconClassName)} />}
<div className="w-full">
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
{item.key === selectedItemKey && <CheckIcon className="flex-shrink-0 size-3.5" />}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
};

View File

@@ -0,0 +1,105 @@
import * as React from "react";
import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import { 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 const BreadcrumbNavigationSearchDropdown: React.FC<TBreadcrumbNavigationSearchDropdownProps> = (props) => {
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 h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
{
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
}
)}
>
{shouldTruncate && <div className="flex @4xl:hidden text-custom-text-300">...</div>}
<div
className={cn("flex gap-2", {
"hidden @4xl:flex gap-2": shouldTruncate,
})}
>
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</div>
</button>
</Tooltip>
<Breadcrumbs.Separator
className={cn("rounded-r", {
"bg-custom-background-80": isDropdownOpen && !isLast,
"hover:bg-custom-background-80": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
"text-custom-text-100": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
})}
showDivider={!isLast}
/>
</>
}
disabled={navigationDisabled}
className="h-full rounded"
customButtonClassName={cn(
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
{
"bg-custom-background-90": isDropdownOpen,
}
)}
/>
);
};

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { cn } from "../utils";
import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TButtonVariant;
size?: TButtonSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getButtonStyling(variant, size, disabled || loading);
const buttonIconStyle = getIconStyling(size);
return (
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
{children}
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
</button>
);
});
Button.displayName = "plane-ui-button";
export { Button };

View File

@@ -0,0 +1,126 @@
export type TButtonVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral-primary"
| "link-primary"
| "danger"
| "accent-danger"
| "outline-danger"
| "link-danger"
| "tertiary-danger"
| "link-neutral";
export type TButtonSizes = "sm" | "md" | "lg" | "xl";
export interface IButtonStyling {
[key: string]: {
default: string;
hover: string;
pressed: string;
disabled: string;
};
}
enum buttonSizeStyling {
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
}
enum buttonIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0 ",
}
export const buttonStyling: IButtonStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
pressed: `focus:text-custom-brand-40 focus:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-100/20 text-custom-primary-100`,
hover: `hover:bg-custom-primary-100/10 hover:text-custom-primary-200`,
pressed: `focus:bg-custom-primary-100/10`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-transparent border border-custom-primary-100`,
hover: `hover:bg-custom-primary-100/20`,
pressed: `focus:text-custom-primary-100 focus:bg-custom-primary-100/30`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
"neutral-primary": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:bg-custom-background-90`,
pressed: `focus:text-custom-text-300 focus:bg-custom-background-90`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"link-primary": {
default: `text-custom-primary-100 bg-custom-background-100`,
hover: `hover:text-custom-primary-200`,
pressed: `focus:text-custom-primary-80 `,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
danger: {
default: `text-white bg-red-500`,
hover: ` hover:bg-red-600`,
pressed: `focus:text-red-200 focus:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-danger": {
default: `text-red-500 bg-red-50`,
hover: `hover:text-red-600 hover:bg-red-100`,
pressed: `focus:text-red-500 focus:bg-red-100`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-danger": {
default: `text-red-500 bg-transparent border border-red-500`,
hover: `hover:text-red-400 hover:border-red-400`,
pressed: `focus:text-red-400 focus:border-red-400`,
disabled: `cursor-not-allowed !text-red-300 !border-red-300`,
},
"link-danger": {
default: `text-red-500 bg-custom-background-100`,
hover: `hover:text-red-400`,
pressed: `focus:text-red-400`,
disabled: `cursor-not-allowed !text-red-300`,
},
"tertiary-danger": {
default: `text-red-500 bg-custom-background-100 border border-red-200`,
hover: `hover:bg-red-50 hover:border-red-300`,
pressed: `focus:text-red-400`,
disabled: `cursor-not-allowed !text-red-300`,
},
"link-neutral": {
default: `text-custom-text-300`,
hover: `hover:text-custom-text-200`,
pressed: `focus:text-custom-text-100`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
};
export const getButtonStyling = (variant: TButtonVariant, size: TButtonSizes, disabled: boolean = false): string => {
let tempVariant: string = ``;
const currentVariant = buttonStyling[variant];
tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover} ${
currentVariant.pressed
}`;
let tempSize: string = ``;
if (size) tempSize = buttonSizeStyling[size];
return `${tempVariant} ${tempSize}`;
};
export const getIconStyling = (size: TButtonSizes): string => {
let icon: string = ``;
if (size) icon = buttonIconStyling[size];
return icon;
};

View File

@@ -0,0 +1,3 @@
export * from "./button";
export * from "./helper";
export * from "./toggle-switch";

View File

@@ -0,0 +1,56 @@
import { Switch } from "@headlessui/react";
import * as React from "react";
// helpers
import { cn } from "../utils";
interface IToggleSwitchProps {
value: boolean;
onChange: (value: boolean) => void;
label?: string;
size?: "sm" | "md" | "lg";
disabled?: boolean;
className?: string;
}
const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
const { value, onChange, label, size = "sm", disabled, className } = props;
return (
<Switch
checked={value}
disabled={disabled}
onChange={onChange}
className={cn(
"relative inline-flex flex-shrink-0 h-6 w-10 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700",
{
"h-4 w-6": size === "sm",
"h-5 w-8": size === "md",
"bg-custom-primary-100": value,
"cursor-not-allowed bg-custom-background-80": disabled,
},
className
)}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className={cn(
"inline-block self-center h-4 w-4 transform rounded-full shadow ring-0 transition duration-200 ease-in-out",
{
"translate-x-5 bg-white": value,
"h-2 w-2": size === "sm",
"h-3 w-3": size === "md",
"translate-x-3": value && size === "sm",
"translate-x-4": value && size === "md",
"translate-x-0.5 bg-custom-background-90": !value,
"cursor-not-allowed bg-custom-background-90": disabled,
}
)}
/>
</Switch>
);
};
ToggleSwitch.displayName = "plane-ui-toggle-switch";
export { ToggleSwitch };

View File

@@ -0,0 +1,41 @@
import * as React from "react";
import { cn } from "../utils";
import {
ECardDirection,
ECardSpacing,
ECardVariant,
getCardStyle,
TCardDirection,
TCardSpacing,
TCardVariant,
} from "./helper";
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: TCardVariant;
spacing?: TCardSpacing;
direction?: TCardDirection;
className?: string;
children: React.ReactNode;
}
const Card = React.forwardRef<HTMLDivElement, CardProps>((props, ref) => {
const {
variant = ECardVariant.WITH_SHADOW,
direction = ECardDirection.COLUMN,
className = "",
spacing = ECardSpacing.LG,
children,
...rest
} = props;
const style = getCardStyle(variant, spacing, direction);
return (
<div ref={ref} className={cn(style, className)} {...rest}>
{children}
</div>
);
});
Card.displayName = "plane-ui-card";
export { Card, ECardVariant, ECardSpacing, ECardDirection };

View File

@@ -0,0 +1,36 @@
export enum ECardVariant {
WITHOUT_SHADOW = "without-shadow",
WITH_SHADOW = "with-shadow",
}
export enum ECardDirection {
ROW = "row",
COLUMN = "column",
}
export enum ECardSpacing {
SM = "sm",
LG = "lg",
}
export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW;
export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN;
export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG;
export interface ICardProperties {
[key: string]: string;
}
const DEFAULT_STYLE =
"bg-custom-background-100 rounded-lg border-[0.5px] border-custom-border-200 w-full flex flex-col";
export const containerStyle: ICardProperties = {
[ECardVariant.WITHOUT_SHADOW]: "",
[ECardVariant.WITH_SHADOW]: "hover:shadow-custom-shadow-4xl duration-300",
};
export const spacings = {
[ECardSpacing.SM]: "p-4",
[ECardSpacing.LG]: "p-6",
};
export const directions = {
[ECardDirection.ROW]: "flex-row space-x-3",
[ECardDirection.COLUMN]: "flex-col space-y-3",
};
export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) =>
DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing];

View File

@@ -0,0 +1 @@
export * from "./card";

View File

@@ -0,0 +1,50 @@
import React, { FC } from "react";
import { DropdownIcon, ISvgIcons } from "@plane/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 const CollapsibleButton: FC<Props> = (props) => {
const {
isOpen,
title,
hideChevron = false,
indicatorElement,
actionItemElement,
className = "",
titleClassName = "",
ChevronIcon = DropdownIcon,
} = props;
return (
<div
className={cn(
"flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-200",
className
)}
>
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
<ChevronIcon
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
"-rotate-90": !isOpen,
})}
/>
)}
<span className={cn("text-base text-custom-text-100 font-medium", titleClassName)}>{title}</span>
</div>
{indicatorElement && indicatorElement}
</div>
{actionItemElement && isOpen && actionItemElement}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { Disclosure, Transition } from "@headlessui/react";
import React, { FC, useState, useEffect, useCallback } from "react";
export type TCollapsibleProps = {
title: string | React.ReactNode;
children: React.ReactNode;
buttonRef?: React.RefObject<HTMLButtonElement>;
className?: string;
buttonClassName?: string;
isOpen?: boolean;
onToggle?: () => void;
defaultOpen?: boolean;
};
export const Collapsible: FC<TCollapsibleProps> = (props) => {
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
show={localIsOpen}
enter="transition-max-height duration-400 ease-in-out"
enterFrom="max-h-0"
enterTo="max-h-screen"
leave="transition-max-height duration-400 ease-in-out"
leaveFrom="max-h-screen"
leaveTo="max-h-0"
>
<Disclosure.Panel static>{children}</Disclosure.Panel>
</Transition>
</Disclosure>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./collapsible";
export * from "./collapsible-button";

View File

@@ -0,0 +1,38 @@
import * as React from "react";
interface ColorPickerProps {
value: string;
onChange: (color: string) => void;
className?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = (props) => {
const { value, onChange, className = "" } = props;
// refs
const inputRef = React.useRef<HTMLInputElement>(null);
// handlers
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
inputRef.current?.click();
};
return (
<div className="flex items-center justify-center relative">
<button
className={`size-4 rounded-full cursor-pointer conical-gradient ${className}`}
onClick={handleOnClick}
aria-label="Open color picker"
/>
<input
ref={inputRef}
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 size-4 invisible"
aria-hidden="true"
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./color-picker";

View File

@@ -0,0 +1,918 @@
import {
Activity,
Airplay,
AlertCircle,
AlertOctagon,
AlertTriangle,
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Anchor,
Aperture,
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
AtSign,
Award,
BarChart,
BarChart2,
Battery,
BatteryCharging,
Bell,
BellOff,
Book,
Bookmark,
BookOpen,
Box,
Briefcase,
Calendar,
Camera,
CameraOff,
Cast,
Check,
CheckCircle,
CheckSquare,
Clipboard,
Clock,
Cloud,
CloudDrizzle,
CloudLightning,
CloudOff,
CloudRain,
CloudSnow,
Code,
Codepen,
Codesandbox,
Coffee,
Columns,
Command,
Compass,
Copy,
CornerDownLeft,
CornerDownRight,
CornerLeftDown,
CornerLeftUp,
CornerRightDown,
CornerRightUp,
CornerUpLeft,
CornerUpRight,
Cpu,
CreditCard,
Crop,
Crosshair,
Database,
Delete,
Disc,
Divide,
DivideCircle,
DivideSquare,
DollarSign,
Download,
DownloadCloud,
Dribbble,
Droplet,
Edit,
Edit2,
Edit3,
ExternalLink,
Eye,
EyeOff,
Facebook,
FastForward,
Feather,
Figma,
File,
FileMinus,
FilePlus,
FileText,
Film,
Filter,
Flag,
Folder,
FolderMinus,
FolderPlus,
Framer,
Frown,
Gift,
GitBranch,
GitCommit,
GitMerge,
GitPullRequest,
Github,
Gitlab,
Globe,
Grid,
HardDrive,
Hash,
Headphones,
Heart,
HelpCircle,
Hexagon,
Home,
Image,
Inbox,
Info,
Instagram,
Italic,
Key,
Layers,
Layout,
LifeBuoy,
Link,
Link2,
Linkedin,
List,
Loader,
Lock,
LogIn,
LogOut,
Mail,
Map,
MapPin,
Maximize,
Maximize2,
Meh,
Menu,
MessageCircle,
MessageSquare,
Mic,
MicOff,
Minimize,
Minimize2,
Minus,
MinusCircle,
MinusSquare,
CircleChevronDown,
UsersRound,
ToggleLeft,
Search,
User,
} from "lucide-react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from "@plane/propel/icons";
export const MATERIAL_ICONS_LIST = [
{
name: "search",
},
{
name: "home",
},
{
name: "menu",
},
{
name: "close",
},
{
name: "settings",
},
{
name: "done",
},
{
name: "check_circle",
},
{
name: "favorite",
},
{
name: "add",
},
{
name: "delete",
},
{
name: "arrow_back",
},
{
name: "star",
},
{
name: "logout",
},
{
name: "add_circle",
},
{
name: "cancel",
},
{
name: "arrow_drop_down",
},
{
name: "more_vert",
},
{
name: "check",
},
{
name: "check_box",
},
{
name: "toggle_on",
},
{
name: "open_in_new",
},
{
name: "refresh",
},
{
name: "login",
},
{
name: "radio_button_unchecked",
},
{
name: "more_horiz",
},
{
name: "apps",
},
{
name: "radio_button_checked",
},
{
name: "download",
},
{
name: "remove",
},
{
name: "toggle_off",
},
{
name: "bolt",
},
{
name: "arrow_upward",
},
{
name: "filter_list",
},
{
name: "delete_forever",
},
{
name: "autorenew",
},
{
name: "key",
},
{
name: "sort",
},
{
name: "sync",
},
{
name: "add_box",
},
{
name: "block",
},
{
name: "restart_alt",
},
{
name: "menu_open",
},
{
name: "shopping_cart_checkout",
},
{
name: "expand_circle_down",
},
{
name: "backspace",
},
{
name: "undo",
},
{
name: "done_all",
},
{
name: "do_not_disturb_on",
},
{
name: "open_in_full",
},
{
name: "double_arrow",
},
{
name: "sync_alt",
},
{
name: "zoom_in",
},
{
name: "done_outline",
},
{
name: "drag_indicator",
},
{
name: "fullscreen",
},
{
name: "star_half",
},
{
name: "settings_accessibility",
},
{
name: "reply",
},
{
name: "exit_to_app",
},
{
name: "unfold_more",
},
{
name: "library_add",
},
{
name: "cached",
},
{
name: "select_check_box",
},
{
name: "terminal",
},
{
name: "change_circle",
},
{
name: "disabled_by_default",
},
{
name: "swap_horiz",
},
{
name: "swap_vert",
},
{
name: "app_registration",
},
{
name: "download_for_offline",
},
{
name: "close_fullscreen",
},
{
name: "file_open",
},
{
name: "minimize",
},
{
name: "open_with",
},
{
name: "dataset",
},
{
name: "add_task",
},
{
name: "start",
},
{
name: "keyboard_voice",
},
{
name: "create_new_folder",
},
{
name: "forward",
},
{
name: "download",
},
{
name: "settings_applications",
},
{
name: "compare_arrows",
},
{
name: "redo",
},
{
name: "zoom_out",
},
{
name: "publish",
},
{
name: "html",
},
{
name: "token",
},
{
name: "switch_access_shortcut",
},
{
name: "fullscreen_exit",
},
{
name: "sort_by_alpha",
},
{
name: "delete_sweep",
},
{
name: "indeterminate_check_box",
},
{
name: "view_timeline",
},
{
name: "settings_backup_restore",
},
{
name: "arrow_drop_down_circle",
},
{
name: "assistant_navigation",
},
{
name: "sync_problem",
},
{
name: "clear_all",
},
{
name: "density_medium",
},
{
name: "heart_plus",
},
{
name: "filter_alt_off",
},
{
name: "expand",
},
{
name: "subdirectory_arrow_right",
},
{
name: "download_done",
},
{
name: "arrow_outward",
},
{
name: "123",
},
{
name: "swipe_left",
},
{
name: "auto_mode",
},
{
name: "saved_search",
},
{
name: "place_item",
},
{
name: "system_update_alt",
},
{
name: "javascript",
},
{
name: "search_off",
},
{
name: "output",
},
{
name: "select_all",
},
{
name: "fit_screen",
},
{
name: "swipe_up",
},
{
name: "dynamic_form",
},
{
name: "hide_source",
},
{
name: "swipe_right",
},
{
name: "switch_access_shortcut_add",
},
{
name: "browse_gallery",
},
{
name: "css",
},
{
name: "density_small",
},
{
name: "assistant_direction",
},
{
name: "check_small",
},
{
name: "youtube_searched_for",
},
{
name: "move_up",
},
{
name: "swap_horizontal_circle",
},
{
name: "data_thresholding",
},
{
name: "install_mobile",
},
{
name: "move_down",
},
{
name: "dataset_linked",
},
{
name: "keyboard_command_key",
},
{
name: "view_kanban",
},
{
name: "swipe_down",
},
{
name: "key_off",
},
{
name: "transcribe",
},
{
name: "send_time_extension",
},
{
name: "swipe_down_alt",
},
{
name: "swipe_left_alt",
},
{
name: "swipe_right_alt",
},
{
name: "swipe_up_alt",
},
{
name: "keyboard_option_key",
},
{
name: "cycle",
},
{
name: "rebase",
},
{
name: "rebase_edit",
},
{
name: "empty_dashboard",
},
{
name: "magic_exchange",
},
{
name: "acute",
},
{
name: "point_scan",
},
{
name: "step_into",
},
{
name: "cheer",
},
{
name: "emoticon",
},
{
name: "explosion",
},
{
name: "water_bottle",
},
{
name: "weather_hail",
},
{
name: "syringe",
},
{
name: "pill",
},
{
name: "genetics",
},
{
name: "allergy",
},
{
name: "medical_mask",
},
{
name: "body_fat",
},
{
name: "barefoot",
},
{
name: "infrared",
},
{
name: "wrist",
},
{
name: "metabolism",
},
{
name: "conditions",
},
{
name: "taunt",
},
{
name: "altitude",
},
{
name: "tibia",
},
{
name: "footprint",
},
{
name: "eyeglasses",
},
{
name: "man_3",
},
{
name: "woman_2",
},
{
name: "rheumatology",
},
{
name: "tornado",
},
{
name: "landslide",
},
{
name: "foggy",
},
{
name: "severe_cold",
},
{
name: "tsunami",
},
{
name: "vape_free",
},
{
name: "sign_language",
},
{
name: "emoji_symbols",
},
{
name: "clear_night",
},
{
name: "emoji_food_beverage",
},
{
name: "hive",
},
{
name: "thunderstorm",
},
{
name: "communication",
},
{
name: "rocket",
},
{
name: "pets",
},
{
name: "public",
},
{
name: "quiz",
},
{
name: "mood",
},
{
name: "gavel",
},
{
name: "eco",
},
{
name: "diamond",
},
{
name: "forest",
},
{
name: "rainy",
},
{
name: "skull",
},
];
export const LUCIDE_ICONS_LIST = [
{ name: "Activity", element: Activity },
{ name: "Airplay", element: Airplay },
{ name: "AlertCircle", element: AlertCircle },
{ name: "AlertOctagon", element: AlertOctagon },
{ name: "AlertTriangle", element: AlertTriangle },
{ name: "AlignCenter", element: AlignCenter },
{ name: "AlignJustify", element: AlignJustify },
{ name: "AlignLeft", element: AlignLeft },
{ name: "AlignRight", element: AlignRight },
{ name: "Anchor", element: Anchor },
{ name: "Aperture", element: Aperture },
{ name: "Archive", element: Archive },
{ name: "ArrowDown", element: ArrowDown },
{ name: "ArrowLeft", element: ArrowLeft },
{ name: "ArrowRight", element: ArrowRight },
{ name: "ArrowUp", element: ArrowUp },
{ name: "AtSign", element: AtSign },
{ name: "Award", element: Award },
{ name: "BarChart", element: BarChart },
{ name: "BarChart2", element: BarChart2 },
{ name: "Battery", element: Battery },
{ name: "BatteryCharging", element: BatteryCharging },
{ name: "Bell", element: Bell },
{ name: "BellOff", element: BellOff },
{ name: "Book", element: Book },
{ name: "Bookmark", element: Bookmark },
{ name: "BookOpen", element: BookOpen },
{ name: "Box", element: Box },
{ name: "Briefcase", element: Briefcase },
{ name: "Calendar", element: Calendar },
{ name: "Camera", element: Camera },
{ name: "CameraOff", element: CameraOff },
{ name: "Cast", element: Cast },
{ name: "CircleChevronDown", element: CircleChevronDown },
{ name: "Check", element: Check },
{ name: "CheckCircle", element: CheckCircle },
{ name: "CheckSquare", element: CheckSquare },
{ name: "ChevronDown", element: ChevronDownIcon },
{ name: "ChevronLeft", element: ChevronLeftIcon },
{ name: "ChevronRight", element: ChevronRightIcon },
{ name: "ChevronUp", element: ChevronUpIcon },
{ name: "Clipboard", element: Clipboard },
{ name: "Clock", element: Clock },
{ name: "Cloud", element: Cloud },
{ name: "CloudDrizzle", element: CloudDrizzle },
{ name: "CloudLightning", element: CloudLightning },
{ name: "CloudOff", element: CloudOff },
{ name: "CloudRain", element: CloudRain },
{ name: "CloudSnow", element: CloudSnow },
{ name: "Code", element: Code },
{ name: "Codepen", element: Codepen },
{ name: "Codesandbox", element: Codesandbox },
{ name: "Coffee", element: Coffee },
{ name: "Columns", element: Columns },
{ name: "Command", element: Command },
{ name: "Compass", element: Compass },
{ name: "Copy", element: Copy },
{ name: "CornerDownLeft", element: CornerDownLeft },
{ name: "CornerDownRight", element: CornerDownRight },
{ name: "CornerLeftDown", element: CornerLeftDown },
{ name: "CornerLeftUp", element: CornerLeftUp },
{ name: "CornerRightDown", element: CornerRightDown },
{ name: "CornerRightUp", element: CornerRightUp },
{ name: "CornerUpLeft", element: CornerUpLeft },
{ name: "CornerUpRight", element: CornerUpRight },
{ name: "Cpu", element: Cpu },
{ name: "CreditCard", element: CreditCard },
{ name: "Crop", element: Crop },
{ name: "Crosshair", element: Crosshair },
{ name: "Database", element: Database },
{ name: "Delete", element: Delete },
{ name: "Disc", element: Disc },
{ name: "Divide", element: Divide },
{ name: "DivideCircle", element: DivideCircle },
{ name: "DivideSquare", element: DivideSquare },
{ name: "DollarSign", element: DollarSign },
{ name: "Download", element: Download },
{ name: "DownloadCloud", element: DownloadCloud },
{ name: "Dribbble", element: Dribbble },
{ name: "Droplet", element: Droplet },
{ name: "Edit", element: Edit },
{ name: "Edit2", element: Edit2 },
{ name: "Edit3", element: Edit3 },
{ name: "ExternalLink", element: ExternalLink },
{ name: "Eye", element: Eye },
{ name: "EyeOff", element: EyeOff },
{ name: "Facebook", element: Facebook },
{ name: "FastForward", element: FastForward },
{ name: "Feather", element: Feather },
{ name: "Figma", element: Figma },
{ name: "File", element: File },
{ name: "FileMinus", element: FileMinus },
{ name: "FilePlus", element: FilePlus },
{ name: "FileText", element: FileText },
{ name: "Film", element: Film },
{ name: "Filter", element: Filter },
{ name: "Flag", element: Flag },
{ name: "Folder", element: Folder },
{ name: "FolderMinus", element: FolderMinus },
{ name: "FolderPlus", element: FolderPlus },
{ name: "Framer", element: Framer },
{ name: "Frown", element: Frown },
{ name: "Gift", element: Gift },
{ name: "GitBranch", element: GitBranch },
{ name: "GitCommit", element: GitCommit },
{ name: "GitMerge", element: GitMerge },
{ name: "GitPullRequest", element: GitPullRequest },
{ name: "Github", element: Github },
{ name: "Gitlab", element: Gitlab },
{ name: "Globe", element: Globe },
{ name: "Grid", element: Grid },
{ name: "HardDrive", element: HardDrive },
{ name: "Hash", element: Hash },
{ name: "Headphones", element: Headphones },
{ name: "Heart", element: Heart },
{ name: "HelpCircle", element: HelpCircle },
{ name: "Hexagon", element: Hexagon },
{ name: "Home", element: Home },
{ name: "Image", element: Image },
{ name: "Inbox", element: Inbox },
{ name: "Info", element: Info },
{ name: "Instagram", element: Instagram },
{ name: "Italic", element: Italic },
{ name: "Key", element: Key },
{ name: "Layers", element: Layers },
{ name: "Layout", element: Layout },
{ name: "LifeBuoy", element: LifeBuoy },
{ name: "Link", element: Link },
{ name: "Link2", element: Link2 },
{ name: "Linkedin", element: Linkedin },
{ name: "List", element: List },
{ name: "Loader", element: Loader },
{ name: "Lock", element: Lock },
{ name: "LogIn", element: LogIn },
{ name: "LogOut", element: LogOut },
{ name: "Mail", element: Mail },
{ name: "Map", element: Map },
{ name: "MapPin", element: MapPin },
{ name: "Maximize", element: Maximize },
{ name: "Maximize2", element: Maximize2 },
{ name: "Meh", element: Meh },
{ name: "Menu", element: Menu },
{ name: "MessageCircle", element: MessageCircle },
{ name: "MessageSquare", element: MessageSquare },
{ name: "Mic", element: Mic },
{ name: "MicOff", element: MicOff },
{ name: "Minimize", element: Minimize },
{ name: "Minimize2", element: Minimize2 },
{ name: "Minus", element: Minus },
{ name: "MinusCircle", element: MinusCircle },
{ name: "MinusSquare", element: MinusSquare },
{ name: "Search", element: Search },
{ name: "ToggleLeft", element: ToggleLeft },
{ name: "User", element: User },
{ name: "UsersRound", element: UsersRound },
];

View File

@@ -0,0 +1 @@
export * from "./icons";

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { Row } from "../row";
import { ERowVariant, TRowVariant } 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<HTMLDivElement, ContentWrapperProps>((props, ref) => {
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 @@
export * from "./content-wrapper";

View File

@@ -0,0 +1,51 @@
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<HTMLAnchorElement, TControlLink>((props, ref) => {
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 @@
export * from "./control-link";

View File

@@ -0,0 +1,37 @@
import { MoreVertical } from "lucide-react";
import React, { forwardRef } from "react";
// helpers
import { cn } from "./utils";
interface IDragHandle {
className?: string;
disabled?: boolean;
}
export const DragHandle = forwardRef<HTMLButtonElement | null, IDragHandle>((props, ref) => {
const { className, disabled = false } = props;
if (disabled) {
return <div className="w-[14px] h-[18px]" />;
}
return (
<button
type="button"
className={cn(
"p-0.5 flex flex-shrink-0 rounded bg-custom-background-90 text-custom-sidebar-text-200 cursor-grab",
className
)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
ref={ref}
>
<MoreVertical className="h-3.5 w-3.5 stroke-custom-text-400" />
<MoreVertical className="-ml-5 h-3.5 w-3.5 stroke-custom-text-400" />
</button>
);
});
DragHandle.displayName = "DragHandle";

View File

@@ -0,0 +1,25 @@
import React from "react";
import { cn } from "./utils";
type Props = {
isVisible: boolean;
classNames?: string;
};
export const DropIndicator = (props: Props) => {
const { isVisible, classNames = "" } = props;
return (
<div
className={cn(
`block relative h-[2px] w-full
before:left-0 before:relative before:block before:top-[-2px] before:h-[6px] before:w-[6px] before:rounded
after:left-[calc(100%-6px)] after:relative after:block after:top-[-8px] after:h-[6px] after:w-[6px] after:rounded`,
{
"bg-custom-primary-100 before:bg-custom-primary-100 after:bg-custom-primary-100": 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,37 @@
import { Combobox } from "@headlessui/react";
import React, { Fragment } from "react";
// helper
import { cn } from "../../utils";
import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelectDropdownButton> = (props) => {
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-custom-text-200": 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,4 @@
export * from "./input-search";
export * from "./button";
export * from "./options";
export * from "./loader";

View File

@@ -0,0 +1,60 @@
import { Combobox } from "@headlessui/react";
import { Search } from "lucide-react";
import React, { FC, useEffect, useRef } from "react";
// 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 const InputSearch: FC<IInputSearch> = (props) => {
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 border border-custom-border-100 bg-custom-background-90 px-2",
inputContainerClassName
)}
>
{inputIcon ? <>{inputIcon}</> : <Search className="h-4 w-4 text-custom-text-300" aria-hidden="true" />}
<Combobox.Input
as="input"
ref={inputRef}
className={cn(
"w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none",
inputClassName
)}
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={inputPlaceholder ?? "Search"}
onKeyDown={searchInputKeyDown}
/>
</div>
);
};

View File

@@ -0,0 +1,10 @@
import { range } from "lodash-es";
import React from "react";
export const DropdownOptionsLoader = () => (
<div className="flex flex-col gap-1 animate-pulse">
{range(6).map((index) => (
<div key={index} className="flex h-[1.925rem] w-full rounded px-1 py-1.5 bg-custom-background-90" />
))}
</div>
);

View File

@@ -0,0 +1,91 @@
import { Combobox } from "@headlessui/react";
import { Check } from "lucide-react";
import React from "react";
// helpers
import { cn } from "../../utils";
// types
import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
// components
import { DropdownOptionsLoader, InputSearch } from ".";
export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSelectDropdownOptions> = (props) => {
const {
isOpen,
query,
setQuery,
inputIcon,
inputPlaceholder,
inputClassName,
inputContainerClassName,
disableSearch,
keyExtractor,
options,
handleClose,
value,
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 space-y-1 overflow-y-scroll", !disableSearch && "mt-2")}>
<>
{options ? (
options.length > 0 ? (
options?.map((option) => (
<Combobox.Option
key={keyExtractor(option)}
value={keyExtractor(option)}
disabled={option.disabled}
className={({ active, selected }) =>
cn(
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
{
"bg-custom-background-80": active,
"text-custom-text-100": selected,
"text-custom-text-200": !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 && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</>
)}
</Combobox.Option>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">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 { 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,3 @@
export * from "./common";
export * from "./multi-select";
export * from "./single-select";

View File

@@ -0,0 +1,167 @@
import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es";
import React, { FC, 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 { IMultiSelectDropdown } from "./dropdown";
export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
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) 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);
return (
<Combobox
as="div"
ref={dropdownRef}
value={value}
onChange={onChange}
className={cn(
"h-full",
typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName
)}
tabIndex={tabIndex}
multiple
onKeyDown={handleKeyDown}
disabled={disabled}
>
<DropdownButton
value={value}
isOpen={isOpen}
setReferenceElement={setReferenceElement}
handleOnClick={handleOnClick}
buttonContent={buttonContent}
buttonClassName={buttonClassName}
buttonContainerClassName={buttonContainerClassName}
disabled={disabled}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg 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}
/>
</div>
</Combobox.Options>
)}
</Combobox>
);
};

View File

@@ -0,0 +1,167 @@
import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es";
import React, { FC, 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 { ISingleSelectDropdown } from "./dropdown";
export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
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}
onChange={onChange}
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 className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg 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,76 @@
import { Combobox } from "@headlessui/react";
import React, {
ElementType,
Fragment,
KeyboardEventHandler,
ReactNode,
Ref,
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((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="h-full flex items-center">
{button}
</div>
);
}
return (
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error, @typescript-eslint/ban-ts-comment
// @ts-expect-error
<Combobox {...rest} ref={ref}>
<Combobox.Button as={Fragment}>{button}</Combobox.Button>
{children}
</Combobox>
);
});
const ComboOptions = Combobox.Options;
const ComboOption = Combobox.Option;
const ComboInput = Combobox.Input;
ComboDropDown.displayName = "ComboDropDown";
export { ComboDropDown, ComboOptions, ComboOption, ComboInput };

View File

@@ -0,0 +1,2 @@
export * from "./item";
export * from "./root";

View File

@@ -0,0 +1,245 @@
import React, { useState, useRef, useContext } from "react";
import { usePopper } from "react-popper";
import { ChevronRightIcon } from "@plane/propel/icons";
// helpers
import { cn } from "../../utils";
// types
import { TContextMenuItem, ContextMenuContext, Portal } from "./root";
type ContextMenuItemProps = {
handleActiveItem: () => void;
handleClose: () => void;
isActive: boolean;
item: TContextMenuItem;
};
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
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(
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
{
"bg-custom-background-90": isActive,
"text-custom-text-400": 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("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
{hasNestedItems && <ChevronRightIcon 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={cn(
"fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-lg",
"ring-1 ring-black ring-opacity-5"
)}
data-context-submenu="true"
>
<div ref={nestedMenuRef} className="max-h-72 overflow-y-scroll vertical-scrollbar scrollbar-sm">
{renderedNestedItems.map((nestedItem, index) => (
<button
key={nestedItem.key}
type="button"
className={cn(
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
{
"bg-custom-background-90": index === activeNestedIndex,
"text-custom-text-400": 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("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": nestedItem.disabled,
})}
>
{nestedItem.description}
</p>
)}
</div>
</>
)}
</button>
))}
</div>
</div>
</Portal>
)}
</>
);
};

View File

@@ -0,0 +1,237 @@
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 const Portal: React.FC<PortalProps> = ({ children, container }) => {
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>;
items: TContextMenuItem[];
portalContainer?: Element | null;
};
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
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(
"fixed h-screen w-screen top-0 left-0 cursor-default z-30 opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": isOpen,
}
)}
>
<div
ref={contextMenuRef}
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
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 const ContextMenu: React.FC<ContextMenuProps> = (props) => {
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,532 @@
import { Menu } from "@headlessui/react";
import { MoreHorizontal } from "lucide-react";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronDownIcon, ChevronRightIcon } from "@plane/propel/icons";
// plane helpers
// helpers
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
// hooks
// types
import {
ICustomMenuDropdownProps,
ICustomMenuItemProps,
ICustomSubMenuProps,
ICustomSubMenuTriggerProps,
ICustomSubMenuContentProps,
} from "./helper";
interface PortalProps {
children: React.ReactNode;
container?: Element | null;
asChild?: boolean;
}
const Portal: React.FC<PortalProps> = ({ children, container, asChild = false }) => {
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);
const 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]);
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(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
{
"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={{ closeAllSubmenus, registerSubmenu }}>{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)}
onKeyDownCapture={handleKeyDown}
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 p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
tabIndex={customButtonTabIndex}
aria-label={ariaLabel}
>
<MoreHorizontal 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 whitespace-nowrap rounded-md px-2.5 py-1 text-xs duration-300 ${
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
} ${noBorder ? "" : "border border-custom-border-300 shadow-sm focus:outline-none"} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={handleMenuButtonClick}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{label}
{!noChevron && <ChevronDownIcon 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
const SubMenu: React.FC<ICustomSubMenuProps> = (props) => {
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();
};
// 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 }) => (
<div
className={cn(
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between cursor-pointer",
{
"bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
"cursor-not-allowed": disabled,
}
)}
onClick={handleClick}
>
<span className="flex-1">{trigger}</span>
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0" />
</div>
)}
</Menu.Item>
</span>
{isOpen && (
<Portal>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className={cn(
"fixed z-30 min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-lg",
"ring-1 ring-black ring-opacity-5", // Additional styling to make it stand out
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={{ closeSubmenu }}>{children}</SubMenuContext.Provider>
</div>
</Portal>
)}
</div>
);
};
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
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 select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
{
"bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
},
className
)}
onClick={(e) => {
close();
onClick?.(e);
// Close submenu if this item is inside a submenu
submenuContext?.closeSubmenu();
}}
disabled={disabled}
>
{children}
</button>
)}
</Menu.Item>
);
};
const SubMenuTrigger: React.FC<ICustomSubMenuTriggerProps> = (props) => {
const { children, disabled = false, className } = props;
return (
<Menu.Item as="div" disabled={disabled}>
{({ active }) => (
<div
className={cn(
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between",
{
"bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
"cursor-pointer": !disabled,
"cursor-not-allowed": disabled,
},
className
)}
>
<span className="flex-1">{children}</span>
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0" />
</div>
)}
</Menu.Item>
);
};
const SubMenuContent: React.FC<ICustomSubMenuContentProps> = (props) => {
const { children, className } = props;
return (
<div
className={cn(
"z-[15] min-w-[12rem] overflow-hidden rounded-md border border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-rg",
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,226 @@
import { Combobox } from "@headlessui/react";
import { Check, Info, Search } from "lucide-react";
import React, { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronDownIcon } from "@plane/propel/icons";
// plane imports
// local imports
import { Tooltip } from "@plane/propel/tooltip";
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
import { ICustomSearchSelectProps } from "./helper";
export const 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-xs",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !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 border-[0.5px] border-custom-border-300",
{
"px-3 py-2 text-sm": input,
"px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
)}
</button>
</Combobox.Button>
)}
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-48 whitespace-nowrap z-30",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2 mx-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div
className={cn("mt-2 px-2 space-y-1 overflow-y-scroll vertical-scrollbar scrollbar-xs", {
"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
key={option.value}
value={option.value}
className={({ active }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
"text-custom-text-400 opacity-60 cursor-not-allowed": option.disabled,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
disabled={option.disabled}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
{option.tooltip && (
<>
{typeof option.tooltip === "string" ? (
<Tooltip tooltipContent={option.tooltip}>
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-custom-text-200" />
</Tooltip>
) : (
option.tooltip
)}
</>
)}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">{noResultsMessage}</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
{footerOption}
</div>
</Combobox.Options>,
document.body
)}
</>
);
}}
</Combobox>
);
};

View File

@@ -0,0 +1,163 @@
import { Combobox } from "@headlessui/react";
import { Check } from "lucide-react";
import React, { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronDownIcon } from "@plane/propel/icons";
// plane helpers
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
import { cn } from "../utils";
// types
import { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
const 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 = () => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const toggleDropdown = () => {
if (isOpen) closeDropdown();
else openDropdown();
};
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
value={value}
onChange={onChange}
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 text-xs ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${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-[0.5px] border-custom-border-300",
{
"px-3 py-2 text-sm": input,
"px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
)}
</>
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-48 whitespace-nowrap z-30",
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>
);
};
const Option = (props: ICustomSelectItemProps) => {
const { children, value, className } = props;
return (
<Combobox.Option
value={value}
className={({ active }) =>
cn(
"cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2",
{
"bg-custom-background-80": active,
},
className
)
}
>
{({ selected }) => (
<>
{children}
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
);
};
CustomSelect.Option = Option;
export { CustomSelect };

View File

@@ -0,0 +1,121 @@
// FIXME: fix this!!!
import { 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,5 @@
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,29 @@
import { Star } from "lucide-react";
import React from "react";
// helpers
import { cn } from "./utils";
type Props = {
buttonClassName?: string;
iconClassName?: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
selected: boolean;
};
export const FavoriteStar: React.FC<Props> = (props) => {
const { buttonClassName, iconClassName, onClick, selected } = props;
return (
<button type="button" className={cn("h-4 w-4 grid place-items-center", buttonClassName)} onClick={onClick}>
<Star
className={cn(
"h-4 w-4 text-custom-text-300 transition-all",
{
"fill-yellow-500 stroke-yellow-500": selected,
},
iconClassName
)}
/>
</button>
);
};

View File

@@ -0,0 +1,88 @@
import * as React from "react";
// helpers
import { cn } from "../utils";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
containerClassName?: string;
iconClassName?: string;
indeterminate?: boolean;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const {
id,
name,
checked,
indeterminate = false,
disabled,
containerClassName,
iconClassName,
className,
...rest
} = props;
return (
<div className={cn("relative flex-shrink-0 flex gap-2", containerClassName)}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={cn(
"appearance-none shrink-0 size-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50 cursor-pointer",
{
"border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled,
"border-custom-border-300 hover:border-custom-border-400 bg-transparent": !disabled,
"border-custom-primary-40 hover:border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
!disabled && (checked || indeterminate),
},
className
)}
disabled={disabled}
{...rest}
/>
<svg
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-4 p-0.5 pointer-events-none outline-none hidden stroke-white",
{
block: checked,
"stroke-custom-text-400 opacity-40": disabled,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-4 p-0.5 pointer-events-none outline-none stroke-white hidden",
{
"stroke-custom-text-400 opacity-40": disabled,
block: indeterminate && !checked,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };

View File

@@ -0,0 +1,6 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./checkbox";
export * from "./root";
export * from "./password";

View File

@@ -0,0 +1,114 @@
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";
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 const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
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-custom-border-200", className)}
style={style}
/>
<Popover as="div" className="absolute right-1 top-1/2 z-10 -translate-y-1/2">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
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 border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ColorPicker.SketchPicker color={value} onChange={handleColorChange} />
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
</div>
);
};

View File

@@ -0,0 +1,54 @@
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;
autoComplete?: "on" | "off";
}
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
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(
"block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none",
{
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
"rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary":
mode === "transparent",
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
"border-red-500": 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,65 @@
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-custom-text-100",
activeFragments: 0,
};
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID:
return {
message: "Password is too short",
textColor: "text-red-500",
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-green-500",
activeFragments: 3,
};
default:
return {
message: "Please enter your password",
textColor: "text-custom-text-100",
activeFragments: 0,
};
}
};
/**
* Get fragment color based on position and active state
*/
export const getFragmentColor = (fragmentIndex: number, activeFragments: number): string => {
if (fragmentIndex >= activeFragments) {
return "bg-custom-background-80";
}
switch (activeFragments) {
case 1:
return "bg-red-500";
case 2:
return "bg-orange-500";
case 3:
return "bg-green-500";
default:
return "bg-custom-background-80";
}
};

View File

@@ -0,0 +1,3 @@
export * from "./indicator";
export * from "./helper";
export * from "./password-input";

View File

@@ -0,0 +1,75 @@
import { CircleCheck } from "lucide-react";
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 const PasswordStrengthIndicator: React.FC<PasswordStrengthIndicatorProps> = ({
password,
showCriteria = true,
isFocused = false,
}) => {
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 gap-1 w-full transition-all duration-300 ease-linear">
{[0, 1, 2].map((fragmentIndex) => (
<div
key={fragmentIndex}
className={cn(
"h-1 flex-1 rounded-sm transition-all duration-300 ease-in-out",
getFragmentColor(fragmentIndex, strengthInfo.activeFragments)
)}
/>
))}
</div>
{/* Strength Message */}
{password && <p className={cn("text-sm 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">
<CircleCheck
className={cn("h-3 w-3 flex-shrink-0", {
"text-green-500": criterion.isValid,
"text-custom-text-100": !criterion.isValid,
})}
/>
</div>
<span
className={cn("text-xs", {
"text-green-500": criterion.isValid,
"text-custom-text-100": !criterion.isValid,
})}
>
{criterion.label}
</span>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { Eye, EyeClosed } from "lucide-react";
import React, { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
interface PasswordInputProps {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
showToggle?: boolean;
error?: boolean;
}
export const PasswordInput: React.FC<PasswordInputProps> = ({
id,
value,
onChange,
placeholder = "Enter your password",
className,
showToggle = true,
error = false,
}) => {
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 px-3 py-2 pr-10 text-custom-text-200 border rounded-md bg-custom-background-100 focus:outline-none focus:ring-2 focus:ring-custom-primary-100 placeholder:text-custom-text-400 focus:border-transparent transition-all duration-200",
{
"border-custom-border-300": !error,
"border-red-500": error,
},
className
)}
placeholder={placeholder}
/>
{showToggle && (
<Tooltip tooltipContent={showPassword ? "Hide password" : "Show password"} position="top">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-custom-text-200 hover:text-custom-text-100 transition-colors duration-200"
>
<div className="relative w-4 h-4">
<Eye
className={cn(
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
showPassword ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"
)}
/>
<EyeClosed
className={cn(
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
showPassword ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-75 -rotate-12"
)}
/>
</div>
</button>
</Tooltip>
)}
</div>
);
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { cn } from "@plane/utils";
// Reusable Label Component
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
className?: string;
}
export const Label: React.FC<LabelProps> = ({ htmlFor, children, className }) => (
<label htmlFor={htmlFor} className={cn("block text-sm font-medium text-custom-text-100", className)}>
{children}
</label>
);
// Reusable Form Field Component
interface FormFieldProps {
label: string;
htmlFor: string;
children: React.ReactNode;
className?: string;
optional?: boolean;
}
export const FormField: React.FC<FormFieldProps> = ({ label, htmlFor, children, className, optional = false }) => (
<div className={cn("flex flex-col gap-1.5", className)}>
<Label htmlFor={htmlFor}>
{label}
{optional && <span className="text-custom-text-400 text-sm"> (optional)</span>}
</Label>
{children}
</div>
);
// Reusable Validation Message Component
interface ValidationMessageProps {
type: "error" | "success";
message: string;
className?: string;
}
export const ValidationMessage: React.FC<ValidationMessageProps> = ({ type, message, className }) => (
<p
className={cn(
"text-sm",
{
"text-red-500": type === "error",
"text-green-500": type === "success",
},
className
)}
>
{message}
</p>
);

View File

@@ -0,0 +1,58 @@
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<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
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-transparent placeholder-custom-text-400 outline-none",
{
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
"focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1":
mode === "transparent",
"rounded 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-red-500": hasError,
"bg-red-100": hasError && mode === "primary",
},
className
)}
{...rest}
/>
);
});
TextArea.displayName = "TextArea";
export { TextArea };

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { ERowVariant, Row } from "../row";
import { cn } from "../utils";
import { EHeaderVariant, getHeaderStyle, THeaderVariant } from "./helper";
export interface HeaderProps {
variant?: THeaderVariant;
setHeight?: boolean;
className?: string;
children: React.ReactNode;
showOnMobile?: boolean;
}
const HeaderContext = React.createContext<THeaderVariant | null>(null);
const 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>
);
};
const LeftItem = (props: HeaderProps) => (
<div
className={cn(
"flex flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap max-w-[80%] flex-grow",
props.className
)}
>
{props.children}
</div>
);
const 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 justify-end gap-3 w-auto items-start",
{
"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,28 @@
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-custom-sidebar-background-100 bg-custom-sidebar-background-100 z-[18]",
[EHeaderVariant.SECONDARY]:
"!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]",
[EHeaderVariant.TERNARY]:
"flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 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 @@
export * from "./header";

View File

@@ -0,0 +1,16 @@
import { useLayoutEffect } from "react";
export const useAutoResizeTextArea = (
textAreaRef: React.RefObject<HTMLTextAreaElement>,
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,31 @@
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,36 @@
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,6 @@
export const usePlatformOS = () => {
const userAgent = window.navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
return { isMobile };
};

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

@@ -0,0 +1,34 @@
export * from "./avatar";
export * from "./badge";
export * from "./breadcrumbs";
export * from "./button";
export * from "./card";
export * from "./collapsible";
export * from "./color-picker";
export * from "./constants";
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 "./progress";
export * from "./row";
export * from "./scroll-area";
export * from "./sortable";
export * from "./spinners";
export * from "./tables";
export * from "./tabs";
export * from "./tag";
export * from "./tooltip";
export * from "./typography";
export * from "./utils";
export * from "./billing";
export * from "./oauth";

View File

@@ -0,0 +1,69 @@
import React, { FC } from "react";
// plane utils
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
// plane ui
import { 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 const LinkItemBlock: FC<TLinkItemBlockProps> = (props) => {
// props
const { title, url, createdAt, menuItems, onClick } = props;
// icons
const Icon = getIconForLink(url);
return (
<div
onClick={onClick}
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
>
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-90 grid place-items-center">
<Icon className="size-4 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
</div>
<div className="flex-1 truncate">
<div className="text-sm font-medium truncate">{title}</div>
{createdAt && <div className="text-xs font-medium text-custom-text-400">{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 items-center gap-2 w-full ", {
"text-custom-text-400": 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("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./block";

View File

@@ -0,0 +1,30 @@
import React from "react";
// helpers
import { cn } from "./utils";
type Props = {
children: React.ReactNode;
className?: string;
};
const Loader = ({ children, className = "" }: Props) => (
<div className={cn("animate-pulse", className)} role="status">
{children}
</div>
);
type ItemProps = {
height?: string;
width?: string;
className?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto", className = "" }) => (
<div className={cn("rounded-md bg-custom-background-80", className)} style={{ height: height, width: width }} />
);
Loader.Item = Item;
Loader.displayName = "plane-ui-loader";
export { Loader };

View File

@@ -0,0 +1,95 @@
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
import React from "react";
// components
import { Button, TButtonVariant } from "../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;
};
const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
danger: AlertTriangle,
primary: Info,
};
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
danger: "danger",
primary: "primary",
};
const VARIANT_CLASSES: Record<TModalVariant, string> = {
danger: "bg-red-500/20 text-red-500",
primary: "bg-custom-primary-100/20 text-custom-primary-100",
};
export const AlertModalCore: React.FC<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,
} = props;
const Icon = VARIANT_ICONS[variant];
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={position} width={width}>
<div className="p-5 flex flex-col sm:flex-row items-center sm:items-start gap-4">
{!hideIcon && (
<span
className={cn(
"flex-shrink-0 grid place-items-center rounded-full size-12 sm:size-10",
VARIANT_CLASSES[variant]
)}
>
<Icon className="size-5" aria-hidden="true" />
</span>
)}
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium">{title}</h3>
<p className="mt-1 text-sm text-custom-text-200">{content}</p>
</div>
</div>
<div className="px-5 py-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{secondaryButtonText}
</Button>
<Button variant={BUTTON_VARIANTS[variant]} size="sm" tabIndex={1} onClick={handleSubmit} loading={isSubmitting}>
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
</Button>
</div>
</ModalCore>
);
};

View File

@@ -0,0 +1,17 @@
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,3 @@
export * from "./alert-modal";
export * from "./constants";
export * from "./modal-core";

View File

@@ -0,0 +1,67 @@
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 const ModalCore: React.FC<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-custom-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 transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all w-full",
width,
className
)}
>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1 @@
export * from "./oauth-options";

View File

@@ -0,0 +1,32 @@
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<HTMLButtonElement, OAuthButtonProps>((props, ref) => {
const { text, icon, compact = false, className = "", ...rest } = props;
return (
<button
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-center gap-2 rounded-md border border-custom-border-300 px-4 py-2.5 text-sm font-medium text-custom-text-100 duration-300 bg-onboarding-background-200 hover:bg-onboarding-background-300",
className
)}
{...rest}
>
<div className="flex flex-shrink-0 items-center justify-center">{icon}</div>
{!compact && (
<div className="flex flex-grow items-center justify-center transition-opacity duration-300">{text}</div>
)}
</button>
);
});
OAuthButton.displayName = "plane-ui-oauth-button";
export { OAuthButton };

View File

@@ -0,0 +1,57 @@
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;
className?: string;
containerClassName?: string;
};
export const OAuthOptions = (props: OAuthOptionsProps) => {
const { options, compact = false, 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>
<div className="mt-4 flex items-center transition-all duration-300">
<hr className="w-full border-custom-border-300 transition-colors duration-300" />
<p className="mx-3 flex-shrink-0 text-center text-sm text-custom-text-400 transition-colors duration-300">or</p>
<hr className="w-full border-custom-border-300 transition-colors duration-300" />
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./popover";
export * from "./popover-menu";

View File

@@ -0,0 +1,38 @@
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 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-sm text-gray-600 hover:text-gray-700 rounded-sm cursor-pointer hover:bg-gray-200 transition-all px-1.5 py-0.5 capitalize">
{item.name}
</div>
),
},
};
export default meta;
type Story = StoryObj<typeof PopoverMenu<TPopoverMenu>>;
export const Default: Story = {};

View File

@@ -0,0 +1,45 @@
import React, { Fragment } from "react";
// components
import { cn } from "../utils";
import { Popover } from "./popover";
// helpers
// types
import { TPopoverMenu } from "./types";
export const 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 border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
panelClassName
)}
popoverClassName={popoverClassName}
popoverButtonRef={popoverButtonRef}
>
<Fragment>
{data.map((item, index) => (
<Fragment key={keyExtractor(item, index)}>{render(item, index)}</Fragment>
))}
</Fragment>
</Popover>
);
};

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