chore: components restructuring and UI improvements. (#5285)

* chore: components restructuring and minor UI improvements.

* chore: minor UI improvements fro icons and member dropdown.

* chore: update issue identifier.

* chore: rename `Issue Extra Property` to `Issue Additional Property`

* chore: fix popovers placement issue on components with overflow.

* chore: add `scrollbar-xs`

* chore: add `xs` size for input and textarea components.

* chore: update `sortable` to return back `movedItem` in the onChange callback.

* chore: minor UI adjustments for radio-select.

* chore: update outside click delay to 1ms.
This commit is contained in:
Prateek Shourya 2024-08-05 20:42:14 +05:30 committed by GitHub
parent 07574b4222
commit 333a989b1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 824 additions and 569 deletions

View file

@ -10,7 +10,12 @@ export * from "./issue_relation";
export * from "./issue_sub_issues";
export * from "./activity/base";
export type TLoader = "init-loader" | "mutation" | "pagination" | undefined;
export type TLoader =
| "init-loader"
| "mutation"
| "pagination"
| "loaded"
| undefined;
export type TGroupedIssues = {
[group_id: string]: string[];
@ -36,4 +41,4 @@ export type TGroupedIssueCount = {
[group_id: string]: number;
};
export type TUnGroupedIssues = string[];
export type TUnGroupedIssues = string[];

View file

@ -25,6 +25,7 @@ export type TBaseIssue = {
parent_id: string | null;
cycle_id: string | null;
module_ids: string[] | null;
type_id: string | null;
created_at: string;
updated_at: string;
@ -48,6 +49,8 @@ export type TIssue = TBaseIssue & {
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.
sourceIssueId?: string;
};
export type TIssueMap = {

View file

@ -34,6 +34,7 @@ export interface IProject {
identifier: string;
anchor: string | null;
is_favorite: boolean;
is_issue_type_enabled: boolean;
is_member: boolean;
is_time_tracking_enabled: boolean;
logo_props: TLogoProps;
@ -58,6 +59,7 @@ export interface IProjectLite {
id: string;
name: string;
identifier: string;
logo_props: TLogoProps;
}
type ProjectPreferences = {

View file

@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
export type TCollapsibleProps = {
title: string | React.ReactNode;
children: React.ReactNode;
buttonRef?: React.RefObject<HTMLButtonElement>;
className?: string;
buttonClassName?: string;
isOpen?: boolean;
@ -12,7 +13,7 @@ export type TCollapsibleProps = {
};
export const Collapsible: FC<TCollapsibleProps> = (props) => {
const { title, children, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
// state
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
@ -33,7 +34,7 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
return (
<Disclosure as="div" className={className}>
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
<Disclosure.Button ref={buttonRef} className={buttonClassName} onClick={handleOnClick}>
{title}
</Disclosure.Button>
<Transition

View file

@ -2,6 +2,7 @@ import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search } from "lucide-react";
import { createPortal } from "react-dom";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
@ -15,6 +16,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
customButtonClassName = "",
buttonClassName = "",
className = "",
chevronClassName = "",
customButton,
placement,
disabled = false,
@ -59,10 +61,12 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => {
setIsOpen(false);
onClose && onClose();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
@ -105,86 +109,93 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
className={cn(
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
{
"px-3 py-2 text-sm": input,
"px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />}
{!noChevron && !disabled && (
<ChevronDown className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
)}
</button>
</Combobox.Button>
)}
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn("mt-2 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",
})}
>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-48 whitespace-nowrap z-20",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div
className={cn("mt-2 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",
})}
>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
{footerOption}
</div>
{footerOption}
</div>
</Combobox.Options>
)}
</Combobox.Options>,
document.body
)}
</>
);
}}

View file

@ -12,6 +12,7 @@ export interface IDropdownProps {
label?: string | JSX.Element;
maxHeight?: "sm" | "rg" | "md" | "lg";
noChevron?: boolean;
chevronClassName?: string;
onOpen?: () => void;
optionsClassName?: string;
placement?: Placement;

View file

@ -149,6 +149,9 @@ import {
Minus,
MinusCircle,
MinusSquare,
CircleChevronDown,
UsersRound,
ToggleLeft,
} from "lucide-react";
export const MATERIAL_ICONS_LIST = [
@ -791,6 +794,7 @@ export const LUCIDE_ICONS_LIST = [
{ name: "Camera", element: Camera },
{ name: "CameraOff", element: CameraOff },
{ name: "Cast", element: Cast },
{ name: "CircleChevronDown", element: CircleChevronDown },
{ name: "Check", element: Check },
{ name: "CheckCircle", element: CheckCircle },
{ name: "CheckSquare", element: CheckSquare },
@ -908,4 +912,6 @@ export const LUCIDE_ICONS_LIST = [
{ name: "Minus", element: Minus },
{ name: "MinusCircle", element: MinusCircle },
{ name: "MinusSquare", element: MinusSquare },
{ name: "ToggleLeft", element: ToggleLeft },
{ name: "UsersRound", element: UsersRound },
];

View file

@ -5,6 +5,7 @@ import useFontFaceObserver from "use-font-face-observer";
import { LUCIDE_ICONS_LIST } from "./icons";
// helpers
import { emojiCodeToUnicode } from "./helpers";
import { cn } from "../../helpers";
type TLogoProps = {
in_use: "emoji" | "icon";
@ -22,10 +23,11 @@ type Props = {
logo: TLogoProps;
size?: number;
type?: "lucide" | "material";
customColor?: string;
};
export const Logo: FC<Props> = (props) => {
const { logo, size = 16, type = "material" } = props;
const { logo, size = 16, customColor, type = "material" } = props;
// destructuring the logo object
const { in_use, emoji, icon } = logo;
@ -72,19 +74,20 @@ export const Logo: FC<Props> = (props) => {
{lucideIcon && (
<lucideIcon.element
style={{
color: color,
color: !customColor ? color : undefined,
height: size,
width: size,
}}
className={cn(customColor)}
/>
)}
</>
) : (
<span
className="material-symbols-rounded"
className={cn("material-symbols-rounded", customColor)}
style={{
fontSize: size,
color: color,
color: !customColor ? color : undefined,
scale: "115%",
}}
>

View file

@ -4,7 +4,7 @@ import { cn } from "../../helpers";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
mode?: "primary" | "transparent" | "true-transparent";
inputSize?: "sm" | "md";
inputSize?: "xs" | "sm" | "md";
hasError?: boolean;
className?: string;
}
@ -26,6 +26,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
mode === "transparent",
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
"border-red-500": hasError,
"px-1.5 py-1": inputSize === "xs",
"px-3 py-2": inputSize === "sm",
"p-3": inputSize === "md",
},

View file

@ -6,12 +6,22 @@ import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea";
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
mode?: "primary" | "transparent";
textAreaSize?: "xs" | "sm" | "md";
hasError?: boolean;
className?: string;
}
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
const {
id,
name,
value = "",
mode = "primary",
textAreaSize = "sm",
hasError = false,
className = "",
...rest
} = props;
// refs
const textAreaRef = useRef<any>(ref);
// auto re-size
@ -24,11 +34,14 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
ref={textAreaRef}
value={value}
className={cn(
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
"no-scrollbar w-full bg-transparent placeholder-custom-text-400 outline-none",
{
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
"focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1":
mode === "transparent",
"px-1.5 py-1": textAreaSize === "xs",
"px-3 py-2": textAreaSize === "sm",
"p-3": textAreaSize === "md",
"border-red-500": hasError,
"bg-red-100": hasError && mode === "primary",
},

View file

@ -1,8 +1,31 @@
import React, { useEffect } from "react";
// TODO: move it to helpers package
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// get all the element with attribute name data-prevent-outside-click
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
// check if the click target is any of the elements with attribute name data-prevent-outside-click
for (let i = 0; i < preventOutsideClickElements.length; i++) {
if (preventOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
return;
}
}
// get all the element with attribute name data-delay-outside-click
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
// check if the click target is any of the elements with attribute name data-delay-outside-click
for (let i = 0; i < delayOutsideClickElements.length; i++) {
if (delayOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 1);
return;
}
}
// else, call the callback immediately
callback();
}
};

View file

@ -5,7 +5,7 @@ import { Draggable } from "./draggable";
type Props<T> = {
data: T[];
render: (item: T, index: number) => React.ReactNode;
onChange: (data: T[]) => void;
onChange: (data: T[], movedItem?: T) => void;
keyExtractor: (item: T, index: number) => string;
containerClassName?: string;
id?: string;
@ -16,13 +16,16 @@ const moveItem = <T,>(
source: T,
destination: T & Record<symbol, string>,
keyExtractor: (item: T, index: number) => string
) => {
): {
newData: T[];
movedItem: T | undefined;
} => {
const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
if (sourceIndex === -1) return data;
if (sourceIndex === -1) return { newData: data, movedItem: undefined };
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
if (destinationIndex === -1) return data;
if (destinationIndex === -1) return { newData: data, movedItem: undefined };
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
@ -41,7 +44,7 @@ const moveItem = <T,>(
newData.splice(adjustedDestinationIndex, 0, movedItem);
return newData;
return { newData, movedItem };
};
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
@ -50,7 +53,13 @@ export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerCl
onDrop({ source, location }) {
const destination = location?.current?.dropTargets[0];
if (!destination) return;
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
const { newData, movedItem } = moveItem(
data,
source.data as T,
destination.data as T & { closestEdge: string },
keyExtractor
);
onChange(newData, movedItem);
},
});

View file

@ -69,7 +69,7 @@ export const setToast = (props: SetToastProps) => {
borderColorClassName,
}: ToastContentProps) =>
props.type === TOAST_TYPE.LOADING ? (
<div className="flex items-center h-[98px] w-[350px]">
<div className="flex items-center h-[98px] w-[350px]" data-prevent-outside-click>
<div
onMouseDown={(e) => {
e.stopPropagation();
@ -96,6 +96,7 @@ export const setToast = (props: SetToastProps) => {
</div>
) : (
<div
data-prevent-outside-click
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();