base
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
import { AuthInput } from "./auth-input";
|
||||
|
||||
export type TAuthConfirmPasswordInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
password: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
containerClassName?: string;
|
||||
labelClassName?: string;
|
||||
errorClassName?: string;
|
||||
onPasswordMatchChange?: (matches: boolean) => void;
|
||||
};
|
||||
|
||||
export function AuthConfirmPasswordInput({
|
||||
password,
|
||||
label = "Confirm Password",
|
||||
error,
|
||||
showPasswordToggle = true,
|
||||
containerClassName = "",
|
||||
errorClassName = "",
|
||||
className = "",
|
||||
value = "",
|
||||
onChange,
|
||||
onPasswordMatchChange,
|
||||
...props
|
||||
}: TAuthConfirmPasswordInputProps) {
|
||||
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="off"
|
||||
/>
|
||||
{confirmPassword && passwordsMatch && <p className="text-13 text-success-primary">Passwords match</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface AuthForgotPasswordProps {
|
||||
onForgotPassword?: () => void;
|
||||
className?: string;
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AuthForgotPassword({
|
||||
onForgotPassword,
|
||||
className = "",
|
||||
text = "Forgot your password?",
|
||||
disabled = false,
|
||||
}: AuthForgotPasswordProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled && onForgotPassword) {
|
||||
onForgotPassword();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-13 text-accent-primary transition-colors duration-200 hover:text-accent-secondary",
|
||||
{
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, 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 function AuthForm({
|
||||
mode,
|
||||
initialData = {},
|
||||
onSubmit,
|
||||
onForgotPassword,
|
||||
onModeChange,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
className = "",
|
||||
showForgotPassword = true,
|
||||
showPasswordStrength = true,
|
||||
emailError,
|
||||
passwordError,
|
||||
confirmPasswordError,
|
||||
submitButtonText,
|
||||
alternateModeText,
|
||||
alternateModeButtonText,
|
||||
}: AuthFormProps) {
|
||||
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-13 text-tertiary">{getAlternateModeText()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleModeChange}
|
||||
className="ml-1 text-13 text-accent-primary transition-colors duration-200 hover:text-accent-secondary"
|
||||
disabled={disabled}
|
||||
>
|
||||
{getAlternateModeButtonText()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Input } from "../form-fields/input";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export type TAuthInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
error?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
errorClassName?: string;
|
||||
};
|
||||
|
||||
const baseContainerClassName = "flex flex-col gap-1.5";
|
||||
|
||||
export function AuthInput({
|
||||
label,
|
||||
error,
|
||||
showPasswordToggle = false,
|
||||
errorClassName = "",
|
||||
className = "",
|
||||
type = "text",
|
||||
autoComplete = "off",
|
||||
...props
|
||||
}: TAuthInputProps) {
|
||||
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-13 font-semibold text-tertiary")}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={cn("relative flex items-center rounded-md border border-strong px-3 py-2 transition-all")}>
|
||||
<Input
|
||||
{...props}
|
||||
type={inputType}
|
||||
autoComplete={autoComplete}
|
||||
className={cn(
|
||||
"h-6 w-full rounded-md border-none p-0 disable-autofill-style placeholder:text-14 placeholder:text-placeholder",
|
||||
{
|
||||
"border-danger-strong": error,
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
{showPasswordToggle && isPasswordType && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className={cn("text-13 text-danger-primary", errorClassName)}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { 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 type TAuthPasswordInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
error?: string;
|
||||
showPasswordStrength?: boolean;
|
||||
showPasswordToggle?: boolean;
|
||||
containerClassName?: string;
|
||||
errorClassName?: string;
|
||||
onPasswordChange?: (password: string) => void;
|
||||
onPasswordStrengthChange?: (strength: E_PASSWORD_STRENGTH) => void;
|
||||
};
|
||||
|
||||
export function AuthPasswordInput({
|
||||
label = "Password",
|
||||
error,
|
||||
showPasswordStrength = true,
|
||||
showPasswordToggle = true,
|
||||
containerClassName = "",
|
||||
errorClassName = "",
|
||||
className = "",
|
||||
value = "",
|
||||
onChange,
|
||||
onPasswordChange,
|
||||
onPasswordStrengthChange,
|
||||
...props
|
||||
}: TAuthPasswordInputProps) {
|
||||
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="off"
|
||||
/>
|
||||
{showPasswordStrength && value && isFocused && (
|
||||
<PasswordStrengthIndicator password={value as string} showCriteria />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 { TAuthInputProps } from "./auth-input";
|
||||
export type { TAuthPasswordInputProps } from "./auth-password-input";
|
||||
export type { TAuthConfirmPasswordInputProps } from "./auth-confirm-password-input";
|
||||
export type { AuthForgotPasswordProps } from "./auth-forgot-password";
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
// types
|
||||
import type { TAvatarSize } from "./helper";
|
||||
import { getSizeInfo, isAValidNumber } from "./helper";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The children of the avatar group.
|
||||
* These should ideally should be `Avatar` components
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The maximum number of avatars to display.
|
||||
* If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars.
|
||||
* @default 2
|
||||
*/
|
||||
max?: number;
|
||||
/**
|
||||
* Whether to show the tooltip or not
|
||||
* @default true
|
||||
*/
|
||||
showTooltip?: boolean;
|
||||
/**
|
||||
* The size of the avatars
|
||||
* Possible values: "sm", "md", "base", "lg"
|
||||
* @default "md"
|
||||
*/
|
||||
size?: TAvatarSize;
|
||||
};
|
||||
|
||||
export function AvatarGroup(props: Props) {
|
||||
const { children, max = 2, showTooltip = true, size = "md" } = props;
|
||||
|
||||
// calculate total length of avatars inside the group
|
||||
const totalAvatars = React.Children.toArray(children).length;
|
||||
|
||||
// if avatars are equal to max + 1, then we need to show the last avatar as well, if avatars are more than max + 1, then we need to show the count of the remaining avatars
|
||||
const maxAvatarsToRender = totalAvatars <= max + 1 ? max + 1 : max;
|
||||
|
||||
// slice the children to the maximum number of avatars
|
||||
const avatars = React.Children.toArray(children).slice(0, maxAvatarsToRender);
|
||||
|
||||
// assign the necessary props from the AvatarGroup component to the Avatar components
|
||||
const avatarsWithUpdatedProps = avatars.map((avatar) => {
|
||||
const updatedProps: Partial<Props> = {
|
||||
showTooltip,
|
||||
size,
|
||||
};
|
||||
|
||||
return React.cloneElement(avatar as React.ReactElement, updatedProps);
|
||||
});
|
||||
|
||||
// get size details based on the size prop
|
||||
const sizeInfo = getSizeInfo(size);
|
||||
|
||||
return (
|
||||
<div className={cn("flex", sizeInfo.spacing)}>
|
||||
{avatarsWithUpdatedProps.map((avatar, index) => (
|
||||
<div key={index} className="rounded-full border border-subtle-1">
|
||||
{avatar}
|
||||
</div>
|
||||
))}
|
||||
{maxAvatarsToRender < totalAvatars && (
|
||||
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center rounded-full border border-subtle-1 bg-accent-subtle text-9 text-accent-primary",
|
||||
{
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
}
|
||||
)}
|
||||
style={
|
||||
isAValidNumber(size)
|
||||
? {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
+{totalAvatars - max}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar } from "./avatar";
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: "Avatar",
|
||||
component: Avatar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { name: "John Doe" },
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: { name: "John Doe" },
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
import type { TAvatarSize } from "./helper";
|
||||
import { getBorderRadius, getSizeInfo, isAValidNumber } from "./helper";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The name of the avatar which will be displayed on the tooltip
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* The background color if the avatar image fails to load
|
||||
*/
|
||||
fallbackBackgroundColor?: string;
|
||||
/**
|
||||
* The text to display if the avatar image fails to load
|
||||
*/
|
||||
fallbackText?: string;
|
||||
/**
|
||||
* The text color if the avatar image fails to load
|
||||
*/
|
||||
fallbackTextColor?: string;
|
||||
/**
|
||||
* Whether to show the tooltip or not
|
||||
* @default true
|
||||
*/
|
||||
showTooltip?: boolean;
|
||||
/**
|
||||
* The size of the avatars
|
||||
* Possible values: "sm", "md", "base", "lg"
|
||||
* @default "md"
|
||||
*/
|
||||
size?: TAvatarSize;
|
||||
/**
|
||||
* The shape of the avatar
|
||||
* Possible values: "circle", "square"
|
||||
* @default "circle"
|
||||
*/
|
||||
shape?: "circle" | "square";
|
||||
/**
|
||||
* The source of the avatar image
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* The custom CSS class name to apply to the component
|
||||
*/
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Avatar(props: Props) {
|
||||
const {
|
||||
name,
|
||||
fallbackBackgroundColor,
|
||||
fallbackText,
|
||||
fallbackTextColor,
|
||||
showTooltip = true,
|
||||
size = "md",
|
||||
shape = "circle",
|
||||
src,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
// get size details based on the size prop
|
||||
const sizeInfo = getSizeInfo(size);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
|
||||
<div
|
||||
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
})}
|
||||
style={
|
||||
isAValidNumber(size)
|
||||
? {
|
||||
height: `${size}px`,
|
||||
width: `${size}px`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} className={cn("h-full w-full", getBorderRadius(shape), className)} alt={name} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
sizeInfo.fontSize,
|
||||
"grid h-full w-full place-items-center",
|
||||
getBorderRadius(shape),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fallbackBackgroundColor ?? "#028375",
|
||||
color: fallbackTextColor ?? "#ffffff",
|
||||
}}
|
||||
>
|
||||
{name?.[0]?.toUpperCase() ?? fallbackText ?? "?"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
||||
|
||||
/**
|
||||
* Get the size details based on the size prop
|
||||
* @param size The size of the avatar
|
||||
* @returns The size details
|
||||
*/
|
||||
export const getSizeInfo = (size: TAvatarSize) => {
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return {
|
||||
avatarSize: "h-4 w-4",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "md":
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "base":
|
||||
return {
|
||||
avatarSize: "h-6 w-6",
|
||||
fontSize: "text-13",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
case "lg":
|
||||
return {
|
||||
avatarSize: "h-7 w-7",
|
||||
fontSize: "text-13",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-11",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the border radius based on the shape prop
|
||||
* @param shape The shape of the avatar
|
||||
* @returns The border radius
|
||||
*/
|
||||
export const getBorderRadius = (shape: "circle" | "square") => {
|
||||
switch (shape) {
|
||||
case "circle":
|
||||
return "rounded-full";
|
||||
case "square":
|
||||
return "rounded-sm";
|
||||
default:
|
||||
return "rounded-full";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the value is a valid number
|
||||
* @param value The value to check
|
||||
* @returns Whether the value is a valid number or not
|
||||
*/
|
||||
export const isAValidNumber = (value: unknown) => typeof value === "number" && !isNaN(value);
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./avatar-group";
|
||||
export * from "./avatar";
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
import type { TBadgeVariant, TBadgeSizes } from "./helper";
|
||||
import { getIconStyling, getBadgeStyling } 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(function Badge(props: BadgeProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
prependIcon = null,
|
||||
appendIcon = null,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonStyle = 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 };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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-11 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||
md = `px-4 py-1.5 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||
lg = `px-4 py-2 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||
xl = `px-5 py-3 font-medium text-13 rounded-sm 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-on-color bg-accent-primary`,
|
||||
hover: `hover:bg-accent-primary/80`,
|
||||
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
|
||||
},
|
||||
"accent-primary": {
|
||||
default: `bg-accent-subtle text-accent-primary`,
|
||||
hover: `hover:bg-custom-primary-20 hover:text-accent-secondary`,
|
||||
disabled: `cursor-not-allowed !text-accent-primary/60`,
|
||||
},
|
||||
"outline-primary": {
|
||||
default: `text-accent-primary bg-surface-1 border border-accent-strong`,
|
||||
hover: `hover:border-accent-strong-80 hover:bg-accent-subtle`,
|
||||
disabled: `cursor-not-allowed !text-accent-primary/60 !border-accent-strong-60 `,
|
||||
},
|
||||
|
||||
neutral: {
|
||||
default: `text-custom-background-100 bg-layer-1 border border-subtle`,
|
||||
hover: `hover:bg-layer-1`,
|
||||
disabled: `cursor-not-allowed bg-subtle-1 !text-placeholder`,
|
||||
},
|
||||
"accent-neutral": {
|
||||
default: `text-secondary bg-layer-1`,
|
||||
hover: `hover:bg-subtle-1 hover:text-primary`,
|
||||
disabled: `cursor-not-allowed !text-placeholder`,
|
||||
},
|
||||
"outline-neutral": {
|
||||
default: `text-secondary bg-surface-1 border border-subtle`,
|
||||
hover: `hover:text-primary hover:bg-subtle-1`,
|
||||
disabled: `cursor-not-allowed !text-placeholder`,
|
||||
},
|
||||
|
||||
success: {
|
||||
default: `text-on-color bg-green-500`,
|
||||
hover: `hover:bg-green-600`,
|
||||
disabled: `cursor-not-allowed !bg-green-300`,
|
||||
},
|
||||
"accent-success": {
|
||||
default: `text-success-primary bg-green-50`,
|
||||
hover: `hover:bg-green-100 hover:text-success-primary`,
|
||||
disabled: `cursor-not-allowed text-success-secondary!`,
|
||||
},
|
||||
"outline-success": {
|
||||
default: `text-success-primary bg-surface-1 border border-success-strong`,
|
||||
hover: `hover:text-success-primary hover:bg-green-50`,
|
||||
disabled: `cursor-not-allowed text-success-secondary! border-success-subtle`,
|
||||
},
|
||||
|
||||
warning: {
|
||||
default: `text-on-color 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-surface-1 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-on-color bg-red-500`,
|
||||
hover: `hover:bg-red-600`,
|
||||
disabled: `cursor-not-allowed !bg-red-300`,
|
||||
},
|
||||
"accent-destructive": {
|
||||
default: `text-danger-primary bg-red-50`,
|
||||
hover: `hover:bg-red-100 hover:text-danger-primary`,
|
||||
disabled: `cursor-not-allowed text-danger-secondary!`,
|
||||
},
|
||||
"outline-destructive": {
|
||||
default: `text-danger-primary bg-surface-1 border border-danger-strong`,
|
||||
hover: `hover:text-danger-primary hover:bg-red-50`,
|
||||
disabled: `cursor-not-allowed text-danger-secondary! border-danger-subtle`,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./badge";
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { 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
|
||||
function BreadcrumbBlock(props: TBreadcrumbBlockProps) {
|
||||
const { label, icon, disableTooltip = false } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs.ItemWrapper label={label} disableTooltip={disableTooltip}>
|
||||
{icon && <div className="flex size-4 items-center justify-center overflow-hidden !text-16">{icon}</div>}
|
||||
{label && <div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>}
|
||||
</Breadcrumbs.ItemWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Breadcrumbs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
|
||||
<Breadcrumbs.Item
|
||||
key="current"
|
||||
component={<BreadcrumbBlock href="/projects/current" label="Current Project" />}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLoading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomComponent: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item
|
||||
key="custom"
|
||||
component={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-blue-500 size-4 rounded-full" />
|
||||
<span>Custom Component</span>
|
||||
</div>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleItem: Story = {
|
||||
args: {
|
||||
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
|
||||
},
|
||||
};
|
||||
|
||||
export const 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
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { 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 function BreadcrumbItemLoader() {
|
||||
return (
|
||||
<div className="flex h-7 animate-pulse items-center gap-2">
|
||||
<div className="group flex h-full items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium">
|
||||
<span className="h-full w-5 rounded-sm bg-layer-1" />
|
||||
<span className="h-full w-16 rounded-sm bg-layer-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumbs({ className, children, onBack, isLoading = false }: BreadcrumbsProps) {
|
||||
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize(); // Call it initially to set the correct state
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-grow items-center gap-0.5 overflow-hidden", className)}>
|
||||
{!isSmallScreen && (
|
||||
<>
|
||||
{childrenArray.map((child, index) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItemLoader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (React.isValidElement<BreadcrumbItemProps>(child)) {
|
||||
return React.cloneElement(child, {
|
||||
isLast: index === childrenArray.length - 1,
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSmallScreen && childrenArray.length > 1 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5 p-1">
|
||||
{onBack && (
|
||||
<span onClick={onBack} className="text-secondary">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0 text-placeholder" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 p-1">
|
||||
{isLoading ? (
|
||||
<BreadcrumbItemLoader />
|
||||
) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? (
|
||||
React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, {
|
||||
isLast: true,
|
||||
})
|
||||
) : (
|
||||
childrenArray[childrenArray.length - 1]
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isSmallScreen && childrenArray.length === 1 && childrenArray}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// breadcrumb item
|
||||
type BreadcrumbItemProps = {
|
||||
component?: React.ReactNode;
|
||||
showSeparator?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
function BreadcrumbItem(props: BreadcrumbItemProps) {
|
||||
const { component, showSeparator = true, isLast = false } = props;
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-0.5">
|
||||
{component}
|
||||
{showSeparator && !isLast && <BreadcrumbSeparator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// breadcrumb icon
|
||||
type BreadcrumbIconProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function BreadcrumbIcon(props: BreadcrumbIconProps) {
|
||||
const { children, className } = props;
|
||||
return <div className={cn("flex size-4 items-center justify-start overflow-hidden", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
// breadcrumb label
|
||||
type BreadcrumbLabelProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function BreadcrumbLabel(props: BreadcrumbLabelProps) {
|
||||
const { children, className } = props;
|
||||
return (
|
||||
<div className={cn("relative line-clamp-1 block max-w-[150px] truncate overflow-hidden", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// breadcrumb separator
|
||||
type BreadcrumbSeparatorProps = {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showDivider?: boolean;
|
||||
};
|
||||
|
||||
function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
|
||||
const { className, containerClassName, iconClassName, showDivider = false } = props;
|
||||
return (
|
||||
<div className={cn("relative flex h-full items-center justify-center px-1.5 py-1", className)}>
|
||||
{showDivider && <span className="absolute top-0 -left-0.5 h-full w-[1.8px] bg-surface-1" />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-shrink-0 items-center justify-center rounded-sm text-placeholder transition-all",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<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;
|
||||
};
|
||||
|
||||
function BreadcrumbItemWrapper(props: BreadcrumbItemWrapperProps) {
|
||||
const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom" disabled={!label || label === "" || disableTooltip}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-full cursor-default items-center gap-2 rounded-sm px-1.5 py-1 text-13 font-medium",
|
||||
{
|
||||
"text-primary": isLast,
|
||||
"text-tertiary": !isLast,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover hover:text-primary": type === "link" && !isLast,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
Breadcrumbs.Item = BreadcrumbItem;
|
||||
Breadcrumbs.Icon = BreadcrumbIcon;
|
||||
Breadcrumbs.Label = BreadcrumbLabel;
|
||||
Breadcrumbs.Separator = BreadcrumbSeparator;
|
||||
Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper;
|
||||
|
||||
export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper };
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./breadcrumbs";
|
||||
export * from "./navigation-dropdown";
|
||||
export * from "./navigation-search-dropdown";
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TContextMenuItem } from "../dropdowns";
|
||||
import { CustomMenu } from "../dropdowns";
|
||||
import { cn } from "../utils";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
type TBreadcrumbNavigationDropdownProps = {
|
||||
selectedItemKey: string;
|
||||
navigationItems: TContextMenuItem[];
|
||||
navigationDisabled?: boolean;
|
||||
handleOnClick?: () => void;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function 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;
|
||||
|
||||
function NavigationButton() {
|
||||
return (
|
||||
<Tooltip tooltipContent={selectedItem?.title} position="bottom" disabled={isOpen}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (!isLast) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOnClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
|
||||
{
|
||||
"hover:bg-layer-1 hover:text-primary": !isLast,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex text-tertiary @4xl:hidden">...</div>
|
||||
<div className="hidden items-center gap-2 @4xl:flex">
|
||||
{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-sm", {
|
||||
"bg-layer-1": isOpen && !isLast,
|
||||
"hover:bg-layer-1": !isLast,
|
||||
})}
|
||||
containerClassName="p-0"
|
||||
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
|
||||
"text-primary": isOpen,
|
||||
"rotate-90": isOpen || isLast,
|
||||
})}
|
||||
showDivider={!isLast}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placement="bottom-start"
|
||||
className="h-full rounded-sm"
|
||||
customButtonClassName={cn(
|
||||
"group flex h-full cursor-pointer items-center gap-0.5 rounded-sm outline-none hover:bg-surface-2",
|
||||
{
|
||||
"bg-surface-2": 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-placeholder": 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("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.key === selectedItemKey && <CheckIcon className="size-3.5 flex-shrink-0" />}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
import { CustomSearchSelect } from "../dropdowns";
|
||||
import { cn } from "../utils";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
type TBreadcrumbNavigationSearchDropdownProps = {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
selectedItem: string;
|
||||
navigationItems: ICustomSearchSelectOption[];
|
||||
onChange?: (value: string) => void;
|
||||
navigationDisabled?: boolean;
|
||||
isLast?: boolean;
|
||||
handleOnClick?: () => void;
|
||||
disableRootHover?: boolean;
|
||||
shouldTruncate?: boolean;
|
||||
};
|
||||
|
||||
export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) {
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
selectedItem,
|
||||
navigationItems,
|
||||
onChange,
|
||||
navigationDisabled = false,
|
||||
isLast = false,
|
||||
handleOnClick,
|
||||
shouldTruncate = false,
|
||||
} = props;
|
||||
// state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
onOpen={() => {
|
||||
setIsDropdownOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
options={navigationItems}
|
||||
value={selectedItem}
|
||||
onChange={(value: string) => {
|
||||
if (value !== selectedItem) {
|
||||
onChange?.(value);
|
||||
}
|
||||
}}
|
||||
customButton={
|
||||
<>
|
||||
<Tooltip tooltipContent={title} position="bottom">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (!isLast) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOnClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
|
||||
{
|
||||
"hover:bg-layer-1 hover:text-primary": !isLast,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>}
|
||||
<div
|
||||
className={cn("flex gap-2", {
|
||||
"hidden items-center gap-2 @4xl:flex": shouldTruncate,
|
||||
})}
|
||||
>
|
||||
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
|
||||
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Breadcrumbs.Separator
|
||||
className={cn("rounded-r-sm", {
|
||||
"bg-layer-1": isDropdownOpen && !isLast,
|
||||
"hover:bg-layer-1": !isLast,
|
||||
})}
|
||||
containerClassName="p-0"
|
||||
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
|
||||
"text-primary": isDropdownOpen,
|
||||
"rotate-90": isDropdownOpen || isLast,
|
||||
})}
|
||||
showDivider={!isLast}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
disabled={navigationDisabled}
|
||||
className="h-full rounded-sm"
|
||||
customButtonClassName={cn(
|
||||
"group flex h-full cursor-pointer items-center gap-0.5 rounded-sm outline-none hover:bg-surface-2",
|
||||
{
|
||||
"bg-surface-2": isDropdownOpen,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../utils";
|
||||
import type { TButtonVariant, TButtonSizes } from "./helper";
|
||||
import { getIconStyling, getButtonStyling } from "./helper";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: TButtonVariant;
|
||||
size?: TButtonSizes;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
appendIcon?: any;
|
||||
prependIcon?: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef(function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
prependIcon = null,
|
||||
appendIcon = null,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonStyle = getButtonStyling(variant, size, disabled || loading);
|
||||
const buttonIconStyle = getIconStyling(size);
|
||||
|
||||
return (
|
||||
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
|
||||
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
||||
{children}
|
||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "plane-ui-button";
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export type TButtonVariant =
|
||||
| "primary"
|
||||
| "accent-primary"
|
||||
| "outline-primary"
|
||||
| "neutral-primary"
|
||||
| "link-primary"
|
||||
| "danger"
|
||||
| "accent-danger"
|
||||
| "outline-danger"
|
||||
| "link-danger"
|
||||
| "tertiary-danger"
|
||||
| "link-neutral";
|
||||
|
||||
export type TButtonSizes = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export interface IButtonStyling {
|
||||
[key: string]: {
|
||||
default: string;
|
||||
hover: string;
|
||||
pressed: string;
|
||||
disabled: string;
|
||||
};
|
||||
}
|
||||
|
||||
enum buttonSizeStyling {
|
||||
sm = `px-3 py-1.5 font-medium text-11 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
md = `px-4 py-1.5 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
lg = `px-5 py-2 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
xl = `px-5 py-3.5 font-medium text-13 rounded-sm flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||
}
|
||||
|
||||
enum buttonIconStyling {
|
||||
sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||
xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0 ",
|
||||
}
|
||||
|
||||
export const buttonStyling: IButtonStyling = {
|
||||
primary: {
|
||||
default: `text-on-color bg-accent-primary`,
|
||||
hover: `hover:bg-accent-primary/80`,
|
||||
pressed: `focus:text-custom-brand-40 focus:bg-accent-primary/80`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-on-color-disabled`,
|
||||
},
|
||||
"accent-primary": {
|
||||
default: `bg-accent-primary/20 text-accent-primary`,
|
||||
hover: `hover:bg-accent-primary/10 hover:text-accent-secondary`,
|
||||
pressed: `focus:bg-accent-primary/10`,
|
||||
disabled: `cursor-not-allowed !text-accent-primary/60`,
|
||||
},
|
||||
"outline-primary": {
|
||||
default: `text-accent-primary bg-transparent border border-accent-strong`,
|
||||
hover: `hover:bg-accent-primary/20`,
|
||||
pressed: `focus:text-accent-primary focus:bg-accent-primary/30`,
|
||||
disabled: `cursor-not-allowed !text-accent-primary/60 !border-accent-strong-60 `,
|
||||
},
|
||||
"neutral-primary": {
|
||||
default: `text-secondary bg-surface-1 border border-subtle`,
|
||||
hover: `hover:bg-surface-2`,
|
||||
pressed: `focus:text-tertiary focus:bg-surface-2`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
|
||||
},
|
||||
"link-primary": {
|
||||
default: `text-accent-primary bg-surface-1`,
|
||||
hover: `hover:text-accent-secondary`,
|
||||
pressed: `focus:text-accent-primary/80 `,
|
||||
disabled: `cursor-not-allowed !text-accent-primary/60`,
|
||||
},
|
||||
danger: {
|
||||
default: `bg-danger-primary text-on-color`,
|
||||
hover: ` hover:bg-danger-primary-hover`,
|
||||
pressed: `focus:bg-danger-primary-active`,
|
||||
disabled: `cursor-not-allowed bg-layer-disabled! text-disabled!`,
|
||||
},
|
||||
"accent-danger": {
|
||||
default: `text-danger-primary bg-red-50`,
|
||||
hover: `hover:text-danger-primary hover:bg-red-100`,
|
||||
pressed: `focus:text-danger-primary focus:bg-red-100`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
|
||||
},
|
||||
"outline-danger": {
|
||||
default: `bg-layer-2 text-danger-primary border border-danger-strong`,
|
||||
hover: `hover:bg-danger-subtle`,
|
||||
pressed: `focus:bg-danger-subtle-hover`,
|
||||
disabled: `cursor-not-allowed text-disabled! border-subtle-1!`,
|
||||
},
|
||||
"link-danger": {
|
||||
default: `text-danger-primary bg-surface-1`,
|
||||
hover: `hover:text-danger-primary`,
|
||||
pressed: `focus:text-danger-primary`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
|
||||
},
|
||||
"tertiary-danger": {
|
||||
default: `text-danger-primary bg-surface-1 border border-danger-subtle`,
|
||||
hover: `hover:bg-red-50 hover:border-danger-subtle`,
|
||||
pressed: `focus:text-danger-primary`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
|
||||
},
|
||||
"link-neutral": {
|
||||
default: `text-tertiary`,
|
||||
hover: `hover:text-secondary`,
|
||||
pressed: `focus:text-primary`,
|
||||
disabled: `cursor-not-allowed !bg-layer-1 !text-placeholder`,
|
||||
},
|
||||
};
|
||||
|
||||
export const getButtonStyling = (variant: TButtonVariant, size: TButtonSizes, disabled: boolean = false): string => {
|
||||
let tempVariant: string = ``;
|
||||
const currentVariant = buttonStyling[variant];
|
||||
|
||||
tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover} ${
|
||||
currentVariant.pressed
|
||||
}`;
|
||||
|
||||
let tempSize: string = ``;
|
||||
if (size) tempSize = buttonSizeStyling[size];
|
||||
return `${tempVariant} ${tempSize}`;
|
||||
};
|
||||
|
||||
export const getIconStyling = (size: TButtonSizes): string => {
|
||||
let icon: string = ``;
|
||||
if (size) icon = buttonIconStyling[size];
|
||||
return icon;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./button";
|
||||
export * from "./helper";
|
||||
export * from "./toggle-switch";
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Switch } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
|
||||
interface IToggleSwitchProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ToggleSwitch(props: IToggleSwitchProps) {
|
||||
const { value, onChange, label, size = "sm", disabled, className } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-10 flex-shrink-0 cursor-pointer rounded-full border border-subtle bg-layer-1 transition-colors duration-200 ease-in-out focus:outline-none",
|
||||
{
|
||||
"h-4 w-7": size === "sm",
|
||||
"h-5 w-9": size === "md",
|
||||
"bg-accent-primary": value && !disabled,
|
||||
"bg-(--text-color-icon-placeholder)": !value && !disabled,
|
||||
"cursor-not-allowed bg-accent-primary opacity-50": value && disabled,
|
||||
"cursor-not-allowed bg-(--text-color-icon-placeholder) opacity-50": !value && disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block h-5 w-5 transform self-center rounded-full bg-(--text-color-icon-on-color) ring-0 transition duration-200 ease-in-out",
|
||||
{
|
||||
"h-3 w-3 translate-x-3.5": size === "sm" && value,
|
||||
"h-3 w-3 translate-x-0.5": size === "sm" && !value,
|
||||
"h-4 w-4 translate-x-4": size === "md" && value,
|
||||
"h-4 w-4 translate-x-0.5": size === "md" && !value,
|
||||
"translate-x-4": size === "lg" && value,
|
||||
"translate-x-0.5": size === "lg" && !value,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
ToggleSwitch.displayName = "plane-ui-toggle-switch";
|
||||
|
||||
export { ToggleSwitch };
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
import type { TCardDirection, TCardSpacing, TCardVariant } from "./helper";
|
||||
import { ECardDirection, ECardSpacing, ECardVariant, getCardStyle } from "./helper";
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: TCardVariant;
|
||||
spacing?: TCardSpacing;
|
||||
direction?: TCardDirection;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = React.forwardRef(function Card(props: CardProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const {
|
||||
variant = ECardVariant.WITH_SHADOW,
|
||||
direction = ECardDirection.COLUMN,
|
||||
className = "",
|
||||
spacing = ECardSpacing.LG,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const style = getCardStyle(variant, spacing, direction);
|
||||
return (
|
||||
<div ref={ref} className={cn(style, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Card.displayName = "plane-ui-card";
|
||||
|
||||
export { Card, ECardVariant, ECardSpacing, ECardDirection };
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum ECardVariant {
|
||||
WITHOUT_SHADOW = "without-shadow",
|
||||
WITH_SHADOW = "with-shadow",
|
||||
}
|
||||
export enum ECardDirection {
|
||||
ROW = "row",
|
||||
COLUMN = "column",
|
||||
}
|
||||
export enum ECardSpacing {
|
||||
SM = "sm",
|
||||
LG = "lg",
|
||||
}
|
||||
export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW;
|
||||
export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN;
|
||||
export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG;
|
||||
|
||||
export interface ICardProperties {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = "bg-surface-1 rounded-lg border-[0.5px] border-subtle w-full flex flex-col";
|
||||
export const containerStyle: ICardProperties = {
|
||||
[ECardVariant.WITHOUT_SHADOW]: "",
|
||||
[ECardVariant.WITH_SHADOW]: "hover:shadow-raised-200 duration-300",
|
||||
};
|
||||
export const spacings = {
|
||||
[ECardSpacing.SM]: "p-4",
|
||||
[ECardSpacing.LG]: "p-6",
|
||||
};
|
||||
export const directions = {
|
||||
[ECardDirection.ROW]: "flex-row space-x-3",
|
||||
[ECardDirection.COLUMN]: "flex-col space-y-3",
|
||||
};
|
||||
export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) =>
|
||||
DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./card";
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import { DropdownIcon } 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 function CollapsibleButton(props: Props) {
|
||||
const {
|
||||
isOpen,
|
||||
title,
|
||||
hideChevron = false,
|
||||
indicatorElement,
|
||||
actionItemElement,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
ChevronIcon = DropdownIcon,
|
||||
} = props;
|
||||
return (
|
||||
<div className={cn("flex h-12 items-center justify-between gap-3 border-b border-subtle px-2.5 py-3", className)}>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideChevron && (
|
||||
<ChevronIcon
|
||||
className={cn("size-2 text-tertiary duration-300 hover:text-secondary", {
|
||||
"-rotate-90": !isOpen,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("text-14 font-medium text-primary", titleClassName)}>{title}</span>
|
||||
</div>
|
||||
{indicatorElement && indicatorElement}
|
||||
</div>
|
||||
{actionItemElement && isOpen && actionItemElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export type TCollapsibleProps = {
|
||||
title: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
buttonRef?: React.RefObject<HTMLButtonElement>;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export function Collapsible(props: TCollapsibleProps) {
|
||||
const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
|
||||
// state
|
||||
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen !== undefined) {
|
||||
setLocalIsOpen(isOpen);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// handlers
|
||||
const handleOnClick = useCallback(() => {
|
||||
if (isOpen !== undefined) {
|
||||
if (onToggle) onToggle();
|
||||
} else {
|
||||
setLocalIsOpen((prev) => !prev);
|
||||
}
|
||||
}, [isOpen, onToggle]);
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className={className}>
|
||||
<Disclosure.Button ref={buttonRef} className={buttonClassName} onClick={handleOnClick}>
|
||||
{title}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={localIsOpen}
|
||||
enter="transition-all duration-300 ease-in-out"
|
||||
enterFrom="grid-rows-[0fr] opacity-0"
|
||||
enterTo="grid-rows-[1fr] opacity-100"
|
||||
leave="transition-all duration-300 ease-in-out"
|
||||
leaveFrom="grid-rows-[1fr] opacity-100"
|
||||
leaveTo="grid-rows-[0fr] opacity-0"
|
||||
className="grid overflow-hidden"
|
||||
>
|
||||
<Disclosure.Panel static className="min-h-0">
|
||||
{children}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./collapsible";
|
||||
export * from "./collapsible-button";
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker(props: ColorPickerProps) {
|
||||
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="relative flex items-center justify-center">
|
||||
<button
|
||||
className={`size-4 cursor-pointer rounded-full conical-gradient ${className}`}
|
||||
onClick={handleOnClick}
|
||||
aria-label="Open color picker"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="invisible absolute inset-0 size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./color-picker";
|
||||
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Airplay,
|
||||
AlertCircle,
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Anchor,
|
||||
Aperture,
|
||||
Archive,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
Award,
|
||||
BarChart,
|
||||
BarChart2,
|
||||
Battery,
|
||||
BatteryCharging,
|
||||
Bell,
|
||||
BellOff,
|
||||
Book,
|
||||
Bookmark,
|
||||
BookOpen,
|
||||
Box,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
Camera,
|
||||
CameraOff,
|
||||
Cast,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
CloudDrizzle,
|
||||
CloudLightning,
|
||||
CloudOff,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
Code,
|
||||
Codepen,
|
||||
Codesandbox,
|
||||
Coffee,
|
||||
Columns,
|
||||
Command,
|
||||
Compass,
|
||||
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,
|
||||
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,
|
||||
Grid,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Headphones,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Hexagon,
|
||||
Home,
|
||||
Image,
|
||||
Inbox,
|
||||
Info,
|
||||
Instagram,
|
||||
Italic,
|
||||
Key,
|
||||
Layers,
|
||||
Layout,
|
||||
LifeBuoy,
|
||||
Linkedin,
|
||||
List,
|
||||
Loader,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Mail,
|
||||
Map,
|
||||
MapPin,
|
||||
Maximize,
|
||||
Maximize2,
|
||||
Meh,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MicOff,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Minus,
|
||||
MinusCircle,
|
||||
MinusSquare,
|
||||
CircleChevronDown,
|
||||
UsersRound,
|
||||
ToggleLeft,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
LinkIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
NewTabIcon,
|
||||
CheckIcon,
|
||||
SearchIcon,
|
||||
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: CheckIcon },
|
||||
{ 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: CopyIcon },
|
||||
{ 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: NewTabIcon },
|
||||
{ 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: GlobeIcon },
|
||||
{ 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: LinkIcon },
|
||||
{ name: "Link2", element: LinkIcon },
|
||||
{ name: "Linkedin", element: Linkedin },
|
||||
{ name: "List", element: List },
|
||||
{ name: "Loader", element: Loader },
|
||||
{ name: "Lock", element: LockIcon },
|
||||
{ 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: SearchIcon },
|
||||
{ name: "ToggleLeft", element: ToggleLeft },
|
||||
{ name: "User", element: User },
|
||||
{ name: "UsersRound", element: UsersRound },
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./icons";
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Row } from "../row";
|
||||
import type { TRowVariant } from "../row/helper";
|
||||
import { ERowVariant } from "../row/helper";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface ContentWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: TRowVariant;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const DEFAULT_STYLE = "flex flex-col vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto";
|
||||
|
||||
const ContentWrapper = React.forwardRef(function ContentWrapper(
|
||||
props: ContentWrapperProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { variant = ERowVariant.REGULAR, className = "", children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Row
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
className={cn(
|
||||
DEFAULT_STYLE,
|
||||
{
|
||||
"py-page-y": variant === ERowVariant.REGULAR,
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
ContentWrapper.displayName = "plane-ui-wrapper";
|
||||
|
||||
export { ContentWrapper };
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./content-wrapper";
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
href: string;
|
||||
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
children: React.ReactNode;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
export const ControlLink = React.forwardRef(function ControlLink(
|
||||
props: TControlLink,
|
||||
ref: React.ForwardedRef<HTMLAnchorElement>
|
||||
) {
|
||||
const { href, onClick, children, target = "_blank", disabled = false, className, draggable = false, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE;
|
||||
if (!clickCondition) {
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
// if disabled but still has a ref or a className then it has to be rendered without a href
|
||||
if (disabled && (ref || className))
|
||||
return (
|
||||
<a ref={ref} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
// else if just disabled return without the parent wrapper
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
onClick={handleOnClick}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={className}
|
||||
draggable={draggable}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
ControlLink.displayName = "ControlLink";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./control-link";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import React, { forwardRef } from "react";
|
||||
// helpers
|
||||
import { cn } from "./utils";
|
||||
|
||||
interface IDragHandle {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DragHandle = forwardRef(function DragHandle(
|
||||
props: IDragHandle,
|
||||
ref: React.ForwardedRef<HTMLButtonElement | null>
|
||||
) {
|
||||
const { className, disabled = false } = props;
|
||||
|
||||
if (disabled) {
|
||||
return <div className="h-[18px] w-[14px]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex flex-shrink-0 cursor-grab rounded-sm bg-surface-2 p-0.5 text-secondary", className)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5 stroke-placeholder" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5 stroke-placeholder" />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
DragHandle.displayName = "DragHandle";
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
type Props = {
|
||||
isVisible: boolean;
|
||||
classNames?: string;
|
||||
};
|
||||
|
||||
export function DropIndicator(props: Props) {
|
||||
const { isVisible, classNames = "" } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`relative block h-[2px] w-full before:relative before:top-[-2px] before:left-0 before:block before:h-[6px] before:w-[6px] before:rounded-sm after:relative after:top-[-8px] after:left-[calc(100%-6px)] after:block after:h-[6px] after:w-[6px] after:rounded-sm`,
|
||||
{
|
||||
"bg-accent-primary before:bg-accent-primary after:bg-accent-primary": isVisible,
|
||||
},
|
||||
classNames
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
// helper
|
||||
import { cn } from "../../utils";
|
||||
import type { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
|
||||
|
||||
export function DropdownButton(props: IMultiSelectDropdownButton | ISingleSelectDropdownButton) {
|
||||
const {
|
||||
isOpen,
|
||||
buttonContent,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
handleOnClick,
|
||||
value,
|
||||
setReferenceElement,
|
||||
disabled,
|
||||
} = props;
|
||||
return (
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./input-search";
|
||||
export * from "./button";
|
||||
export * from "./options";
|
||||
export * from "./loader";
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { SearchIcon } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
|
||||
interface IInputSearch {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
updateQuery: (query: string) => void;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
inputClassName?: string;
|
||||
inputPlaceholder?: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export function InputSearch(props: IInputSearch) {
|
||||
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder, isMobile } =
|
||||
props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
updateQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !isMobile) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen, isMobile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2",
|
||||
inputContainerClassName
|
||||
)}
|
||||
>
|
||||
{inputIcon ? <>{inputIcon}</> : <SearchIcon className="h-4 w-4 text-tertiary" aria-hidden="true" />}
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none",
|
||||
inputClassName
|
||||
)}
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
placeholder={inputPlaceholder ?? "Search"}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { range } from "lodash-es";
|
||||
import React from "react";
|
||||
|
||||
export function DropdownOptionsLoader() {
|
||||
return (
|
||||
<div className="flex animate-pulse flex-col gap-1">
|
||||
{range(6).map((index) => (
|
||||
<div key={index} className="flex h-[1.925rem] w-full rounded-sm bg-surface-2 px-1 py-1.5" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
||||
import React from "react";
|
||||
import { CheckIcon } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
// types
|
||||
import type { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
|
||||
// components
|
||||
import { DropdownOptionsLoader, InputSearch } from ".";
|
||||
|
||||
export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSelectDropdownOptions) {
|
||||
const {
|
||||
isOpen,
|
||||
query,
|
||||
setQuery,
|
||||
inputIcon,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputContainerClassName,
|
||||
disableSearch,
|
||||
keyExtractor,
|
||||
options,
|
||||
handleClose,
|
||||
renderItem,
|
||||
loader,
|
||||
isMobile = false,
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
{!disableSearch && (
|
||||
<InputSearch
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
updateQuery={(query) => setQuery(query)}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("max-h-48 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 items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
|
||||
{
|
||||
"bg-layer-1": active,
|
||||
"text-primary": selected,
|
||||
"text-secondary": !selected,
|
||||
},
|
||||
option.className && option.className({ active, selected })
|
||||
)
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{renderItem ? (
|
||||
<>{renderItem({ value: keyExtractor(option), selected, disabled: option.disabled })}</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.value}</span>
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">No matching results</p>
|
||||
)
|
||||
) : loader ? (
|
||||
<> {loader} </>
|
||||
) : (
|
||||
<DropdownOptionsLoader />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import type { Placement } from "@popperjs/core";
|
||||
|
||||
export interface IDropdown {
|
||||
// root props
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
containerClassName?: string | ((isOpen: boolean) => string);
|
||||
tabIndex?: number;
|
||||
placement?: Placement;
|
||||
disabled?: boolean;
|
||||
|
||||
// button props
|
||||
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
|
||||
// input props
|
||||
disableSearch?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
|
||||
// options props
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
optionsContainerClassName?: string;
|
||||
queryArray?: string[];
|
||||
sortByKey?: string;
|
||||
firstItem?: (optionValue: string) => boolean;
|
||||
renderItem?: ({
|
||||
value,
|
||||
selected,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
}) => React.ReactNode;
|
||||
loader?: React.ReactNode;
|
||||
disableSorting?: boolean;
|
||||
}
|
||||
|
||||
export interface TDropdownOption {
|
||||
data: any;
|
||||
value: string;
|
||||
className?: ({ active, selected }: { active: boolean; selected?: boolean }) => string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdown extends IDropdown {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdown extends IDropdown {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface IDropdownButton {
|
||||
isOpen: boolean;
|
||||
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
setReferenceElement: (element: HTMLButtonElement | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownButton extends IDropdownButton {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownButton extends IDropdownButton {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IDropdownOptions {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
|
||||
handleClose?: () => void;
|
||||
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
renderItem:
|
||||
| (({ value, selected, disabled }: { value: string; selected: boolean; disabled?: boolean }) => React.ReactNode)
|
||||
| undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
loader?: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./common";
|
||||
export * from "./multi-select";
|
||||
export * from "./single-select";
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { sortBy } from "lodash-es";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// local imports
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import { cn } from "../utils";
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
import type { IMultiSelectDropdown } from "./dropdown";
|
||||
|
||||
export function MultiSelectDropdown(props: IMultiSelectDropdown) {
|
||||
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-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { sortBy } from "lodash-es";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// local imports
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import { cn } from "../utils";
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
import type { ISingleSelectDropdown } from "./dropdown";
|
||||
|
||||
export function Dropdown(props: ISingleSelectDropdown) {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
containerClassName,
|
||||
tabIndex,
|
||||
placement,
|
||||
disabled,
|
||||
buttonContent,
|
||||
buttonContainerClassName,
|
||||
buttonClassName,
|
||||
disableSearch,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputIcon,
|
||||
inputContainerClassName,
|
||||
keyExtractor,
|
||||
optionsContainerClassName,
|
||||
queryArray,
|
||||
sortByKey,
|
||||
firstItem,
|
||||
renderItem,
|
||||
loader = false,
|
||||
disableSorting,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// handlers
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen?.();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose?.();
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
setQuery?.("");
|
||||
};
|
||||
|
||||
// options
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = queryArray
|
||||
? (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
})
|
||||
: options;
|
||||
|
||||
if (disableSorting || !sortByKey) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
(option) => !(value ?? []).includes(option.data[option.value]),
|
||||
() => sortByKey && sortByKey.toLowerCase(),
|
||||
]);
|
||||
}, [query, options]);
|
||||
|
||||
// hooks
|
||||
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose, true);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
value={value}
|
||||
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-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import type { ElementType, KeyboardEventHandler, ReactNode, Ref } from "react";
|
||||
import React, { Fragment, forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
as?: ElementType | undefined;
|
||||
ref?: Ref<HTMLElement> | undefined;
|
||||
tabIndex?: number | undefined;
|
||||
className?: string | undefined;
|
||||
value?: string | string[] | null;
|
||||
onChange?: (value: any) => void;
|
||||
disabled?: boolean | undefined;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLDivElement> | undefined;
|
||||
multiple?: boolean;
|
||||
renderByDefault?: boolean;
|
||||
button: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ComboDropDown = forwardRef(function ComboDropDown(props: Props, ref) {
|
||||
const { button, renderByDefault = true, children, ...rest } = props;
|
||||
|
||||
const dropDownButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [shouldRender, setShouldRender] = useState(renderByDefault);
|
||||
|
||||
const onHover = () => {
|
||||
setShouldRender(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = dropDownButtonRef.current as any;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
element.addEventListener("mouseenter", onHover);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("mouseenter", onHover);
|
||||
};
|
||||
}, [dropDownButtonRef, shouldRender]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return (
|
||||
<div ref={dropDownButtonRef} className="flex h-full items-center">
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
<Combobox {...rest} ref={ref}>
|
||||
<Combobox.Button as={Fragment}>{button}</Combobox.Button>
|
||||
{children}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
||||
|
||||
const ComboOptions = Combobox.Options;
|
||||
const ComboOption = Combobox.Option;
|
||||
const ComboInput = Combobox.Input;
|
||||
|
||||
ComboDropDown.displayName = "ComboDropDown";
|
||||
|
||||
export { ComboDropDown, ComboOptions, ComboOption, ComboInput };
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./item";
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronRightIcon } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
// types
|
||||
import type { TContextMenuItem } from "./root";
|
||||
import { ContextMenuContext, Portal } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
handleClose: () => void;
|
||||
isActive: boolean;
|
||||
item: TContextMenuItem;
|
||||
};
|
||||
|
||||
export function ContextMenuItem(props: ContextMenuItemProps) {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
// Nested menu state
|
||||
const [isNestedOpen, setIsNestedOpen] = useState(false);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [activeNestedIndex, setActiveNestedIndex] = useState<number>(0);
|
||||
const nestedMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const contextMenuContext = useContext(ContextMenuContext);
|
||||
const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0;
|
||||
const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || [];
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-start",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeNestedMenu = React.useCallback(() => {
|
||||
setIsNestedOpen(false);
|
||||
setActiveNestedIndex(0);
|
||||
}, []);
|
||||
|
||||
// Register this nested menu with the main context
|
||||
React.useEffect(() => {
|
||||
if (contextMenuContext && hasNestedItems) {
|
||||
return contextMenuContext.registerSubmenu(closeNestedMenu);
|
||||
}
|
||||
}, [contextMenuContext, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Toggle nested menu
|
||||
if (!isNestedOpen && contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(!isNestedOpen);
|
||||
} else {
|
||||
// Execute action for regular items
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
handleActiveItem();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Close other submenus and open this one
|
||||
if (contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
nestedItem.action();
|
||||
if (nestedItem.closeOnClick !== false) {
|
||||
handleClose(); // Close the entire context menu
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation for nested items
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isNestedOpen || !hasNestedItems) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const nestedItem = renderedNestedItems[activeNestedIndex];
|
||||
if (!nestedItem.disabled) {
|
||||
handleNestedItemClick(nestedItem);
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
closeNestedMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isNestedOpen && nestedMenuRef.current) {
|
||||
const menuElement = nestedMenuRef.current;
|
||||
menuElement.addEventListener("keydown", handleKeyDown);
|
||||
// Ensure the menu can receive keyboard events
|
||||
menuElement.setAttribute("tabindex", "-1");
|
||||
menuElement.focus();
|
||||
return () => {
|
||||
menuElement.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": isActive,
|
||||
"text-placeholder": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={handleItemClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div className="flex-1">
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasNestedItems && <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="fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11"
|
||||
data-context-submenu="true"
|
||||
>
|
||||
<div ref={nestedMenuRef} className="vertical-scrollbar scrollbar-sm max-h-72 overflow-y-scroll">
|
||||
{renderedNestedItems.map((nestedItem, index) => (
|
||||
<button
|
||||
key={nestedItem.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": index === activeNestedIndex,
|
||||
"text-placeholder": nestedItem.disabled,
|
||||
},
|
||||
nestedItem.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleNestedItemClick(nestedItem, e);
|
||||
}}
|
||||
onMouseEnter={() => setActiveNestedIndex(index)}
|
||||
disabled={nestedItem.disabled}
|
||||
data-context-submenu="true"
|
||||
>
|
||||
{nestedItem.customContent ?? (
|
||||
<>
|
||||
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{nestedItem.title}</h5>
|
||||
{nestedItem.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": nestedItem.disabled,
|
||||
})}
|
||||
>
|
||||
{nestedItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// hooks
|
||||
import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
customContent?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
shouldRender?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
nestedMenuItems?: TContextMenuItem[];
|
||||
};
|
||||
|
||||
// Portal component for nested menus
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
}
|
||||
|
||||
export function Portal({ children, container }: PortalProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
}
|
||||
|
||||
// Context for managing nested menus
|
||||
export const ContextMenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
portalContainer?: Element | null;
|
||||
} | null>(null);
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
portalContainer?: Element | null;
|
||||
};
|
||||
|
||||
function ContextMenuWithoutPortal(props: ContextMenuProps) {
|
||||
const { parentRef, items, portalContainer } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const submenuClosersRef = useRef<Set<() => void>>(new Set());
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const closeAllSubmenus = React.useCallback(() => {
|
||||
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||
}, []);
|
||||
|
||||
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||
submenuClosersRef.current.add(closeSubmenu);
|
||||
return () => {
|
||||
submenuClosersRef.current.delete(closeSubmenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
closeAllSubmenus();
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
||||
// calculate position of context menu
|
||||
useEffect(() => {
|
||||
const parentElement = parentRef.current;
|
||||
const contextMenu = contextMenuRef.current;
|
||||
if (!parentElement || !contextMenu) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (isMobile) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const contextMenuWidth = contextMenu.clientWidth;
|
||||
const contextMenuHeight = contextMenu.clientHeight;
|
||||
|
||||
const clickX = e?.pageX || 0;
|
||||
const clickY = e?.pageY || 0;
|
||||
|
||||
// check if there's enough space at the bottom, otherwise show at the top
|
||||
let top = clickY;
|
||||
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||
|
||||
// check if there's enough space on the right, otherwise show on the left
|
||||
let left = clickX;
|
||||
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideContextMenu = (e: KeyboardEvent) => {
|
||||
if (isOpen && e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||
window.addEventListener("keydown", hideContextMenu);
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
};
|
||||
}, [contextMenuRef, isMobile, isOpen, parentRef, setIsOpen, setPosition]);
|
||||
|
||||
// handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = renderedItems[activeItemIndex];
|
||||
if (!item.disabled) {
|
||||
renderedItems[activeItemIndex].action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// Custom handler for nested menu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if the click is on a nested menu element
|
||||
const isNestedMenuClick = target.closest('[data-context-submenu="true"]');
|
||||
const isMainMenuClick = contextMenuRef.current?.contains(target);
|
||||
|
||||
// Also check if the target itself has the data attribute
|
||||
const isNestedMenuElement = target.hasAttribute("data-context-submenu");
|
||||
|
||||
// If it's a nested menu click, main menu click, or nested menu element, don't close
|
||||
if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
// Use capture phase to ensure we handle the event before other handlers
|
||||
document.addEventListener("mousedown", handleDocumentClick, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed top-0 left-0 z-30 h-screen w-screen cursor-default opacity-0 transition-opacity",
|
||||
{
|
||||
"pointer-events-auto opacity-100": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="vertical-scrollbar fixed scrollbar-sm max-h-72 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
data-context-menu="true"
|
||||
>
|
||||
<ContextMenuContext.Provider value={{ closeAllSubmenus, registerSubmenu, portalContainer }}>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu(props: ContextMenuProps) {
|
||||
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#context-menu-portal");
|
||||
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||
return contextMenu;
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { 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 type {
|
||||
ICustomMenuDropdownProps,
|
||||
ICustomMenuItemProps,
|
||||
ICustomSubMenuProps,
|
||||
ICustomSubMenuTriggerProps,
|
||||
ICustomSubMenuContentProps,
|
||||
} from "./helper";
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Portal({ children, container, asChild = false }: PortalProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
|
||||
if (asChild) {
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(<div data-radix-portal="">{children}</div>, targetContainer);
|
||||
}
|
||||
|
||||
// Context for main menu to communicate with submenus
|
||||
const MenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
} | null>(null);
|
||||
|
||||
function CustomMenu(props: ICustomMenuDropdownProps) {
|
||||
const {
|
||||
ariaLabel,
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
customButton,
|
||||
disabled = false,
|
||||
ellipsis = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noBorder = false,
|
||||
noChevron = false,
|
||||
optionsClassName = "",
|
||||
menuItemsClassName = "",
|
||||
verticalEllipsis = false,
|
||||
portalElement,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
closeOnSelect,
|
||||
openOnHover = false,
|
||||
useCaptureForOutsideClick = false,
|
||||
} = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// refs
|
||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
const closeAllSubmenus = React.useCallback(() => {
|
||||
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||
}, []);
|
||||
|
||||
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||
submenuClosersRef.current.add(closeSubmenu);
|
||||
return () => {
|
||||
submenuClosersRef.current.delete(closeSubmenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const closeDropdown = React.useCallback(() => {
|
||||
if (isOpen) {
|
||||
closeAllSubmenus();
|
||||
onMenuClose?.();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [isOpen, closeAllSubmenus, onMenuClose]);
|
||||
|
||||
const selectActiveItem = () => {
|
||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||
`[data-headlessui-state="active"] button`
|
||||
);
|
||||
activeItem?.click();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
};
|
||||
|
||||
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (openOnHover) openDropdown();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (openOnHover && isOpen) {
|
||||
setTimeout(() => {
|
||||
// Only close if menu is still open
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
}, 150); // Small delay to allow moving to submenu
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
|
||||
|
||||
// Custom handler for submenu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]');
|
||||
const isMainMenuClick = dropdownRef.current?.contains(target);
|
||||
|
||||
// If it's a submenu click or main menu click, don't close
|
||||
if (isSubmenuClick || isMainMenuClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
};
|
||||
}
|
||||
}, [isOpen, closeDropdown, useCaptureForOutsideClick]);
|
||||
|
||||
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 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
{
|
||||
"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-sm p-1 text-secondary outline-none hover:text-primary ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<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 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 ${
|
||||
open ? "text-primary" : "text-secondary"
|
||||
} ${noBorder ? "" : "shadow-sm border border-strong focus:outline-none"} ${
|
||||
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${buttonClassName}`}
|
||||
onClick={handleMenuButtonClick}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <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
|
||||
function SubMenu(props: ICustomSubMenuProps) {
|
||||
const {
|
||||
children,
|
||||
trigger,
|
||||
disabled = false,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
placement = "right-start",
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLSpanElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const menuContext = React.useContext(MenuContext);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
strategy: "fixed", // Use fixed positioning to escape overflow constraints
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeSubmenu = React.useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Register this submenu with the main menu context
|
||||
React.useEffect(() => {
|
||||
if (menuContext) {
|
||||
return menuContext.registerSubmenu(closeSubmenu);
|
||||
}
|
||||
}, [menuContext, closeSubmenu]);
|
||||
|
||||
const toggleSubmenu = () => {
|
||||
if (!disabled) {
|
||||
// Close other submenus when opening this one
|
||||
if (!isOpen && menuContext) {
|
||||
menuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSubmenu();
|
||||
};
|
||||
|
||||
// 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(
|
||||
"flex w-full cursor-pointer items-center justify-between rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
"cursor-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-subtle-1 bg-surface-1 p-1 text-11",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: ICustomMenuItemProps) {
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
const submenuContext = useSubMenu();
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active, close }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
// Close submenu if this item is inside a submenu
|
||||
submenuContext?.closeSubmenu();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenuTrigger(props: ICustomSubMenuTriggerProps) {
|
||||
const { children, disabled = false, className } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
"cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{children}</span>
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenuContent(props: ICustomSubMenuContentProps) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[15] min-w-[12rem] overflow-hidden rounded-md border border-subtle-1 bg-surface-1 p-1 text-11",
|
||||
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 };
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { Info } 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 { CheckIcon, SearchIcon, 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 type { ICustomSearchSelectProps } from "./helper";
|
||||
|
||||
export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
||||
const {
|
||||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
className = "",
|
||||
chevronClassName = "",
|
||||
customButton,
|
||||
placement,
|
||||
disabled = false,
|
||||
footerOption,
|
||||
input = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
multiple = false,
|
||||
noChevron = false,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
optionsClassName = "",
|
||||
value,
|
||||
tabIndex,
|
||||
noResultsMessage = "No matches found",
|
||||
defaultOpen = false,
|
||||
} = props;
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
if (onOpen) onOpen();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative flex-shrink-0 text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...comboboxProps}
|
||||
>
|
||||
{({ open }: { open: boolean }) => {
|
||||
if (open && onOpen) onOpen();
|
||||
|
||||
return (
|
||||
<>
|
||||
{customButton ? (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 text-11",
|
||||
{
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
customButtonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
) : (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong",
|
||||
{
|
||||
"px-3 py-2 text-13": input,
|
||||
"px-2 py-1 text-11": !input,
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && (
|
||||
<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(
|
||||
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="mx-2 flex items-center gap-1.5 rounded-sm border border-subtle px-2">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn("vertical-scrollbar mt-2 scrollbar-xs space-y-1 overflow-y-scroll px-2", {
|
||||
"max-h-96": maxHeight === "2xl",
|
||||
"max-h-80": maxHeight === "xl",
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active,
|
||||
"cursor-not-allowed text-placeholder opacity-60": option.disabled,
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!multiple) closeDropdown();
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <CheckIcon 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-secondary" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
option.tooltip
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{noResultsMessage}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption}
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||
// plane helpers
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
// types
|
||||
import type { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
|
||||
|
||||
// Context to share the close handler with option components
|
||||
const DropdownContext = createContext<() => void>(() => {});
|
||||
|
||||
function CustomSelect(props: ICustomSelectProps) {
|
||||
const {
|
||||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
customButton,
|
||||
disabled = false,
|
||||
input = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noChevron = false,
|
||||
onChange,
|
||||
optionsClassName = "",
|
||||
value,
|
||||
tabIndex,
|
||||
} = props;
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
});
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
}, [referenceElement]);
|
||||
|
||||
const closeDropdown = useCallback(() => setIsOpen(false), []);
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = useCallback(() => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
}, [closeDropdown, isOpen, openDropdown]);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={closeDropdown}>
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange?.(val);
|
||||
closeDropdown();
|
||||
}}
|
||||
className={cn("relative flex-shrink-0 text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{customButton ? (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 rounded text-11 ${
|
||||
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
) : (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded border border-strong",
|
||||
{
|
||||
"px-3 py-2 text-13": input,
|
||||
"px-2 py-1 text-11": !input,
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
)}
|
||||
</>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click>
|
||||
<div
|
||||
className={cn(
|
||||
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div
|
||||
className={cn("space-y-1 overflow-y-scroll", {
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</Combobox>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Option(props: ICustomSelectItemProps) {
|
||||
const { children, value, className } = props;
|
||||
const closeDropdown = useContext(DropdownContext);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// Close dropdown for both new and already-selected options.
|
||||
// Use setTimeout to ensure HeadlessUI's onChange handler fires first for new selections.
|
||||
// For already-selected options, this ensures the dropdown closes since onChange won't fire.
|
||||
setTimeout(() => {
|
||||
closeDropdown();
|
||||
}, 0);
|
||||
}, [closeDropdown]);
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={value}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
"flex cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active,
|
||||
},
|
||||
className
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
{children}
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
|
||||
CustomSelect.Option = Option;
|
||||
|
||||
export { CustomSelect };
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// FIXME: fix this!!!
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
|
||||
type Placement =
|
||||
| "top"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left"
|
||||
| "left-start"
|
||||
| "left-end"
|
||||
| "right"
|
||||
| "right-start"
|
||||
| "right-end";
|
||||
|
||||
export interface IDropdownProps {
|
||||
customButtonClassName?: string;
|
||||
customButtonTabIndex?: number;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
input?: boolean;
|
||||
label?: string | React.ReactNode;
|
||||
maxHeight?: "sm" | "rg" | "md" | "lg" | "xl" | "2xl";
|
||||
noChevron?: boolean;
|
||||
chevronClassName?: string;
|
||||
onOpen?: () => void;
|
||||
optionsClassName?: string;
|
||||
placement?: Placement;
|
||||
tabIndex?: number;
|
||||
useCaptureForOutsideClick?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface IPortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
children: React.ReactNode;
|
||||
ellipsis?: boolean;
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
menuItemsClassName?: string;
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
openOnHover?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSelectProps extends IDropdownProps {
|
||||
children: React.ReactNode;
|
||||
value: any;
|
||||
onChange: any;
|
||||
}
|
||||
|
||||
interface CustomSearchSelectProps {
|
||||
footerOption?: React.ReactNode;
|
||||
onChange: any;
|
||||
onClose?: () => void;
|
||||
noResultsMessage?: string;
|
||||
options?: ICustomSearchSelectOption[];
|
||||
}
|
||||
|
||||
interface SingleValueProps {
|
||||
multiple?: false;
|
||||
value: any;
|
||||
}
|
||||
|
||||
interface MultipleValuesProps {
|
||||
multiple?: true;
|
||||
value: any[] | null;
|
||||
}
|
||||
|
||||
export type ICustomSearchSelectProps = IDropdownProps &
|
||||
CustomSearchSelectProps &
|
||||
(SingleValueProps | MultipleValuesProps);
|
||||
|
||||
export interface ICustomMenuItemProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: (args?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSelectItemProps {
|
||||
children: React.ReactNode;
|
||||
value: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Submenu interfaces
|
||||
export interface ICustomSubMenuProps {
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuTriggerProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./context-menu";
|
||||
export * from "./custom-menu";
|
||||
export * from "./custom-select";
|
||||
export * from "./custom-search-select";
|
||||
export * from "./combo-box";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { 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 function FavoriteStar(props: Props) {
|
||||
const { buttonClassName, iconClassName, onClick, selected } = props;
|
||||
|
||||
return (
|
||||
<button type="button" className={cn("grid h-4 w-4 place-items-center", buttonClassName)} onClick={onClick}>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-4 w-4 text-tertiary transition-all",
|
||||
{
|
||||
"fill-(--color-label-yellow-icon) stroke-(--color-label-yellow-icon)": selected,
|
||||
},
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef(function Checkbox(props: CheckboxProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
checked,
|
||||
indeterminate = false,
|
||||
disabled,
|
||||
containerClassName,
|
||||
iconClassName,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex flex-shrink-0 gap-2", containerClassName)}>
|
||||
<input
|
||||
id={id}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"size-4 shrink-0 cursor-pointer appearance-none rounded-[3px] border focus:outline-1 focus:outline-offset-4 focus:outline-accent-strong",
|
||||
{
|
||||
"cursor-not-allowed border-subtle bg-layer-1": disabled,
|
||||
"border-strong bg-transparent hover:border-strong-1": !disabled,
|
||||
"border-accent-strong-40 hover:border-accent-strong-40 bg-accent-primary hover:bg-accent-primary/80":
|
||||
!disabled && (checked || indeterminate),
|
||||
|
||||
"border-none": checked,
|
||||
},
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
<svg
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 left-1/2 hidden size-4 -translate-x-1/2 -translate-y-1/2 p-0.5 text-on-color outline-none",
|
||||
{
|
||||
block: checked,
|
||||
"text-placeholder 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(
|
||||
"pointer-events-none absolute top-1/2 left-1/2 hidden size-4 -translate-x-1/2 -translate-y-1/2 stroke-white p-0.5 outline-none",
|
||||
{
|
||||
"stroke-placeholder 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 };
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./input";
|
||||
export * from "./textarea";
|
||||
export * from "./input-color-picker";
|
||||
export * from "./checkbox";
|
||||
export * from "./root";
|
||||
export * from "./password";
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import * as React from "react";
|
||||
import * as ColorPicker from "react-color";
|
||||
import type { ColorResult } from "react-color";
|
||||
import { usePopper } from "react-popper";
|
||||
// helpers
|
||||
import { Button } from "../button";
|
||||
import { cn } from "../utils";
|
||||
// components
|
||||
import { Input } from "./input";
|
||||
|
||||
export interface InputColorPickerProps {
|
||||
hasError: boolean;
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
name: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export function InputColorPicker(props: InputColorPickerProps) {
|
||||
const { value, hasError, onChange, name, className, style, placeholder } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "auto",
|
||||
});
|
||||
|
||||
const handleColorChange = (newColor: ColorResult) => {
|
||||
const { hex } = newColor;
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
hasError={hasError}
|
||||
placeholder={placeholder}
|
||||
className={cn("border-[0.5px] border-subtle", className)}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
<Popover as="div" className="absolute top-1/2 right-1 z-10 -translate-y-1/2">
|
||||
{() => (
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button ref={setReferenceElement} variant="neutral-primary" className="border-none !bg-transparent">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-palette"
|
||||
>
|
||||
<circle cx="13.5" cy="6.5" r=".5" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel>
|
||||
<div
|
||||
className="z-10 overflow-hidden rounded-sm border border-subtle bg-surface-1 shadow-raised-200"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<ColorPicker.SketchPicker color={value} onChange={handleColorChange} />
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
mode?: "primary" | "transparent" | "true-transparent";
|
||||
inputSize?: "xs" | "sm" | "md";
|
||||
hasError?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef(function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
mode = "primary",
|
||||
inputSize = "sm",
|
||||
hasError = false,
|
||||
className = "",
|
||||
autoComplete = "off",
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
ref={ref}
|
||||
type={type}
|
||||
name={name}
|
||||
className={cn(
|
||||
"placeholder-tertiary block rounded-md border-subtle-1 bg-layer-2 text-13 focus:outline-none",
|
||||
{
|
||||
"rounded-md border-[0.5px]": mode === "primary",
|
||||
"rounded-sm border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-accent-strong":
|
||||
mode === "transparent",
|
||||
"rounded-sm border-none bg-transparent ring-0": mode === "true-transparent",
|
||||
"border-danger-strong": hasError,
|
||||
"px-1.5 py-1": inputSize === "xs",
|
||||
"px-3 py-2": inputSize === "sm",
|
||||
"p-3": inputSize === "md",
|
||||
},
|
||||
className
|
||||
)}
|
||||
autoComplete={autoComplete}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "form-input-field";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
|
||||
export interface StrengthInfo {
|
||||
message: string;
|
||||
textColor: string;
|
||||
activeFragments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strength information including message, color, and active fragments
|
||||
*/
|
||||
export const getStrengthInfo = (strength: E_PASSWORD_STRENGTH): StrengthInfo => {
|
||||
switch (strength) {
|
||||
case E_PASSWORD_STRENGTH.EMPTY:
|
||||
return {
|
||||
message: "Please enter your password",
|
||||
textColor: "text-primary",
|
||||
activeFragments: 0,
|
||||
};
|
||||
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID:
|
||||
return {
|
||||
message: "Password is too short",
|
||||
textColor: "text-danger-primary",
|
||||
activeFragments: 1,
|
||||
};
|
||||
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID:
|
||||
return {
|
||||
message: "Password does not meet security requirements",
|
||||
textColor: "text-orange-500",
|
||||
activeFragments: 2,
|
||||
};
|
||||
case E_PASSWORD_STRENGTH.STRENGTH_VALID:
|
||||
return {
|
||||
message: "Password meets security requirements",
|
||||
textColor: "text-success-primary",
|
||||
activeFragments: 3,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: "Please enter your password",
|
||||
textColor: "text-primary",
|
||||
activeFragments: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get fragment color based on position and active state
|
||||
*/
|
||||
export const getFragmentColor = (fragmentIndex: number, activeFragments: number): string => {
|
||||
if (fragmentIndex >= activeFragments) {
|
||||
return "bg-layer-1";
|
||||
}
|
||||
|
||||
switch (activeFragments) {
|
||||
case 1:
|
||||
return "bg-danger-primary";
|
||||
case 2:
|
||||
return "bg-orange-500";
|
||||
case 3:
|
||||
return "bg-success-primary";
|
||||
default:
|
||||
return "bg-layer-1";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./indicator";
|
||||
export * from "./helper";
|
||||
export * from "./password-input";
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { 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 function PasswordStrengthIndicator({
|
||||
password,
|
||||
showCriteria = true,
|
||||
isFocused = false,
|
||||
}: PasswordStrengthIndicatorProps) {
|
||||
const strength = getPasswordStrength(password);
|
||||
const criteria = getPasswordCriteria(password);
|
||||
const strengthInfo = getStrengthInfo(strength);
|
||||
|
||||
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
|
||||
|
||||
if ((!password && !showCriteria) || !isPasswordMeterVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3")}>
|
||||
{/* Strength Indicator */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full gap-1 transition-all duration-300 ease-linear">
|
||||
{[0, 1, 2].map((fragmentIndex) => (
|
||||
<div
|
||||
key={fragmentIndex}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-xs transition-all duration-300 ease-in-out",
|
||||
getFragmentColor(fragmentIndex, strengthInfo.activeFragments)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Strength Message */}
|
||||
{password && <p className={cn("!text-13 font-medium", strengthInfo.textColor)}>{strengthInfo.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Criteria list */}
|
||||
{showCriteria && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{criteria.map((criterion) => (
|
||||
<div key={criterion.key} className="flex items-center gap-1.5">
|
||||
<div className="flex items-center justify-center p-0.5">
|
||||
<CircleCheck
|
||||
className={cn("h-3 w-3 flex-shrink-0", {
|
||||
"text-success-primary": criterion.isValid,
|
||||
"text-primary": !criterion.isValid,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn("!text-11", {
|
||||
"text-success-primary": criterion.isValid,
|
||||
"text-primary": !criterion.isValid,
|
||||
})}
|
||||
>
|
||||
{criterion.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Eye, EyeClosed } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TPasswordInputProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showToggle?: boolean;
|
||||
error?: boolean;
|
||||
autoComplete?: React.HTMLInputAutoCompleteAttribute;
|
||||
};
|
||||
|
||||
export function PasswordInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Enter your password",
|
||||
className,
|
||||
showToggle = true,
|
||||
error = false,
|
||||
autoComplete = "off",
|
||||
}: TPasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded-md border bg-surface-1 px-3 py-2 pr-10 text-secondary transition-all duration-200 placeholder:text-placeholder focus:border-transparent focus:ring-2 focus:ring-accent-strong focus:outline-none",
|
||||
{
|
||||
"border-strong": !error,
|
||||
"border-danger-strong": error,
|
||||
},
|
||||
className
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
{showToggle && (
|
||||
<Tooltip tooltipContent={showPassword ? "Hide password" : "Show password"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary transition-colors duration-200 hover:text-primary"
|
||||
>
|
||||
<div className="relative h-4 w-4">
|
||||
<Eye
|
||||
className={cn(
|
||||
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
|
||||
showPassword ? "scale-75 rotate-12 opacity-0" : "scale-100 rotate-0 opacity-100"
|
||||
)}
|
||||
/>
|
||||
<EyeClosed
|
||||
className={cn(
|
||||
"absolute inset-0 h-4 w-4 transition-all duration-300 ease-in-out",
|
||||
showPassword ? "scale-100 rotate-0 opacity-100" : "scale-75 -rotate-12 opacity-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Reusable Label Component
|
||||
interface LabelProps {
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Label({ htmlFor, children, className }: LabelProps) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className={cn("block text-13 font-medium text-primary", className)}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Reusable Form Field Component
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, children, className, optional = false }: FormFieldProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && <span className="text-13 text-placeholder"> (optional)</span>}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reusable Validation Message Component
|
||||
interface ValidationMessageProps {
|
||||
type: "error" | "success";
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ValidationMessage({ type, message, className }: ValidationMessageProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-13",
|
||||
{
|
||||
"text-danger-primary": type === "error",
|
||||
"text-success-primary": type === "success",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useRef } from "react";
|
||||
// helpers
|
||||
import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea";
|
||||
import { cn } from "../utils";
|
||||
// hooks
|
||||
|
||||
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
mode?: "primary" | "transparent" | "true-transparent";
|
||||
textAreaSize?: "xs" | "sm" | "md";
|
||||
hasError?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextArea = React.forwardRef(function TextArea(
|
||||
props: TextAreaProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
value = "",
|
||||
mode = "primary",
|
||||
textAreaSize = "sm",
|
||||
hasError = false,
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
// refs
|
||||
const textAreaRef = useRef<any>(ref);
|
||||
// auto re-size
|
||||
useAutoResizeTextArea(textAreaRef, value);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
ref={textAreaRef}
|
||||
value={value}
|
||||
className={cn(
|
||||
"no-scrollbar w-full bg-layer-2 placeholder-(--text-color-placeholder) outline-none",
|
||||
{
|
||||
"rounded-md border-[0.5px] border-subtle-1": mode === "primary",
|
||||
"focus:ring-theme rounded-sm border-none bg-transparent ring-0 transition-all focus:ring-1":
|
||||
mode === "transparent",
|
||||
"rounded-sm border-none bg-transparent ring-0": mode === "true-transparent",
|
||||
"px-1.5 py-1": textAreaSize === "xs",
|
||||
"px-3 py-2": textAreaSize === "sm",
|
||||
"p-3": textAreaSize === "md",
|
||||
"border-danger-strong": hasError,
|
||||
"bg-danger-subtle": hasError && mode === "primary",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextArea.displayName = "TextArea";
|
||||
|
||||
export { TextArea };
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { ERowVariant, Row } from "../row";
|
||||
import { cn } from "../utils";
|
||||
import type { THeaderVariant } from "./helper";
|
||||
import { EHeaderVariant, getHeaderStyle } from "./helper";
|
||||
|
||||
export interface HeaderProps {
|
||||
variant?: THeaderVariant;
|
||||
setHeight?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const HeaderContext = React.createContext<THeaderVariant | null>(null);
|
||||
|
||||
function Header(props: HeaderProps) {
|
||||
const {
|
||||
variant = EHeaderVariant.PRIMARY,
|
||||
className = "",
|
||||
showOnMobile = true,
|
||||
setHeight = true,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const style = getHeaderStyle(variant, setHeight, showOnMobile);
|
||||
return (
|
||||
<HeaderContext.Provider value={variant}>
|
||||
<Row
|
||||
variant={variant === EHeaderVariant.PRIMARY ? ERowVariant.HUGGING : ERowVariant.REGULAR}
|
||||
className={cn(style, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Row>
|
||||
</HeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftItem(props: HeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[80%] flex-grow flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RightItem(props: HeaderProps) {
|
||||
const variant = React.useContext(HeaderContext);
|
||||
if (variant === undefined) throw new Error("RightItem must be used within Header");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-auto items-center justify-end gap-2",
|
||||
{
|
||||
"items-baseline": variant === EHeaderVariant.TERNARY,
|
||||
},
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Header.LeftItem = LeftItem;
|
||||
Header.RightItem = RightItem;
|
||||
Header.displayName = "plane-ui-header";
|
||||
|
||||
export { Header, EHeaderVariant };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum EHeaderVariant {
|
||||
PRIMARY = "primary",
|
||||
SECONDARY = "secondary",
|
||||
TERNARY = "ternary",
|
||||
}
|
||||
export type THeaderVariant = EHeaderVariant.PRIMARY | EHeaderVariant.SECONDARY | EHeaderVariant.TERNARY;
|
||||
|
||||
export interface IHeaderProperties {
|
||||
[key: string]: string;
|
||||
}
|
||||
export const headerStyle: IHeaderProperties = {
|
||||
[EHeaderVariant.PRIMARY]:
|
||||
"relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-surface-1 bg-surface-1 z-[18]",
|
||||
[EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-subtle justify-between bg-surface-1 z-[15]",
|
||||
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-subtle gap-2 bg-surface-1 z-[12]",
|
||||
};
|
||||
export const minHeights: IHeaderProperties = {
|
||||
[EHeaderVariant.PRIMARY]: "",
|
||||
[EHeaderVariant.SECONDARY]: "min-h-[52px]",
|
||||
[EHeaderVariant.TERNARY]: "",
|
||||
};
|
||||
export const getHeaderStyle = (variant: THeaderVariant, setMinHeight: boolean, showOnMobile: boolean) => {
|
||||
const height = setMinHeight ? minHeights[variant] : "";
|
||||
const display = variant === EHeaderVariant.SECONDARY ? (showOnMobile ? "flex" : "hidden md:flex") : "";
|
||||
return " @container " + headerStyle[variant] + " " + height + " " + display;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./header";
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
export const useAutoResizeTextArea = (
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>,
|
||||
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]);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyDown = {
|
||||
(
|
||||
onOpen: () => void,
|
||||
onClose: () => void,
|
||||
isOpen: boolean,
|
||||
selectActiveItem?: () => void
|
||||
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
if (!isOpen) {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
} else {
|
||||
selectActiveItem && selectActiveItem();
|
||||
}
|
||||
} else if (event.key === "Escape" && isOpen) {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onOpen, onClose]
|
||||
);
|
||||
|
||||
return handleKeyDown;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyPressed = {
|
||||
(
|
||||
onEnterKeyDown: () => void,
|
||||
onEscKeyDown: () => void,
|
||||
stopPropagation?: boolean
|
||||
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
|
||||
const stopEventPropagation = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[stopPropagation]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
stopEventPropagation(event);
|
||||
onEnterKeyDown();
|
||||
} else if (event.key === "Escape") {
|
||||
stopEventPropagation(event);
|
||||
onEscKeyDown();
|
||||
} else if (event.key === "Tab") onEscKeyDown();
|
||||
},
|
||||
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
|
||||
);
|
||||
|
||||
return handleKeyDown;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export const usePlatformOS = () => {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
|
||||
|
||||
return { isMobile };
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./avatar";
|
||||
export * from "./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 "./oauth";
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// plane utils
|
||||
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
|
||||
// plane ui
|
||||
import type { TContextMenuItem } from "../dropdowns/context-menu/root";
|
||||
import { CustomMenu } from "../dropdowns/custom-menu";
|
||||
|
||||
export type TLinkItemBlockProps = {
|
||||
title: string;
|
||||
url: string;
|
||||
createdAt?: Date | string;
|
||||
menuItems?: TContextMenuItem[];
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function LinkItemBlock(props: TLinkItemBlockProps) {
|
||||
// props
|
||||
const { title, url, createdAt, menuItems, onClick } = props;
|
||||
// icons
|
||||
const Icon = getIconForLink(url);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="group flex h-[56px] w-[230px] cursor-pointer items-center gap-4 rounded-md border-[0.5px] border-subtle bg-surface-1 px-4"
|
||||
>
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-sm bg-surface-2 p-2">
|
||||
<Icon className="size-4 stroke-2 text-tertiary group-hover:text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="truncate text-13 font-medium">{title}</div>
|
||||
{createdAt && <div className="text-11 font-medium text-placeholder">{calculateTimeAgo(createdAt)}</div>}
|
||||
</div>
|
||||
{menuItems && (
|
||||
<div className="hidden group-hover:block">
|
||||
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
|
||||
{menuItems.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn("flex w-full items-center gap-2", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./block";
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "./utils";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Loader({ children, className = "" }: Props) {
|
||||
return (
|
||||
<div className={cn("animate-pulse", className)} role="status">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
height?: string;
|
||||
width?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Item({ height = "auto", width = "auto", className = "" }: ItemProps) {
|
||||
return <div className={cn("rounded-md bg-layer-1", className)} style={{ height: height, width: width }} />;
|
||||
}
|
||||
|
||||
Loader.Item = Item;
|
||||
|
||||
Loader.displayName = "plane-ui-loader";
|
||||
|
||||
export { Loader };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import React from "react";
|
||||
// components
|
||||
import type { TButtonVariant } from "@plane/propel/button";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { cn } from "../utils";
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
import { ModalCore } from "./modal-core";
|
||||
// constants
|
||||
// helpers
|
||||
|
||||
export type TModalVariant = "danger" | "primary";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode | string;
|
||||
handleClose: () => void;
|
||||
handleSubmit: () => void;
|
||||
hideIcon?: boolean;
|
||||
isSubmitting: boolean;
|
||||
isOpen: boolean;
|
||||
position?: EModalPosition;
|
||||
primaryButtonText?: {
|
||||
loading: string;
|
||||
default: string;
|
||||
};
|
||||
secondaryButtonText?: string;
|
||||
title: string;
|
||||
variant?: TModalVariant;
|
||||
width?: EModalWidth;
|
||||
customIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
|
||||
danger: AlertTriangle,
|
||||
primary: Info,
|
||||
};
|
||||
|
||||
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
|
||||
danger: "error-fill",
|
||||
primary: "primary",
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<TModalVariant, string> = {
|
||||
danger: "bg-danger-subtle text-danger-primary",
|
||||
primary: "bg-accent-primary/20 text-accent-primary",
|
||||
};
|
||||
|
||||
export function AlertModalCore(props: Props) {
|
||||
const {
|
||||
content,
|
||||
handleClose,
|
||||
handleSubmit,
|
||||
hideIcon = false,
|
||||
isSubmitting,
|
||||
isOpen,
|
||||
position = EModalPosition.CENTER,
|
||||
primaryButtonText = {
|
||||
loading: "Deleting",
|
||||
default: "Delete",
|
||||
},
|
||||
secondaryButtonText = "Cancel",
|
||||
title,
|
||||
variant = "danger",
|
||||
width = EModalWidth.XL,
|
||||
customIcon,
|
||||
} = props;
|
||||
|
||||
const Icon = VARIANT_ICONS[variant];
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={position} width={width}>
|
||||
<div className="flex flex-col items-center gap-4 p-5 sm:flex-row sm:items-start">
|
||||
{!hideIcon && (
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-12 flex-shrink-0 place-items-center rounded-full sm:size-10",
|
||||
VARIANT_CLASSES[variant]
|
||||
)}
|
||||
>
|
||||
{customIcon ? <>{customIcon}</> : <Icon className="size-5" aria-hidden="true" />}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-16 font-medium">{title}</h3>
|
||||
<p className="mt-1 text-13 text-secondary">{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 border-t-[0.5px] border-subtle px-5 py-4 sm:flex-row sm:justify-end">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
<Button variant={BUTTON_VARIANTS[variant]} tabIndex={1} onClick={handleSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
SM = "sm:max-w-sm",
|
||||
MD = "sm:max-w-md",
|
||||
LG = "sm:max-w-lg",
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
VXL = "sm:max-w-5xl",
|
||||
VIXL = "sm:max-w-6xl",
|
||||
VIIXL = "sm:max-w-7xl",
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./alert-modal";
|
||||
export * from "./constants";
|
||||
export * from "./modal-core";
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
// constants
|
||||
import { cn } from "../utils";
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
// helpers
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
handleClose?: () => void;
|
||||
isOpen: boolean;
|
||||
position?: EModalPosition;
|
||||
width?: EModalWidth;
|
||||
className?: string;
|
||||
};
|
||||
export function ModalCore(props: Props) {
|
||||
const {
|
||||
children,
|
||||
handleClose,
|
||||
isOpen,
|
||||
position = EModalPosition.CENTER,
|
||||
width = EModalWidth.XXL,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={() => handleClose && handleClose()}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className={position}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
"relative w-full transform rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all",
|
||||
width,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./oauth-options";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface OAuthButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OAuthButton = React.forwardRef(function OAuthButton(
|
||||
props: OAuthButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
const { text, icon, compact = false, className = "", ...rest } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-onboarding-background-200 hover:bg-onboarding-background-300 flex h-9 w-full items-center justify-center gap-2 rounded-md border border-strong px-4 py-2.5 text-13 font-medium text-primary duration-300",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
{!compact && (
|
||||
<span className="flex flex-grow items-center justify-center text-body-sm-regular transition-opacity duration-300">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
OAuthButton.displayName = "plane-ui-oauth-button";
|
||||
|
||||
export { OAuthButton };
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils";
|
||||
import { OAuthButton } from "./oauth-button";
|
||||
|
||||
export type TOAuthOption = {
|
||||
id: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type OAuthOptionsProps = {
|
||||
options: TOAuthOption[];
|
||||
compact?: boolean;
|
||||
showDivider?: boolean;
|
||||
dividerLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function OAuthOptions(props: OAuthOptionsProps) {
|
||||
const {
|
||||
options,
|
||||
compact = false,
|
||||
showDivider = true,
|
||||
dividerLabel = "or",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
} = props;
|
||||
|
||||
// Filter enabled options
|
||||
const enabledOptions = options.filter((option) => option.enabled !== false);
|
||||
|
||||
if (enabledOptions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", containerClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 overflow-hidden transition-all duration-500 ease-in-out",
|
||||
compact ? "flex-row" : "flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{enabledOptions.map((option) => (
|
||||
<OAuthButton
|
||||
key={option.id}
|
||||
text={option.text}
|
||||
icon={option.icon}
|
||||
onClick={option.onClick}
|
||||
compact={compact}
|
||||
className="transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showDivider && (
|
||||
<div className="mt-4 flex items-center transition-all duration-300">
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
<p className="mx-3 flex-shrink-0 text-center text-13 text-placeholder transition-colors duration-300">
|
||||
{dividerLabel}
|
||||
</p>
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./popover";
|
||||
export * from "./popover-menu";
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { PopoverMenu } from "./popover-menu";
|
||||
|
||||
type TPopoverMenu = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const meta: Meta<typeof PopoverMenu<TPopoverMenu>> = {
|
||||
title: "Components/PopoverMenu",
|
||||
component: PopoverMenu,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
popperPosition: "bottom-start",
|
||||
panelClassName: "rounded-sm bg-gray-100 p-2",
|
||||
data: [
|
||||
{ id: 1, name: "John Doe" },
|
||||
{ id: 2, name: "Jane Doe" },
|
||||
{ id: 3, name: "John Smith" },
|
||||
{ id: 4, name: "Jane Smith" },
|
||||
],
|
||||
keyExtractor: (item, index: number) => `${item.id}-${index}`,
|
||||
render: (item: TPopoverMenu) => (
|
||||
<div className="text-gray-600 hover:text-gray-700 hover:bg-gray-200 cursor-pointer rounded-xs px-1.5 py-0.5 text-13 capitalize transition-all">
|
||||
{item.name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PopoverMenu<TPopoverMenu>>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
// components
|
||||
import { cn } from "../utils";
|
||||
import { Popover } from "./popover";
|
||||
// helpers
|
||||
// types
|
||||
import type { TPopoverMenu } from "./types";
|
||||
|
||||
export function PopoverMenu<T>(props: TPopoverMenu<T>) {
|
||||
const {
|
||||
popperPosition = "bottom-end",
|
||||
popperPadding = 0,
|
||||
buttonClassName = "",
|
||||
button,
|
||||
disabled,
|
||||
panelClassName = "",
|
||||
data,
|
||||
popoverClassName = "",
|
||||
keyExtractor,
|
||||
render,
|
||||
popoverButtonRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
popperPosition={popperPosition}
|
||||
popperPadding={popperPadding}
|
||||
buttonClassName={buttonClassName}
|
||||
button={button}
|
||||
disabled={disabled}
|
||||
panelClassName={cn(
|
||||
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
|
||||
panelClassName
|
||||
)}
|
||||
popoverClassName={popoverClassName}
|
||||
popoverButtonRef={popoverButtonRef}
|
||||
>
|
||||
<Fragment>
|
||||
{data.map((item, index) => (
|
||||
<Fragment key={keyExtractor(item, index)}>{render(item, index)}</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Popover } from "./popover";
|
||||
|
||||
const meta: Meta<typeof Popover> = {
|
||||
title: "Popover",
|
||||
component: Popover,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
// types
|
||||
type Story = StoryObj<typeof Popover>;
|
||||
|
||||
// data
|
||||
|
||||
// components
|
||||
const RenderCustomPopoverComponent = (
|
||||
<div className="space-y-2">
|
||||
<div className="text-gray-500 text-13 font-medium">Your custom component</div>
|
||||
<div>
|
||||
{["option1", "option2", "option3"].map((option) => (
|
||||
<div
|
||||
key={option}
|
||||
className="text-gray-600 hover:text-gray-700 hover:bg-gray-200 cursor-pointer rounded-xs px-1.5 py-0.5 text-13 capitalize transition-all"
|
||||
>
|
||||
{option}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// stories
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
popperPosition: "bottom-start",
|
||||
panelClassName: "rounded-sm bg-gray-100 p-2",
|
||||
children: RenderCustomPopoverComponent,
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomMenuButton: Story = {
|
||||
args: {
|
||||
popperPosition: "bottom-start",
|
||||
button: (
|
||||
<div className="bg-gray-100 hover:bg-gray-200 rounded-sm p-2 text-13 font-medium transition-all">
|
||||
Custom Menu Button
|
||||
</div>
|
||||
),
|
||||
panelClassName: "rounded-sm bg-gray-100 p-2",
|
||||
children: RenderCustomPopoverComponent,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Popover as HeadlessReactPopover, Transition } from "@headlessui/react";
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
// types
|
||||
import type { TPopover } from "./types";
|
||||
|
||||
export function Popover(props: TPopover) {
|
||||
const {
|
||||
popperPosition = "bottom-end",
|
||||
popperPadding = 0,
|
||||
buttonClassName = "",
|
||||
popoverClassName = "",
|
||||
button,
|
||||
disabled = false,
|
||||
panelClassName = "",
|
||||
children,
|
||||
popoverButtonRef,
|
||||
buttonRefClassName = "",
|
||||
} = props;
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// react-popper derived values
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: popperPosition,
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: popperPadding,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
|
||||
<div ref={setReferenceElement} className={cn("w-full", buttonRefClassName)}>
|
||||
<HeadlessReactPopover.Button
|
||||
ref={popoverButtonRef as Ref<HTMLButtonElement>}
|
||||
className={cn(
|
||||
{
|
||||
"flex h-6 w-6 items-center justify-center rounded-sm bg-surface-2 text-14 transition-all hover:bg-layer-1":
|
||||
!button,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{button ? button : <EllipsisVertical className="h-3 w-3" />}
|
||||
</HeadlessReactPopover.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<HeadlessReactPopover.Panel
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn("absolute top-full left-0 z-20 mt-2 w-screen max-w-xs", panelClassName)}
|
||||
>
|
||||
{children}
|
||||
</HeadlessReactPopover.Panel>
|
||||
</Transition>
|
||||
</HeadlessReactPopover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Placement } from "@popperjs/core";
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
|
||||
export type TPopoverButtonDefaultOptions = {
|
||||
// button and button styling
|
||||
button?: ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonRefClassName?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
|
||||
// popper styling
|
||||
popperPosition?: Placement | undefined;
|
||||
popperPadding?: number | undefined;
|
||||
// panel styling
|
||||
panelClassName?: string;
|
||||
popoverClassName?: string;
|
||||
popoverButtonRef?: MutableRefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
export type TPopover = TPopoverDefaultOptions & {
|
||||
// children
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type TPopoverMenu<T> = TPopoverDefaultOptions & {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
render: (item: T, index: number) => ReactNode;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface ICircularProgressIndicator {
|
||||
size: number;
|
||||
percentage: number;
|
||||
strokeWidth?: number;
|
||||
strokeColor?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CircularProgressIndicator(props: ICircularProgressIndicator) {
|
||||
const { size = 40, percentage = 25, strokeWidth = 6, strokeColor = "stroke-success-secondary", children } = props;
|
||||
|
||||
const sqSize = size;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const viewBox = `0 0 ${sqSize} ${sqSize}`;
|
||||
const dashArray = radius * Math.PI * 2;
|
||||
const dashOffset = dashArray - (dashArray * percentage) / 100;
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg width={size} height={size} viewBox={viewBox} fill="none">
|
||||
<circle
|
||||
className="fill-none stroke-(--border-color-strong)"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={`${strokeWidth}px`}
|
||||
style={{ filter: "url(#filter0_bi_377_19141)" }}
|
||||
/>
|
||||
{/* <defs>
|
||||
<filter
|
||||
id="filter0_bi_377_19141"
|
||||
x="-3.57544"
|
||||
y="-3.57422"
|
||||
width="45.2227"
|
||||
height="45.2227"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="2" />
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_377_19141" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_377_19141" result="shape" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.63125 0 0 0 0 0.6625 0 0 0 0 0.75 0 0 0 0.35 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_377_19141" />
|
||||
</filter>
|
||||
</defs> */}
|
||||
<circle
|
||||
className={`fill-none ${strokeColor}`}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={`${strokeWidth}px`}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{
|
||||
strokeDasharray: dashArray,
|
||||
strokeDashoffset: dashOffset,
|
||||
}}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./radial-progress";
|
||||
export * from "./progress-bar";
|
||||
export * from "./linear-progress-indicator";
|
||||
export * from "./circular-progress-indicator";
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "../utils";
|
||||
|
||||
type Props = {
|
||||
data: any;
|
||||
noTooltip?: boolean;
|
||||
inPercentage?: boolean;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
};
|
||||
|
||||
export function LinearProgressIndicator({
|
||||
data,
|
||||
noTooltip = false,
|
||||
inPercentage = false,
|
||||
size = "sm",
|
||||
className = "",
|
||||
barClassName = "",
|
||||
}: Props) {
|
||||
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let progress = 0;
|
||||
|
||||
const bars = data.map((item: any) => {
|
||||
const width = `${(item.value / total) * 100}%`;
|
||||
if (width === "0%") return <></>;
|
||||
const style = {
|
||||
width,
|
||||
backgroundColor: item.color,
|
||||
};
|
||||
progress += item.value;
|
||||
if (noTooltip) return <div style={style} key={item.id} />;
|
||||
else
|
||||
return (
|
||||
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}${inPercentage ? "%" : ""}`}>
|
||||
<div style={style} className={cn("first:rounded-l-xs last:rounded-r-xs", barClassName)} />
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full items-center justify-between gap-[1px] rounded-xs", {
|
||||
"h-2": size === "sm",
|
||||
"h-3": size === "md",
|
||||
"h-3.5": size === "lg",
|
||||
"h-[14px]": size === "xl",
|
||||
})}
|
||||
>
|
||||
<div className={cn("flex h-full w-full gap-[1.5px] rounded-xs bg-surface-2 p-[2px]", className)}>{bars}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
maxValue?: number;
|
||||
value?: number;
|
||||
radius?: number;
|
||||
strokeWidth?: number;
|
||||
activeStrokeColor?: string;
|
||||
inactiveStrokeColor?: string;
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
maxValue = 0,
|
||||
value = 0,
|
||||
radius = 8,
|
||||
strokeWidth = 2,
|
||||
activeStrokeColor = "#3e98c7",
|
||||
inactiveStrokeColor = "#ddd",
|
||||
}: Props) {
|
||||
// PIE Calc Fn
|
||||
const generatePie = (value: any) => {
|
||||
const x = radius - Math.cos((2 * Math.PI) / (100 / value)) * radius;
|
||||
const y = radius + Math.sin((2 * Math.PI) / (100 / value)) * radius;
|
||||
const long = value <= 50 ? 0 : 1;
|
||||
const d = `M${radius} ${radius} L${radius} ${0} A${radius} ${radius} 0 ${long} 1 ${y} ${x} Z`;
|
||||
|
||||
return d;
|
||||
};
|
||||
|
||||
// ---- PIE Area Calc --------
|
||||
const calculatePieValue = (numberOfBars: any) => {
|
||||
const angle = 360 / numberOfBars;
|
||||
const pieValue = Math.floor(angle / 4);
|
||||
return pieValue < 1 ? 1 : Math.floor(angle / 4);
|
||||
};
|
||||
|
||||
// ---- PIE Render Fn --------
|
||||
const renderPie = (i: any) => {
|
||||
const DIRECTION = -1;
|
||||
// Rotation Calc
|
||||
const primaryRotationAngle = (maxValue - 1) * (360 / maxValue);
|
||||
const rotationAngle = -1 * DIRECTION * primaryRotationAngle + i * DIRECTION * primaryRotationAngle;
|
||||
const rotationTransformation = `rotate(${rotationAngle}, ${radius}, ${radius})`;
|
||||
const pieValue = calculatePieValue(maxValue);
|
||||
const dValue = generatePie(pieValue);
|
||||
const fillColor = value > 0 && i <= value ? activeStrokeColor : inactiveStrokeColor;
|
||||
|
||||
return (
|
||||
<path
|
||||
style={{ opacity: i === 0 ? 0 : 1 }}
|
||||
key={i}
|
||||
d={dValue}
|
||||
fill={fillColor}
|
||||
transform={rotationTransformation}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// combining the Pies
|
||||
const renderOuterCircle = () => [...Array(maxValue + 1)].map((e, i) => renderPie(i));
|
||||
|
||||
return (
|
||||
<svg width={radius * 2} height={radius * 2}>
|
||||
{renderOuterCircle()}
|
||||
<circle r={radius - strokeWidth} cx={radius} cy={radius} className="progress-bar" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface IRadialProgressBar {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function RadialProgressBar(props: IRadialProgressBar) {
|
||||
const { progress } = props;
|
||||
const [circumference, setCircumference] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
setCircumference(circumference);
|
||||
}, []);
|
||||
|
||||
const progressOffset = ((100 - progress) / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative h-4 w-4">
|
||||
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
|
||||
<circle
|
||||
className={"stroke-current opacity-10"}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
/>
|
||||
<circle
|
||||
className={`stroke-current`}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={progressOffset}
|
||||
transform="rotate(-90 50 50)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum ERowVariant {
|
||||
REGULAR = "regular",
|
||||
HUGGING = "hugging",
|
||||
}
|
||||
export type TRowVariant = ERowVariant.REGULAR | ERowVariant.HUGGING;
|
||||
export interface IRowProperties {
|
||||
[key: string]: string;
|
||||
}
|
||||
export const rowStyle: IRowProperties = {
|
||||
[ERowVariant.REGULAR]: "px-page-x",
|
||||
[ERowVariant.HUGGING]: "px-0",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./row";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user